diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d5578f3c4d..bee25da90b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,17 +10,25 @@ on: - "!*-fdroid" - "!*-armv7a" pull_request: - paths-ignore: - - "apps/ios" - - "apps/multiplatform" - - "blog" - - "docs" - - "fastlane" - - "images" - - "packages" - - "website" - - "README.md" - - "PRIVACY.md" + paths: + - "src/**" + - "apps/simplex-chat/**" + - "apps/simplex-bot/**" + - "apps/simplex-bot-advanced/**" + - "apps/simplex-broadcast-bot/**" + - "apps/simplex-directory-service/**" + - "tests/**" + - "bots/src/**" + - "simplex-chat.cabal" + - "cabal.project" + - "Dockerfile*" + - "scripts/ci/**" + - "scripts/desktop/**" + - ".github/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} # This workflow uses custom actions (prepare-build and prepare-release) defined in: # @@ -111,6 +119,7 @@ jobs: arch: x86_64 runner: "ubuntu-22.04" ghc: "8.10.7" + hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} - os: 22.04 os_underscore: 22_04 @@ -118,24 +127,28 @@ jobs: runner: "ubuntu-22.04" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' - os: 24.04 os_underscore: 24_04 arch: x86_64 runner: "ubuntu-24.04" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237' - os: 22.04 os_underscore: 22_04 arch: aarch64 runner: "ubuntu-22.04-arm" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' - os: 24.04 os_underscore: 24_04 arch: aarch64 runner: "ubuntu-24.04-arm" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' steps: - name: Checkout Code if: matrix.should_run == true @@ -147,6 +160,12 @@ jobs: with: swap-size-gb: 30 + - name: Get UID and GID + id: ids + run: | + echo "uid=$(id -u)" >> $GITHUB_OUTPUT + echo "gid=$(id -g)" >> $GITHUB_OUTPUT + # Otherwise we run out of disk space with Docker build - name: Free disk space if: matrix.should_run == true @@ -176,7 +195,10 @@ jobs: tags: build/${{ matrix.os }}:latest build-args: | TAG=${{ matrix.os }} + HASH=${{ matrix.hash }} GHC=${{ matrix.ghc }} + USER_UID=${{ steps.ids.outputs.uid }} + USER_GID=${{ steps.ids.outputs.gid }} # Docker needs these flags for AppImage build: # --device /dev/fuse @@ -209,7 +231,6 @@ jobs: if: matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | - chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*' cabal clean cabal update cabal build -j --enable-tests @@ -281,6 +302,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | + export ASSETS_DIR='../../assets' scripts/desktop/make-deb-linux.sh - name: Prepare Desktop @@ -306,6 +328,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | + export ASSETS_DIR='../../assets' scripts/desktop/make-appimage-linux.sh - name: Prepare AppImage @@ -356,6 +379,100 @@ jobs: exit 1 fi +# ================================= +# Linux PostgreSQL Library Build +# ================================= + + build-linux-postgres: + name: "ubuntu-22.04-x86_64 (Postgres lib), GHC: ${{ needs.variables.outputs.GHC_VER }}" + needs: [maybe-release, variables] + runs-on: ubuntu-22.04 + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Get UID and GID + id: ids + run: | + echo "uid=$(id -u)" >> $GITHUB_OUTPUT + echo "gid=$(id -g)" >> $GITHUB_OUTPUT + + - name: Free disk space + shell: bash + run: ./scripts/ci/linux_util_free_space.sh + + - name: Restore cached build + uses: actions/cache@v4 + with: + path: | + ~/.cabal/store + dist-newstyle + key: ubuntu-22.04-x86_64-postgres-ghc${{ needs.variables.outputs.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} + + - name: Set up Docker Buildx + uses: simplex-chat/docker-setup-buildx-action@v3 + + - name: Build and cache Docker image + uses: simplex-chat/docker-build-push-action@v6 + with: + context: . + load: true + file: Dockerfile.build + tags: build/22.04:latest + build-args: | + TAG=22.04 + HASH=sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5 + GHC=${{ needs.variables.outputs.GHC_VER }} + USER_UID=${{ steps.ids.outputs.uid }} + USER_GID=${{ steps.ids.outputs.gid }} + + - name: Start container + shell: bash + run: | + docker run -t -d \ + --name builder \ + -v ~/.cabal:/root/.cabal \ + -v /home/runner/work/_temp:/home/runner/work/_temp \ + -v ${{ github.workspace }}:/project \ + build/22.04:latest + + - name: Prepare cabal.project.local + shell: bash + run: | + echo "ignore-project: False" >> cabal.project.local + echo "package direct-sqlcipher" >> cabal.project.local + echo " flags: +openssl" >> cabal.project.local + + - name: Build postgres library + shell: docker exec -t builder sh -eu {0} + run: | + cabal clean + cabal update + scripts/desktop/build-lib-linux.sh postgres + + - name: Copy libs from container + shell: bash + run: | + ARCH=x86_64 + GHC_VER=${{ needs.variables.outputs.GHC_VER }} + BUILD_DIR=$(echo dist-newstyle/build/${ARCH}-linux/ghc-${GHC_VER}/simplex-chat-*) + mkdir -p postgres-libs + cp ${BUILD_DIR}/build/libsimplex.so postgres-libs/ + cp ${BUILD_DIR}/build/deps/* postgres-libs/ + + - name: Upload postgres libs artifact + uses: actions/upload-artifact@v4 + with: + name: simplex-libs-linux-postgres-x86_64 + path: postgres-libs/ + + - name: Fix permissions for cache + shell: bash + run: | + sudo chmod -R 777 dist-newstyle ~/.cabal + sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + # ========================= # MacOS Build # ========================= @@ -434,7 +551,8 @@ jobs: APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }} APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }} run: | - scripts/build-desktop-mac.sh + export ASSETS_DIR='../../assets' + scripts/ci/build-desktop-mac.sh path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg) echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT @@ -560,7 +678,7 @@ jobs: export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) scripts/desktop/build-lib-windows.sh cd apps/multiplatform - ./gradlew packageMsi + ./gradlew -Psimplex.assets.dir=../../assets packageMsi rm -rf dist-newstyle/src/direct-sq* path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') echo "package_path=$path" >> $GITHUB_OUTPUT @@ -575,3 +693,110 @@ jobs: bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }} github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} + +# ========================= +# NodeJS libs release +# ========================= + +# Downloads Desktop builds, extracts and archives libraries for NodeJS addon. +# Depends on Linux/MacOS, executes only on release. + +# Secrets: +# ------- +# NODEJS_REPO_TOKEN +# Only select repositories: simplex-chat-libs +# Permissions: +# * Contents (Read and Write) + + release-nodejs-libs: + runs-on: ubuntu-latest + needs: [build-linux, build-linux-postgres, build-macos] + if: startsWith(github.ref, 'refs/tags/v') && (!cancelled()) + steps: + - name: Checkout current repository + uses: actions/checkout@v6 + + - name: Install packages for archiving + run: sudo apt install -y msitools gcc-mingw-w64 + + - name: Download postgres libs artifact + if: needs.build-linux-postgres.result == 'success' + uses: actions/download-artifact@v4 + with: + name: simplex-libs-linux-postgres-x86_64 + path: ${{ runner.temp }}/postgres-libs + + - name: Build archives + run: | + INIT_DIR='${{ runner.temp }}/artifacts' + RELEASE_DIR='${{ runner.temp }}/release-assets' + TAG='${{ github.ref_name }}' + URL='https://github.com/${{ github.repository }}/releases/download' + PREFIX='${{ github.event.repository.name }}-libs' + # Windows-specific + FILE_URL='https://raw.githubusercontent.com/${{ github.repository }}/refs/tags/${{ github.ref_name }}' + + # Setup directories + mkdir "$INIT_DIR" "$RELEASE_DIR" && cd "$INIT_DIR" + + # Downlaod desktop release + curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-ubuntu-22_04-x86_64.deb" -o linux.deb + curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-macos-aarch64.dmg" -o macos-aarch64.dmg + curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-macos-x86_64.dmg" -o macos-x86_64.dmg + curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-windows-x86_64.msi" -o windows-x86_64.msi + + # Linux + # ----- + # Extract libraries + dpkg-deb -R linux.deb linux-out/ && cd linux-out/opt/simplex/lib/app/resources + # Preprare directory + mkdir libs && cp *.so libs/ + # Archive + zip -r "${PREFIX}-linux-x86_64.zip" libs + # Back to original dir + mv "${PREFIX}-linux-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" + + # MacOS: aarch64 + # -------------- + 7z x macos-aarch64.dmg -omacos1-out/ && cd macos1-out/SimpleX/SimpleX.app/Contents/app/resources/ + mkdir libs && cp *.dylib libs/ + zip -r "${PREFIX}-macos-aarch64.zip" libs + mv "${PREFIX}-macos-aarch64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" + + # Macos: x86_64 + # ------------- + 7z x macos-x86_64.dmg -omacos2-out/ && cd macos2-out/SimpleX/SimpleX.app/Contents/app/resources/ + mkdir libs && cp *.dylib libs/ + zip -r "${PREFIX}-macos-x86_64.zip" libs + mv "${PREFIX}-macos-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" + + # Windows: x86_64 + # --------------- + msiextract windows-x86_64.msi -C windows-out && cd windows-out/SimpleX/app/resources + + # We need to generate library that exports symbols from Windows dll + curl --proto '=https' --tlsv1.2 -sSf -LO "${FILE_URL}/libsimplex.dll.def" + x86_64-w64-mingw32-dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll + + mkdir libs && cp *.dll *.lib libs/ + zip -r "${PREFIX}-windows-x86_64.zip" libs + mv "${PREFIX}-windows-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" + + # Linux PostgreSQL (only if postgres build succeeded) + # ------------------------------------------------- + POSTGRES_LIBS='${{ runner.temp }}/postgres-libs' + if [ -d "$POSTGRES_LIBS" ]; then + mkdir -p linux-postgres/libs + cp "${POSTGRES_LIBS}"/*.so linux-postgres/libs/ + cd linux-postgres + zip -r "${PREFIX}-linux-x86_64-postgres.zip" libs + mv "${PREFIX}-linux-x86_64-postgres.zip" "$RELEASE_DIR" && cd "$INIT_DIR" + fi + + - name: Create release in libs repo and upload artifacts + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ github.ref_name }} + files: ${{ runner.temp }}/release-assets/* + token: ${{ secrets.NODEJS_REPO_TOKEN }} diff --git a/.gitignore b/.gitignore index 4560272980..7bd3d04e59 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,9 @@ website/translations.json website/src/img/images/ website/src/images/ website/src/js/lottie.min.js +website/src/js/ethers* +website/src/file-assets/ +website/src/link-images/ website/src/privacy.md # Generated files website/package/generated* @@ -80,3 +83,4 @@ website/.cache website/test/stubs-layout-cache/_includes/*.js apps/android/app/release apps/multiplatform/.kotlin/sessions + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..8487f0a53c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,552 @@ +# Release History + +## v6.5 + +30 April, 2026 + +Public channels - speak freely! +- Reliability: many relays per channel. +- Ownership: you can run your own relays. +- Security: owners hold channel keys. +- Privacy: for owners and subscribers. + +Easier to invite your friends: we made connecting simpler for new users. + +Safe web links: +- opt-in to send link previews. +- use SOCKS proxy for previews (if enabled). +- prevent hyperlink phishing. +- remove link tracking. + +Non-profit governance: to make SimpleX Network last. + +Read more on April 30 at 20:00 UTC: https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +## v6.4 + +15 July, 2025 + +- Connect faster: message instantly once you tap Connect. +- Review group members: chat with new members before they join. +- Chat with admins: send your private feedback to group owners. +- New group role: Moderator - can remove messages and block members. +- Improved message delivery - less traffic on mobile networks. + +Read about the new UX for making connections in the blog post: https://simplex.chat/blog/20250703-simplex-network-protocol-extension-for-securely-connecting-people.html + +## v6.3 + +7 March, 2025 + +Better groups. +- Mention members and get notified when mentioned. +- Send private reports to moderators. +- Delete, block and change role for multiple members at once (Android and desktop only). +- Faster sending messages and faster deletion. + +Better chat navigation +- Organize chats into lists to keep track of what's important. +- Jump to found and forwarded messages. + +Better privacy and security. +- Private media file names. +- Message expiration in chats. + +Read more on March 8: https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +## v6.2 + +7 December, 2024 + +- SimpleX Chat and Flux (https://runonflux.com) made an agreement to include servers operated by Flux into the app – to improve metadata privacy. +- Business chats – your customers' privacy. +- Improved user experience of chats: + - Open chat on the first unread message. + - Jump to quoted messages anywhere in the conversation. + - See who reacted to messages. +- Improved iOS push notifications. + +Read more on December 10: https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +## v6.1 + +12 October, 2024 + +Better security: +- SimpleX protocols reviewed by Trail of Bits. +- security improvements (don't worry, there is nothing critical there). + +Better calls: +- you can switch audio and video during the call +- share the screen from desktop app. + +Better iOS notifications: +- improved delivery, reduced traffic usage. +- more improvements are coming soon! + +Better user experience: +- switch chat profile for 1-time invitations. +- customizable message shape. +- better message dates. +- forward up to 20 messages at once. +- delete or moderate up to 200 messages. + +The protocols review by Trail of Bits and release announcement will be published on October 14 afternoon here: https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html + +## v6.0 + +11 August, 2024 + +New chat experience: +- connect to your friends faster. +- archive contacts to chat later. +- delete up to 20 messages at once. +- increase font size. +- new chat themes on iOS - same as on Android and desktop in the previous version. +- reachable chat toolbar - use the app with one hand. + +New media options: +- share from other apps (iOS). +- play from the chat list. +- blur for better privacy. + +Private routing: it protects your IP address and connections and is now enabled by default. + +Connection and servers information: to control your network status and usage. + +Read more on 8/14: https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html + +## v5.8 + +3 June, 2024 + +- private message routing to protect IP addresses (opt-in in this version). +- protect IP address when receiving files. +- chat themes with wallpapers - set themes for all chats app-wide, per chat profile and per conversation - Android and desktop apps. +- some groups permissions can now be granted to admins only. +- improved message and file delivery with reduced battery usage. +- Persian interface language - Android and desktop apps. + +Read more: https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html + +## v5.7 + +26 April, 2024 + +- quantum resistant end-to-end encryption – will be enabled for all direct chats! +- forward and save messages and files, without revealing the source. +- improved calls: in-call sounds when connecting calls, better support for bluetooth headphones. +- customizable shapes of profile images - from square to circle. +- more reliable network connection. + +Lithuanian UI language in Android and desktop apps - thanks to our users! + +Read more: https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html + +## v5.6 + +21 March, 2024 + +1. **Quantum resistant end-to-end encryption** in direct chats (BETA). + It can be enabled for the new contacts by *Post-quantum E2EE* toggle in dev tools, and for the existing contacts - both users need to tap *Allow PQ encryption* in contact information page (and the toggle in dev tools should be enable for this button to be available). + Once quantum resistant shared secret is agreed, there will be a message indicating it - it takes about 2-3 messages from each side to be sent in turns before it gets enabled. + Read more about end-to-end encryption in SimpleX Chat here: https://simplex.chat/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.html + +2. **App data migration**. + As suggested by one of SimpleX Chat users in our [users group](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D), you can now migrate all data from one device to a new app installation by uploading it to the configured XFTP relays and then scanning QR code from the new device – choose *Migrate to another device* from the app settings and *Migrate from another device* on the first screen after installing the app. + +3. **Use the app during the audio and video calls**. + Now you can continue using the app, with small video if it's a video call. + +Also in this version: +- admins can block a member for all other members. +- much faster leaving and deleting groups. +- filtering chats no longer includes muted chats with unread messages. +- reduced memory usage when sending large files. +- desktop: scrollbars in all views with the scrolling - finally! +- iOS: + - fixed rendering glitches with messages and context menus. + - added Hungarian interface language. + +The blog post with the announcement is coming on 3/23/2024. + +## v5.5 + +23 January, 2024 + +- private notes - with encrypted files and media. +- paste link to connect - search bar now accepts invitation links. +- optional recent history in groups. +- improved message delivery - with reduced battery usage. +- reveal secrets in messages by tapping them. +- all files in local app storage are encrypted by default. +- allow deleting the last visible user profile. +- do not share contact address in member profile. +- many fixes! + +Also, we added Hungarian (Android only) and Turkish interface - thanks to the users and Weblate (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat). + +## v5.4 + +25 November, 2023 + +- Link mobile and desktop apps via secure quantum-resistant protocol. +- Better groups: + - Faster to join and more reliable. + - Create groups with incognito profile. + - Block group members to hide their messages. + - Prohibit files and media in a group. +- Better calls: + - Connect faster and more stable (still far from great). + - Screen sharing in video calls in desktop app. +- Other improvements: + - profile names now allow spaces. + - when you delete contacts, they are optionally notified. + - previously used and your own SimpleX links are recognised by the app. + - many fixes and improvements. + +## v5.3 + +22 September, 2023 + +All apps (Android, iOS, desktop): +- encrypt local files in app storage (except videos). +- improved groups: + - delivery receipts (up to 20 members). + - send direct messages to members even after contact is deleted. + - faster and more stable. +- simplified incognito mode. +- new privacy settings: show last messages & save draft. +- faster app loading. +- reduced memory usage by 40%. +- fixed bug preventing group members connecting (it will only help the new connections). +- iOS app fixes: + - playing videos on full screen. + - screen reader for messages. + - fixed most background crashes. + +Also, 6 new interface languages added by the users: Arabic*, Bulgarian, Finnish, Hebrew*, Thai and Ukrainian! + +\* Android and desktop only + +## v5.2 + +22 July, 2023 + +- message delivery receipts – with opt out per contact! +- filter favorite and unread chats. +- keep your connections working after restoring from backup. +- share your address with group members via your chat profile. +- improved disappearing messages. +- a bit more usable groups. +- chat preference to prohibit message reactions. +- restart and shutdown buttons. +- more stable message delivery. + +Read more: https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html + +## v5.1 + +22 May, 2023 + +Mobile apps: +- message reactions 🚀 +- self-destruct passcode +- improved messages: + - voice messages up to 5 minutes. + - custom time to disappear - can be set just for one message. + - message editing history. +- setting to disable audio/video calls per contact. +- welcome message visible in group profile. + +Android only: +- new design and custom themes for Android - you can share them! +- configurable SOCKS proxy port. +- improved calls on lock screen. +- fixes for sending files. +- locale-dependent formatting of time and date. + +Also, the users have added Japanese and Portuguese (Brazil) interfaces (the latter is available on Android only) - huge thanks! + +## v5.0 + +20 April, 2023 + +- send videos and files up to 1gb - the recipient must have at least version 4.6.1. +- you can self-host XFTP servers and configure the app to use your servers. +- passcode as an alternative to system/device authentication. +- support for IPv6 server addresses. +- configurable SOCKS proxy host and port in Android app. + +Also we added Polish interface language – [thanks to the users and Weblate](https://github.com/simplex-chat/simplex-chat#help-translating-simplex-chat). + +See more details in this post: https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html + +## v4.6 + +25 March, 2023 + +Mobile apps: + +- hidden chat profiles – you can protect them with a password! +- audio/video calls: + - iOS: completely re-implemented using WebRTC native library and iOS CallKit. Calls now work when the app is in background, and can be answered when the app is fully stopped. + - Android: added support for bluetooth headphones, volume control in video calls, proximity sensor turns off screen in audio calls. +- group moderation. Admins now can delete member messages and disable members (by assigning "observer" role). +- group welcome message to show to the new users when they join. +- reduced battery usage, particularly when sending messages to large groups. +- Chinese and Spanish interface. + +Android app now supports Android 8+ (API 26+), and also supports 32 bit/ARMv7a devices via a separate APK. If you don't know which APK you need, try simplex.apk first. You can check your device CPU in z-cpu app. + +Terminal / CLI app: + +- hidden profiles are supported. +- improved help, with all supported commands included. + +## v4.5 + +3 February, 2023 + +- multiple chat profiles: use different names, avatars and transport isolation. +- transport isolation: separate transport connections are used for each chat profile (default) or for each connection (BETA – enable dev tools to make this option available in Network & Servers.) +- message draft: the last message text and any attachments are now preserved when you leave the conversation (while the app is running). +- private filenames: to protect your timezone, image and voice message files now use UTC time. + +## v4.4 + +31 December, 2022 + +- disappearing messages - with mutual agreement! +- live messages – they update for all recipients as you type them, every few seconds. +- connection security code verification, for contacts and group members – protect from MITM attack (e.g. invitation link substitution). +- performance improvements - faster UI loading, faster group deletion, etc. + +Mobile apps: +- French language support in the UI! + +iOS app: +- send animated images and "stickers" (e.g., from GIF and PNG files and from 3rd party keyboards) + +## v4.3 + +4 December, 2022 + +Mobile apps: +- instant voice messages! +- irreversible deletion of sent messages on recipients devices (depends on chat preferences) +- an option to hide the app screen in the recent apps, and also prevent the screenshots on Android +- add SMP servers by scanning QR code, support for server passwords (with the new version 4.0 of SMP server) +- improved privacy and security of SimpleX invitation links in the app + +## v4.2 + +6 November, 2022 + +- fixed issues from security audit! +- group links - group admins can create the links for new members to join +- auto-accept contact requests + configure to accept incognito and welcome message +- change group member role +- mark chat as unread +- on Android: + - support for image/gif/sticker keyboards + - fix keyboard bug with backspace + +Beta features (enable Developer tools): +- manually switch contact or member to another address / server +- receive files faster (enable in Privacy settings) + +## v4.1 + +13 October, 2022 + +Changes: +- automatic message deletion (set TTL per-chat or globally) +- change group member roles +- send multiple images at once +- connection aliases and information view +- share text and files from other apps into SimpleX (Android) +- image gallery (Android) +- scroll to quoted message (Android) +- German translations +- improved connection stability and performance + +## v4.0 + +24 September, 2022 + +Changes: + +Local database encryption with passphrase on iOS, Android, Linux, Mac! + +Mobile apps: +- configurable WebRTC ICE servers - see https://github.com/simplex-chat/simplex-chat/blob/stable/docs/WEBRTC.md +- improved stability of establishing direct and group connections, files transfers and message reception. +- support for animated images on Android +- German language UI +- deleting files and media + +Terminal app: +- disable messages and notifications per contact / group + +For developers: +- [TypeScript SDK for integrating with SimpleX Chat](#typescript-sdk-for-integrating-with-simplex-chat) (e.g., chat bots or chat assistants). + +## v3.2 + +20 August, 2022 + +Changes: +- use .onion addresses of the servers (if available) when Tor is used – it is based on a separate setting on iOS. +- endless scrolling and search in chats +- UI improvements +- reduced Android APK size (from 200 to 46Mb) + +## v3.1 + +6 August, 2022 + +Mobile apps: +- secret chat groups! +- support accessing SimpleX messaging servers via Orbot (both iOS and Android) +- new app icons +- advanced network settings +- improved battery usage and traffic + +Terminal app: +- support SOCKS5 proxy +- `/info` command to show information and servers for contacts and group members: use `/info ` for contact and `/info # ` for member information. + +## v3.0 + +9 July, 2022 + +Changes: + +Chat core: +- support for push notifications on iOS +- support for database export/import in mobile clients + +Terminal client: +- automatically accept contact requests and sending reply message with `/auto_accept on` and `/auto_accept on ` coomands + +Mobile clients: +- instant push notifications for iOS (the sending clients have to be upgraded too for notifications to work), +- e2e encrypted WebRTC audio/video calls, +- export and import of chat database, allowing to move the chat profile to another device, +- improved privacy and performance of the protocol. + +Please see [this post](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md) for more details. + +## v2.2 + +1 June, 2022 + +Changes: +- WebRTC calls integrated with CallKit (iOS) +- call notifications and alerts (Android) +- local authentication / app lock (both platforms) +- call settings and invitation timeouts +- privacy settings: auto-accepting images, link previews +- paste image from clipboard (iOS) +- SMP servers settings page (iOS) + +## v2.1 + +21 May, 2022 + +New commands for terminal users: +- /clear - delete all messages in a conversation +- /image - send file as image for mobile clients +- /fforward - forward file to another conversation +- /image_forward - forward image to another conversation + +## v2.0 + +11 May, 2022 + +For terminal users: +- /tail command to show the last messages from a given chat or from all chats + +## v1.6 + +16 April, 2022 + +Changes: +- Improved stability of network connection. +- The new protocol to exchange files, in preparation to support images, files and groups in mobile apps. It makes sending files to groups much more efficient, and allows attaching files to the text messages. This version is backwards and forwards compatible, so you can exchange the files with the previous version. It will not be possible to receive the files sent from the next version (1.7) in the previous version (1.5) - please upgrade. +- **Up arrow** key in the terminal can be used to edit the last message you sent. +- CLI option to execute a single command / send one message, e.g. to use in CI to notify about the build completion, or for any other scenario. +- Library support + [chat bot examples](https://github.com/simplex-chat/simplex-chat/tree/stable/apps) to create SimpleX Chat chat bots. + +## v1.5 + +3 April, 2022 + +Edit, delete and reply to messages, in the mobile apps and from the terminal. + +## v1.4 + +26 March, 2022 + +Changes: +- message edit and delete in mobile apps +- profile images +- TCP keep-alive replacing SMP protocol pings (improved connection stability) +- bug fixes for chat scrolling and empty chat views + +## v1.3 + +26 February, 2022 + +Changes: +- markdown support in messages (both platforms) +- user addresses (Android) +- group member names shown in messages +- display name validation +- asynchronous message processing (improved performance) +- search in chats +- Android app UI redesign (welcome page, help view, dark mode fixes) + +## v1.2 + +14 February, 2022 + +Changes: +- message sent/unread status indicators (iOS) +- search in chats +- auto-accept contact requests option +- deduplicate contact requests +- iOS public beta launch +- connection stability fixes + +## v1.1 + +2 February, 2022 + +- TLS 1.3 support. +- Terminal app is now also a backend for our new mobile app - public access to our new iOS app via TestFlight is coming soon! +- The code base now includes an iOS app preview. + +## v1.0 + +12 January, 2022 + +### The most private and secure chat and application platform + +We are building a new platform for distributed Internet applications where privacy of the messages _and_ the network matter. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is our first application, a messaging application built on the SimpleX platform. + +### What is SimpleX? + +There is currently no messaging application other than SimpleX Chat that guarantees metadata privacy - who is communicating with whom and when. SimpleX is designed to not use any permanent users identities to protect meta-data privacy. See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more details. + +### SimpleX protocol changes + +Best possible E2E encryption - the only messenger using two-layer E2E encryption, with one layer using double ratchet protocol that provides forward secrecy and break-in recovery, and additional encryption layer providing meta-data protection. See more details about encryption algorithms in [SimpleXMQ change log](https://github.com/simplex-chat/simplexmq/blob/master/CHANGELOG.md#100). + +Performance and space efficiency improvements - protocol overhead is reduced from circa 15% to 3.7% thanks to binary encoding, and performance is substantially improved due to more efficient cryptographic algorithms. + +Shorter invitation and contact links due to switching from long RSA to much shorter Curve448/25519 keys - for example, you can connect to the team via [team's SimpleX Chat contact address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D) (you need to use it in terminal app) or just by using `/simplex` command in the chat. + +This [this post](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md) for more information. + diff --git a/Dockerfile.build b/Dockerfile.build index fddc96b6c2..89f8c25101 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1.7.0-labs ARG TAG=24.04 -FROM ubuntu:${TAG} AS build +ARG HASH=sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237 +FROM ubuntu:${TAG}@${HASH} AS build ### Build stage @@ -13,6 +14,10 @@ ARG JAVA_HASH_ARM64=2b460859b681757b33a7591b6238ecaf51569d05d2684984e5f0a89c6514 ENV TZ=Etc/UTC \ DEBIAN_FRONTEND=noninteractive +ARG USER_UID=1000 +ARG USER_GID=1000 +ARG USER_NAME=builder + # Install curl, git and and simplex-chat dependencies RUN apt-get update && \ apt-get install -y curl \ @@ -38,6 +43,11 @@ RUN apt-get update && \ file \ appstream \ gpg \ + zipalign \ + apksigner \ + python3 \ + python3-venv \ + xz-utils \ unzip &&\ ln -s /bin/fusermount /bin/fusermount3 || : @@ -67,6 +77,12 @@ RUN export JAVA_FILENAME='java-corretto.deb' \ echo "Checksum mismatch" && exit 1; \ fi +RUN userdel -r ubuntu || : +RUN groupadd -g ${USER_GID} ${USER_NAME} || :; useradd -u ${USER_UID} -g ${USER_GID} --create-home --shell /bin/bash ${USER_NAME} || : +RUN mkdir /nix /out && chown ${USER_NAME}:${USER_NAME} /nix /out +USER ${USER_NAME} +WORKDIR /home/${USER_NAME} + # Specify bootstrap Haskell versions ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} @@ -78,8 +94,10 @@ ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true # Install ghcup RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh +# Setup basic env variables (required) +ENV HOME="/home/${USER_NAME}" USER="${USER_NAME}" # Adjust PATH -ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH" +ENV PATH="$HOME/.cabal/bin:$HOME/.ghcup/bin:$PATH" # Set both as default RUN ghcup set ghc "${GHC}" && \ @@ -90,8 +108,8 @@ RUN ghcup set ghc "${GHC}" && \ #===================== ARG SDK_VERSION=13114758 -ENV SDK_VERSION=$SDK_VERSION \ - ANDROID_HOME=/root +ENV SDK_VERSION="$SDK_VERSION" \ + ANDROID_HOME="$HOME" RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \ unzip tools.zip && rm tools.zip && \ @@ -101,11 +119,17 @@ RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlineto ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin" # https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded -RUN mkdir -p ~/.android ~/.gradle && \ - touch ~/.android/repositories.cfg && \ - echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\ +RUN mkdir -p "$HOME/.android" "$HOME/.gradle" && \ + touch "$HOME/.android/repositories.cfg" && \ + echo 'org.gradle.console=plain' > "$HOME/.gradle/gradle.properties" &&\ yes | sdkmanager --licenses >/dev/null -ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools +ENV PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools" + +# Android reproducibility scripts +RUN python3 -m venv "$HOME/.venv" +RUN "$HOME/.venv/bin/pip" install apksigcopier repro-apk + +ENV PATH="$HOME/.venv/bin:$PATH" WORKDIR /project diff --git a/PRIVACY.md b/PRIVACY.md index 18e5539726..5713f8e134 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -27,17 +27,17 @@ permalink: /privacy/index.html SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability. -SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts. +SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to quantum-resistant double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts. Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)). -If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). +If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw). ## Privacy Policy ### General principles -SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack). +SimpleX network software uses established industry practices for security and encryption to provide secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack) by the relay servers, even if they are modified or compromised. SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications. @@ -45,7 +45,7 @@ SimpleX software is similar in its design approach to email clients and browsers SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers. -SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability. +SimpleX network design is based on the principles of user and data sovereignty, and device and operator portability. The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). @@ -69,22 +69,26 @@ Your message history is stored only on your own device and the devices of your c #### Private message delivery -You do not have control over which servers are used to send messages to your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts. +You do not have control over which servers are used to receive messages by your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts. In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client. +Preset servers do not log IP addresses of the user devices that connect to them. + You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you. *Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers. #### Storage of messages and files on the servers -The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers). +The messages stored on the servers are end-to-end encrypted, and cannot be read by server owners. + +The messages are irreversibly removed from the preset relay servers as soon as they are delivered or after 21 days. + +Other relay servers may use message logs that would result in longer storage of delivered messages, until the log file is rotated, which normally should happen within one month if servers use the same code as preset servers. The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers). -The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage. - #### Connections with other users When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers, or, if available, the relays of two different operators, for increased privacy, in case you have more than one relay server configured in the app, which is the default. @@ -93,15 +97,11 @@ Preset and unmodified SimpleX relay servers do not store information about which #### Connection links privacy -When you create a connection with another user, the app generates a link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app). +When you create a connection with another user, the app generates a one-time link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app). -While the connection "links" contain SimpleX Chat Ltd domain name `simplex.chat`, this site is never accessed by the app, and is only used for these purposes: -- to direct the new users to the app download instructions, -- to show connection QR code that can be scanned via the app, -- to "namespace" these links, -- to open links directly in the installed app when it is clicked outside of the app. +The connection link contains the address of the server used to establish the connection. Your profile name and picture are stored on this server in encrypted form until your contact uses the link, after which this data is removed. The server cannot access this data without the link. -You can always safely replace the initial part of the link `https://simplex.chat/` either with `simplex:/` (which is a URI scheme provisionally registered with IANA) or with any other domain name where you can self-host the app download instructions and show the connection QR code (but in case it is your domain, it will not open in the app). Also, while the page renders QR code, all the information needed to render it is only available to the browser, as the part of the "link" after `#` symbol is not sent to the website server. +The old connection "links" contained SimpleX Chat Ltd domain name `simplex.chat`, but this site is never accessed by the app - you could replace the initial part of the old link `https://simplex.chat/` either with `simplex:/` or with any other domain name. #### iOS Push Notifications @@ -117,11 +117,15 @@ You can read more about the design of iOS push notifications [here](./blog/20220 Additional technical information can be stored on the network servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX network design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively. +Because SimpleX servers do not create user accounts and do not store any identifiers linking transport data to message queues or user profiles, this technical data cannot be used by server operators to identify any person. + #### SimpleX Directory -This section applies only to the experimental group directory operated by SimpleX Chat Ltd. +This section applies only to the experimental group directory chat bot operated by SimpleX Chat Ltd. -[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). +[SimpleX Directory](/docs/DIRECTORY.md) bot stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok). + +You can also view the groups registered in SimpleX directory via the browser at [simplex.chat/directory](https://simplex.chat/directory) #### Public groups and content channels @@ -131,15 +135,23 @@ You may participate in a public group and receive content from a public channel - to retain a copy of your messages according to the Group settings (e.g., the Group may allow irreversible message deletion from the recipient devices for a limited period of time, or it may only allow to edit and mark messages as deleted on recipient devices). Deleting message from the recipient devices or marking message as deleted revokes the license to share the message. - to Group owners: to share your messages with the new Group members as history of the Group. Currently, the Group history shared with the new members is limited to 100 messages. -Group owners may use chat relays or automated bots (Chat Relays) to re-broadcast member messages to all members, for efficiency. The Chat Relays may be operated by the group owners, by preset operators or by 3rd parties. The Chat Relays have access to and will retain messages in line with Group settings, for technical functioning of the Group. Neither you nor group owners grant any content license to Chat Relay operators. +#### Public channels and chat relays (beta) + +Public channels are experimental - their functionality and privacy properties may change. + +Channel owners use chat relays that retain messages to deliver them to channel subscribers. The chat relays may be operated by the channel owners, by preset operators or by 3rd parties. The chat relays are client applications on SimpleX network - they cannot identify subscribers. Neither you nor channel owners grant any content license to chat relay operators. + +#### Public contact, group and channel addresses + +Public addresses contain profile name, picture and other profile details. This data is encrypted on the servers, and can only be accessed via the address. Server operators cannot list addresses and cannot access this data without having the address. Public address data remains on the servers until removed by the user via the app. If you lose access to the app without a backup, server operators have no way to verify address ownership and can only remove addresses following due process. #### User Support -The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information. +The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw) when it is possible, and avoid sharing any personal information. ### Preset Server Operators -Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics. +Preset server operators will not share the information or any metadata on their servers with each other, other than aggregate usage statistics. Preset server operators must not provide general access to their servers or the data on their servers to each other. @@ -149,7 +161,7 @@ Preset server operators will provide non-administrative access to control port o The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs. -SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat). +SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/vks/v1/by-fingerprint/FB44AF81A45BDE327319797C85107E357D4A17FC). The cases when the preset server operators may share the data temporarily stored on the servers: @@ -158,9 +170,7 @@ The cases when the preset server operators may share the data temporarily stored - To detect, prevent, or otherwise address fraud, security, or technical issues. - To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law. -By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law. - -Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md). +Reports on requests for user data are published in [Transparency reports](./docs/TRANSPARENCY.md). To date, no user information was provided in response to any requests. If the preset server operators are ever required to provide information, they will follow the due legal process to limit any information shared to the minimally required by law. ### Source code license @@ -168,6 +178,8 @@ As this software is fully open-source and provided under AGPLv3 license, all inf In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications. +Users can independently [reproduce builds](./docs/REPRODUCE.md) to verify that the published client and server binaries were compiled from the published code. + ### Updates This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app. @@ -176,7 +188,7 @@ This Privacy Policy may be updated as needed so that it is current, accurate, an Please also read The Conditions of Use of Software and Infrastructure below. -If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). +If you have questions about this Privacy Policy or data protection please contact SimpleX Chat Ltd (company number 13691484, registered at 20-22 Wenlock Road, London, United Kingdom N1 7GU) via [email](mailto:chat@simplex.chat) or [chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw). ## Conditions of Use of Software and Infrastructure @@ -188,6 +200,8 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b **Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed. +Client applications must not include any code that could compromise the security of end-to-end encryption of files and messages. Client applications must not send anything not directly required for users communications without explicit users' consent. + **Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks. **Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way. @@ -243,4 +257,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b **Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators. -Updated March 3, 2025 +Updated April 18, 2026 diff --git a/README.md b/README.md index 3092cfc02f..252fc95708 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ [](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)     [](https://www.privacyguides.org/en/real-time-communication/#simplex-chat)     [](https://www.whonix.org/wiki/Chat#Recommendation)     [](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) +**[Why we are building SimpleX Network](./docs/WHY.md)** + ## Welcome to SimpleX Chat! 1. 📲 [Install the app](#install-the-app). @@ -32,17 +34,17 @@   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) - 🖲 Protects your messages and metadata - who you talk to and when. - 🔐 Double ratchet end-to-end encryption, with additional encryption layer. -- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**! - 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows. ## Connect to the team -You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). Please connect to: +You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw). Please connect to: - to ask any questions - to suggest any improvements @@ -54,24 +56,10 @@ If you are interested in helping us to integrate open-source language models, an ## Join user groups -You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups. +You can find the groups created by users in [SimpleX Directory](https://simplex.chat/directory/). It is also available as [SimpleX bot](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) that allows to add your own groups and communities to the directory. We are not responsible for the content shared in these groups. **Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only. -You also can: -- criticize the app, and make comparisons with other messengers. -- share new messengers you think could be interesting for privacy, as long as you don't spam. -- share some privacy related publications, infrequently. -- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it. - -You must: -- be polite to other users -- avoid spam (too frequent messages, even if they are relevant) -- avoid any personal attacks or hostility. -- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.). - -Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment. - You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://smp4.simplex.im/g#hr4lvFeBmndWMKTwqiodPz3VBo_6UmdGWocXd1SupsM) There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSpzTspw9SER0iJwrQTmKBafQHwkKM) for developers who build on SimpleX platform: @@ -81,11 +69,7 @@ There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSp - social apps and services - etc. -There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users: - -[\#SimpleX-DE](https://smp6.simplex.im/g#V6tQ-lJqsdgJJdJiLPtP326oQFKHvwinIbgruZ9K2oU) (German-speaking), [\#SimpleX-ES](https://smp5.simplex.im/g#xJ5kwDLq2305O5FmpUzvgRIXXAcAJ9S5BItCd2Wmloc) (Spanish-speaking), [\#SimpleX-FR](https://smp6.simplex.im/g#cVOpB0CKd6hEf2aWQ6sJ22E2DVgQLtdHoiSdKxXeKqk) (French-speaking), [\#SimpleX-RU](https://smp5.simplex.im/g#vwXRdfG5SgtaG6aVcITiUGd--Ux0rY1IuH4QXYxlq3U) (Russian-speaking), [\#SimpleX-IT](https://smp5.simplex.im/g#BtRcjsl29ULFNBSE2OPhp1UwZfW7PW9gUYFQTKHdjqU) (Italian-speaking). - -You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code. +You can join these and other groups by opening these links in the app or by opening them in a desktop browser and scanning the QR code. ## Follow our updates @@ -441,7 +425,9 @@ Please do NOT report security vulnerabilities via GitHub issues. ## License -This software is licensed under the GNU Affero General Public License version 3 (AGPLv3). See the [LICENSE](./LICENSE) file for details. The SimpleX and SimpleX Chat name, logo, and associated branding materials are not covered by this license and are subject to the terms outlined in the [TRADEMARK](./docs/TRADEMARK.md) file. +This software is licensed under the GNU Affero General Public License version 3 (AGPLv3). See the [LICENSE](./LICENSE) file for details. The SimpleX and SimpleX Chat name, logo, associated branding materials, and application and website graphic assets (illustrations, images, visual designs, etc.) are not covered by this license and are subject to the terms outlined in the [TRADEMARK](./docs/TRADEMARK.md) and [ASSETS_LICENSE](./assets/ASSETS_LICENSE.md) files respectively. + +If you want to use any graphic assets in your publications, please ask for permission. Texts can be used as direct quotes, referencing the source. [iOS app](https://apps.apple.com/us/app/simplex-chat/id1605771084)   @@ -451,4 +437,4 @@ This software is licensed under the GNU Affero General Public License version 3   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore index 3d152a0610..ea8e911891 100644 --- a/apps/ios/.gitignore +++ b/apps/ios/.gitignore @@ -69,3 +69,7 @@ Libraries/ Shared/MyPlayground.playground/* testpush.sh + +# Local build config and generated assets +Local.xcconfig +Shared/SimpleXAssets.xcassets/*.imageset diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md new file mode 100644 index 0000000000..5a8356f656 --- /dev/null +++ b/apps/ios/CODE.md @@ -0,0 +1,223 @@ +# Coding and building + +You are an expert developer for SimpleX Chat, a privacy-first decentralized messaging platform. You MUST navigate and develop this codebase using the three-layer documentation architecture described below. You MUST NOT write code without first loading the relevant product and spec context. + +## Three-Layer Documentation Architecture + +### Why this structure exists + +LLMs start each session with no persistent understanding of the codebase. Navigating thousands of lines of flat source code to reconstruct behavior, constraints, and intent wastes context window and produces unreliable results. + +The `product/`, `spec/`, and source layers form a persistent, structured representation of the system that survives across sessions. Each layer is connected to the next by bidirectional cross-references. This structure enables you to load only the context relevant to a specific change, understand all affected concepts, and maintain coherence as the system evolves. + +### The layers + +| Layer | Contains | Question it answers | +|-------|----------|-------------------| +| `product/` | Capabilities, user flows, views, business rules, glossary | **What** does the system do and why? | +| `spec/` | Technical design, API contracts, database schema, service internals | **How** is it organized technically? | +| `Shared/`, `SimpleXChat/`, `SimpleX NSE/` | Executable Swift code (iOS app) | What does it **execute**? | +| `../../src/Simplex/Chat/` | Haskell core (chat logic, protocol, database) | What does the **core** execute? | + +Each layer links to the next: +- `product/concepts.md` links every concept to its spec docs, source files, and tests in a single table — this is the primary navigation entry point +- `product/views/*.md` and `product/flows/*.md` each have a **Related spec:** line linking to their most relevant spec documents +- `product/glossary.md` uses *See: [spec/...]* references and `product/rules.md` uses **Spec:** [spec/...] references to link individual terms and rules down to spec +- `spec/` documents contain **Source:** headers and inline function links pointing down to source. Line references MUST be clickable by embedding the `#Lxx-Lyy` fragment in the link URL: [`functionName()`](Shared/Model/SimpleXAPI.swift#Lxx-Lyy). You MUST NOT duplicate line numbers in the display text — the URL fragment is sufficient. Why: redundant line numbers in display text create maintenance burden on every line shift. +- Reverse direction: the Document Map (end of this file) maps source → spec → product + +### Navigation workflow + +When the user requests any change, you MUST follow these steps before writing any code: + +1. **Identify scope.** You MUST read `product/concepts.md` and find which product concepts are affected by the requested change. Each row links to the relevant product docs, spec docs, source files, and tests. Why: concepts.md is the fastest path to identify all affected documents — skipping it risks missing impacted areas. + +2. **Load product context.** You MUST read the relevant `product/views/*.md` or `product/flows/*.md` to understand current user-facing behavior. For business constraints, you MUST read `product/rules.md`. Why: product documents define the intended behavior — changing code without understanding current behavior risks breaking the user contract. + +3. **Load spec context.** You MUST follow the product → spec links to read the relevant `spec/*.md` or `spec/services/*.md`. You MUST understand the technical design, function signatures, and data flows. Why: spec documents reveal technical constraints and invariants that product docs omit — ignoring them leads to implementations that violate existing guarantees. + +4. **Load source context.** You MUST follow the spec → source links (with line numbers) to read the relevant source files. Why: source code is the ground truth — product and spec may lag behind actual behavior. + +5. **Identify full impact.** You MUST read `spec/impact.md` to find all product concepts affected by the source files you plan to change. This determines which documents you MUST update after the code change. Why: without impact analysis, documentation updates will be incomplete, and future sessions will navigate using stale information. + +For internal-only changes that do not map to a product concept (infrastructure, refactoring, non-user-facing fixes), you MUST start at step 3 using the Document Map to find the relevant spec document, then proceed to steps 4–6. + +6. **Implement.** Make the code change in source, then you MUST update all affected documentation as described in the Change Protocol below. + +### Key navigation documents + +| Document | Purpose | When to read | +|----------|---------|-------------| +| `product/concepts.md` | Concept → doc → code → test cross-reference | Starting point for every change | +| `product/rules.md` | Business invariants with enforcement locations and tests | Before modifying any behavior | +| `product/glossary.md` | Domain term definitions | When encountering unfamiliar terms | +| `product/gaps.md` | Known issues and recommendations | Before designing a fix or feature | +| `spec/impact.md` | Source file → affected product concepts | After identifying which files to change | +| Document Map (below) | Source ↔ spec ↔ product mapping | When updating documentation | + +--- + +## Code Security + +When designing code and planning implementations, you MUST: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. Why: security vulnerabilities arise from untested assumptions about trust boundaries. +- Formulate an explicit threat model for each change — who can do which undesirable things and under which circumstances. Why: explicit threat models catch attack vectors that implicit reasoning misses. + +--- + +## Code Style + +**Follow existing code patterns — you MUST:** +- Match the style of surrounding code. Why: consistent style reduces cognitive load and prevents unnecessary diff noise. +- Use Swift structs for value types, classes for reference types, and enums with associated values for variants. Why: correct type choices leverage the type system for compile-time correctness. +- Prefer exhaustive switch statements over default cases. Why: default cases bypass compiler checks for new enum cases and hide bugs. + +**Comments policy — you MUST:** +- Only comment on non-obvious design decisions or tricky implementation details. Why: redundant comments create maintenance burden and drift from code. +- Keep function names and type signatures self-documenting. Why: good names eliminate the need for most comments. +- Assume a competent Swift reader. Why: over-explaining trivial Swift adds noise without value. + +**Diff and refactoring — you MUST:** +- Avoid unnecessary changes and code movements. Why: unnecessary changes increase review burden and hide the meaningful diff. +- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring itself. Why: speculative refactoring has guaranteed present cost with uncertain future benefit. +- Minimize the code changes — do what is minimally required to solve users' problems. Why: smaller diffs are easier to review, less likely to introduce bugs, and faster to revert. + +**Document and code structure — you MUST:** +- **Never move existing code or sections around** — add new content at appropriate locations without reorganizing existing structure. Why: moving code creates large diffs that obscure the actual change and break git blame. +- When adding new sections to documents, continue the existing numbering scheme. Why: consistent numbering preserves document navigability. +- Minimize diff size — prefer small, targeted changes over reorganization. Why: large diffs compound review errors and make rollback difficult. + +**Code analysis and review — you MUST:** +- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. Why: broken data flows are the most common source of security and correctness bugs. +- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. Why: function signatures can be misleading about actual behavior. +- Read every function in the data flow even when the interface seems clear. Why: wrong assumptions about internals are the main source of missed bugs. + +--- + +## Plans + +When developing via plans (non-trivial features, multi-step changes, architectural decisions), you MUST store the plan in the `plans/` folder before implementing. Why: plans are the persistent record of design decisions and rationale — without them, future sessions cannot understand why the system was built the way it was. + +### Plan requirements + +1. **File naming.** You MUST use the format `YYYYMMDD_NN.md` (e.g., `20260211_01.md`). Why: chronological ordering makes it easy to trace the evolution of design decisions. + +2. **Plan structure.** Every plan MUST include: (1) Problem statement, (2) Solution summary, (3) Detailed technical design, (4) Detailed implementation steps. Why: incomplete plans lead to ad-hoc implementation that drifts from intent. + +3. **Consistency with product/ and spec/.** The plan MUST be consistent with the current state of `product/` and `spec/`. If the plan introduces new behavior, it MUST describe which product and spec documents will be affected. Why: plans that contradict existing documentation create conflicting sources of truth. + +4. **Adversarial self-review.** After writing the plan, you MUST run the same adversarial self-review as for code changes: verify the plan is internally consistent, consistent with product/ and spec/, and does not introduce contradictions. You MUST repeat until two consecutive passes find zero issues. Why: an incoherent plan produces incoherent implementation. + +--- + +## Change Protocol + +### The rule + +Every code change MUST include corresponding updates to `spec/` and `product/`. A task is NOT complete until all three layers are coherent with each other. Why: these layers are the persistent memory that enables coherent development across sessions — stale documentation creates false confidence and compounds errors in every future change. + +### What to update + +1. **spec/ — on every code change.** You MUST update the corresponding spec document to reflect the change. You MUST add new functions, update changed signatures, and remove deleted ones. Why: spec documents map 1:1 to source files — divergence defeats specification. + +2. **product/ — when user-visible behavior changes.** You MUST update the relevant `product/views/*.md` and any affected `product/flows/*.md`. You MUST update `product/rules.md` when business invariants change. Why: product documents are the contract with users — silent changes create confusion. + +3. **Line number references — on every code change.** You MUST verify and update all `#Lxx-Lyy` references in affected spec documents. Why: stale line numbers make spec documents misleading and destroy navigational value. + +4. **Cross-references — when adding or removing files.** You MUST add corresponding spec documents and update `spec/README.md` document index and reverse index. When adding pages, you MUST add `product/views/` and `spec/client/` documents. You MUST update the Document Map at the end of this file. Why: every source file must be covered for the navigation system to work. + +5. **Impact graph — when adding files or changing what a file affects.** You MUST update `spec/impact.md` to reflect the source file → product concept mapping. Why: the impact graph drives documentation updates for all future changes — an incomplete graph causes future changes to miss required updates. + +6. **Concept index — when adding or changing product concepts.** You MUST add or update the relevant row in `product/concepts.md` with links to product docs, spec docs, source files, and tests. Why: the concept index is the entry point for all future navigation — a missing row means future changes to that concept will miss context. + +7. **[GAP] annotations — when discovering issues.** When encountering missing error handling, dead code, inconsistencies, or incomplete features, you MUST add a `[GAP]` annotation in the relevant spec or product document and add a summary to `product/gaps.md`. Why: this builds institutional knowledge about technical debt. + +8. **[REC] annotations — when identifying improvements.** You MUST add a `[REC]` annotation in the relevant document. Why: capturing improvement ideas at discovery time preserves context that is lost later. + +9. **Preserve document structure.** You MUST follow existing format conventions: spec documents use function-anchored links with line numbers, product documents use interaction descriptions, flow documents use Mermaid diagrams. Why: consistent structure makes documents predictable and navigable. + +### Adversarial self-review + +After completing all changes (code + documentation), you MUST run an adversarial self-review. You MUST check coherence both within each layer and across layers. + +**Within-layer coherence — you MUST verify:** +- spec/ is internally consistent — no contradictory descriptions, state machines have no unreachable states, data model is referentially intact +- product/ is internally consistent — flows match views, rules match behavior descriptions + +**Across-layer coherence — you MUST verify:** +- Every new or changed function in source appears in the corresponding spec/ document +- Every user-visible behavior change in source appears in the relevant product/ document +- All `#Lxx-Lyy` line references in affected spec documents point to the correct lines +- All cross-references resolve — product → spec links, spec → source links +- `spec/impact.md` covers all affected product concepts for the changed source files +- `product/concepts.md` rows are current for any affected concepts + +**Convergence:** You MUST repeat the review-and-fix cycle until two consecutive passes find zero issues. You MUST fix all issues discovered between passes. Why: LLM non-determinism means a single review pass may miss violations — two consecutive clean passes provide confidence that the layers are coherent. + +--- + +## Document Map + +### iOS Swift Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| Shared/ContentView.swift | spec/client/navigation.md | product/views/chat-list.md | +| Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md | +| Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md | +| Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md | +| Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md | +| Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md | +| Shared/Views/Chat/ChatInfoView.swift | spec/client/chat-view.md | product/views/contact-info.md | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/AddGroupMembersView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/GroupLinkView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/ChannelMembersView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/ChannelRelaysView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/NewChat/NewChatView.swift | spec/client/navigation.md | product/views/new-chat.md | +| Shared/Views/NewChat/QRCode.swift | spec/client/navigation.md | product/views/new-chat.md | +| Shared/Views/Call/ActiveCallView.swift | spec/services/calls.md | product/views/call.md | +| Shared/Views/Call/CallController.swift | spec/services/calls.md | product/flows/calling.md | +| Shared/Views/Call/WebRTCClient.swift | spec/services/calls.md | product/flows/calling.md | +| Shared/Views/UserSettings/SettingsView.swift | spec/client/navigation.md | product/views/settings.md | +| Shared/Views/UserSettings/AppearanceSettings.swift | spec/services/theme.md | product/views/settings.md | +| Shared/Views/UserSettings/NetworkAndServers/ | spec/architecture.md | product/views/settings.md | +| Shared/Views/UserSettings/UserProfilesView.swift | spec/client/navigation.md | product/views/user-profiles.md | +| Shared/Views/Onboarding/ | spec/client/navigation.md | product/views/onboarding.md | +| Shared/Views/LocalAuth/ | spec/architecture.md | product/views/settings.md | +| Shared/Views/Database/ | spec/database.md | product/views/settings.md | +| Shared/Views/Migration/ | spec/database.md | product/flows/onboarding.md | +| Shared/Model/ChatModel.swift | spec/state.md | product/concepts.md | +| Shared/Model/SimpleXAPI.swift | spec/api.md, spec/architecture.md | product/concepts.md | +| Shared/Model/AppAPITypes.swift | spec/api.md | product/concepts.md | +| Shared/Model/NtfManager.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Model/BGManager.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Theme/ThemeManager.swift | spec/services/theme.md | product/views/settings.md | +| SimpleXChat/ChatTypes.swift | spec/state.md, spec/api.md | product/glossary.md | +| SimpleXChat/APITypes.swift | spec/api.md | product/concepts.md | +| SimpleXChat/CallTypes.swift | spec/services/calls.md | product/flows/calling.md | +| SimpleXChat/FileUtils.swift | spec/services/files.md | product/flows/file-transfer.md | +| SimpleXChat/Notifications.swift | spec/services/notifications.md | product/flows/messaging.md | +| SimpleX NSE/NotificationService.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Views/Chat/ChatItemsMerger.swift | spec/client/chat-view.md | product/views/chat.md | +| SimpleX SE/ShareAPI.swift | spec/api.md | product/flows/messaging.md | + +### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/ios/`) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| ../../src/Simplex/Chat/Controller.hs | spec/api.md | product/concepts.md | +| ../../src/Simplex/Chat/Types.hs | spec/api.md | product/glossary.md | +| ../../src/Simplex/Chat/Core.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Protocol.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Messages.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Messages/CIContent.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Call.hs | spec/services/calls.md | product/flows/calling.md | +| ../../src/Simplex/Chat/Files.hs | spec/services/files.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Messages.hs | spec/database.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Store/Groups.hs | spec/database.md | product/flows/group-lifecycle.md | +| ../../src/Simplex/Chat/Store/Direct.hs | spec/database.md | product/flows/connection.md | +| ../../src/Simplex/Chat/Store/Files.hs | spec/database.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Profiles.hs | spec/database.md | product/views/user-profiles.md | diff --git a/apps/ios/Debug.xcconfig b/apps/ios/Debug.xcconfig new file mode 100644 index 0000000000..7f0389c760 --- /dev/null +++ b/apps/ios/Debug.xcconfig @@ -0,0 +1,2 @@ +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; +#include? "Local.xcconfig" diff --git a/apps/ios/README.md b/apps/ios/README.md index de6c52c01d..1e987f655e 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1 +1,114 @@ # SimpleX Chat iOS app + +This file provides guidance when working with code in this repository. + +## iOS App Overview + +The iOS app is a SwiftUI application that interfaces with the Haskell core library via FFI. It shares the SimpleXChat framework with two extensions: Notification Service Extension (NSE) for push notifications and Share Extension (SE) for sharing content from other apps. + +## Build & Development + +Open `SimpleX.xcodeproj` in Xcode. The project has five targets: +- **SimpleX (iOS)** - Main app (Bundle ID: `chat.simplex.app`) +- **SimpleXChat** - Framework containing FFI bridge and shared types +- **SimpleX NSE** - Notification Service Extension +- **SimpleX SE** - Share Extension +- **Tests iOS** - UI tests + +Build and run via Xcode (Product > Build/Run). Tests run via Product > Test or: +```bash +xcodebuild test -scheme "SimpleX (iOS)" -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +Deployment target: iOS 15.0+, Swift 5.0. + +## Architecture + +### Haskell Core Integration + +The app calls the Haskell core library through C FFI defined in `SimpleXChat/SimpleX.h`: +- `chat_migrate_init_key()` - Initialize/migrate database +- `chat_send_cmd_retry()` - Send command to chat controller +- `chat_recv_msg_wait()` - Receive messages from controller + +Swift wrappers in `SimpleXChat/API.swift`: +- `chatMigrateInit()` - Initialize chat controller +- `sendSimpleXCmd()` - Send typed commands and parse responses +- `recvSimpleXMsg()` - Receive typed messages + +Haskell runtime initialization (`SimpleXChat/hs_init.c`) uses different memory configurations: +- Main app: 64MB heap +- NSE: 512KB heap (minimal footprint for background processing) +- SE: 1MB heap + +Pre-compiled Haskell libraries are in `Libraries/{ios,mac,sim}/`. + +### State Management + +- **ChatModel** (`Shared/Model/ChatModel.swift`) - Main singleton `ObservableObject` for app-wide state (chat list, active chat, users) +- **ItemsModel** - Manages chat items within a selected chat (similar to Kotlin's ChatsContext) +- **AppTheme** - Theme management and customization + +### App Structure + +Entry point: `Shared/SimpleXApp.swift` + +Key directories in `Shared/`: +- `Model/` - Data models and API layer (`ChatModel.swift`, `SimpleXAPI.swift`) +- `Views/` - SwiftUI views organized by feature: + - `ChatList/` - Chat list and user picker + - `Chat/` - Message display and composition + - `Call/` - VoIP call UI + - `UserSettings/` - App settings + - `LocalAuth/` - Passcode and biometric authentication + - `Database/` - Database initialization and migration + +### Shared Data Between Targets + +All three targets share data via App Group (`group.chat.simplex.app`): +- `SimpleXChat/AppGroup.swift` - GroupDefaults wrapper for typed shared preferences +- Keychain for sensitive data: `kcDatabasePassword`, `kcAppPassword`, `kcSelfDestructPassword` + +### Key Types + +Types are defined in `SimpleXChat/`: +- `ChatTypes.swift` - User, Chat, Message, Group types +- `APITypes.swift` - API request/response types + +Commands follow `ChatCmdProtocol` (has `cmdString` property), sent as JSON through FFI. + +## Localization + +31 languages supported. Localization files in `SimpleX Localizations/`. + +Workflow: +- `Product > Export Localizations` - Export XLIFF files +- `Product > Import Localizations` - Import updated translations + +## SimpleX Assets + +The app includes optional assets behind the `SIMPLEX_ASSETS` Swift compilation flag. Without setup, the app builds normally without them. + +### Setup + +Create `Local.xcconfig` (gitignored) in the `apps/ios/` directory: +``` +SIMPLEX_ASSETS_DIR = /path/to/assets +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) SIMPLEX_ASSETS +``` + +The copy script (`scripts/ios/copy-assets.sh`) runs as a build phase on each build but exits immediately if `SIMPLEX_ASSETS` is not set. + +### Updating assets + +When source images change, regenerate resized images (requires ImageMagick): +```bash +cd path/to/assets && ./resize.sh +``` + +## Background Capabilities + +Configured in Info.plist: +- Background modes: audio, fetch, remote-notification, voip +- URL scheme: `simplex://` for deep linking +- BGTaskScheduler: `chat.simplex.app.receive` diff --git a/apps/ios/Release.xcconfig b/apps/ios/Release.xcconfig new file mode 100644 index 0000000000..234f81e782 --- /dev/null +++ b/apps/ios/Release.xcconfig @@ -0,0 +1 @@ +#include? "Local.xcconfig" diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 3f6998c9ec..0a401f9bf3 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 30/03/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UIKit diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 7adf7a0435..ba49c767da 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -4,6 +4,7 @@ // // Created by Evgeny Poberezkin on 17/01/2022. // +// Spec: spec/client/navigation.md import SwiftUI import Intents @@ -19,15 +20,18 @@ private enum NoticesSheet: Identifiable { } } +// Spec: spec/client/navigation.md#ContentView struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared + // Spec: spec/client/navigation.md#AppSheetState @ObservedObject var appSheetState = AppSheetState.shared @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme @EnvironmentObject var sceneDelegate: SceneDelegate + // Spec: spec/client/navigation.md#contentAccessAuthenticationExtended var contentAccessAuthenticationExtended: Bool @Environment(\.scenePhase) var scenePhase @@ -161,6 +165,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#contentView @ViewBuilder private func contentView() -> some View { if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) @@ -176,6 +181,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#callView @ViewBuilder private func callView(_ call: Call) -> some View { if CallController.useCallKit() { ActiveCallView(call: call, canConnectCall: Binding.constant(true)) @@ -193,6 +199,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#callBanner private func activeCallInteractiveArea(_ call: Call) -> some View { HStack { Text(call.contact.displayName).font(.body).foregroundColor(.white) @@ -227,6 +234,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#lockButton private func lockButton() -> some View { Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } @@ -339,6 +347,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#unlockedRecently private func unlockedRecently() -> Bool { if let lastSuccessfulUnlock = lastSuccessfulUnlock { return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 @@ -426,6 +435,7 @@ struct ContentView: View { ) } + // Spec: spec/client/navigation.md#connectViaUrl func connectViaUrl() { let m = ChatModel.shared if let url = m.appOpenUrl { @@ -441,7 +451,12 @@ struct ContentView: View { func connectViaUrl_(_ url: URL) { dismissAllSheets() { var path = url.path - if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { + if path == "/r" { + showAlert( + NSLocalizedString("Relay address", comment: "alert title"), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") + ) + } else if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { path.removeFirst() let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") planAndConnect( diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 35b9bf033e..b459f36c9d 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -5,11 +5,13 @@ // Created by EP on 01/05/2025. // Copyright © 2025 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md import SimpleXChat import SwiftUI // some constructors are used in SEChatCommand or NSEChatCommand types as well - they must be syncronised +// Spec: spec/api.md#ChatCommand enum ChatCommand: ChatCmdProtocol { case showActiveUser case createActiveUser(profile: Profile?, pastTimestamp: Bool) @@ -41,8 +43,9 @@ enum ChatCommand: ChatCmdProtocol { case apiGetChatTags(userId: Int64) case apiGetChats(userId: Int64) case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) + case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) - case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatTag(tag: ChatTagData) case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) case apiDeleteChatTag(tagId: Int64) @@ -58,7 +61,8 @@ enum ChatCommand: ChatCmdProtocol { case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64]) - case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) + case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) + case apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -67,6 +71,9 @@ enum ChatCommand: ChatCmdProtocol { case apiGetNtfConns(nonce: String, encNtfInfo: String) case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) + case apiNewPublicGroup(userId: Int64, incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) + case apiGetGroupRelays(groupId: Int64) + case apiAddGroupRelays(groupId: Int64, relayIds: [Int64]) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole) @@ -86,6 +93,7 @@ enum ChatCommand: ChatCmdProtocol { case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) case apiAcceptMemberContact(contactId: Int64) case apiTestProtoServer(userId: Int64, server: String) + case apiTestChatRelay(userId: Int64, address: String) case apiGetServerOperators case apiSetServerOperators(operators: [ServerOperator]) case apiGetUserServers(userId: Int64) @@ -104,6 +112,7 @@ enum ChatCommand: ChatCmdProtocol { case reconnectServer(userId: Int64, smpServer: String) case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) + case apiGetUpdatedGroupLinkData(groupId: Int64) case apiContactInfo(contactId: Int64) case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiContactQueueInfo(contactId: Int64) @@ -121,9 +130,9 @@ enum ChatCommand: ChatCmdProtocol { case apiAddContact(userId: Int64, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiChangeConnectionUser(connId: Int64, userId: Int64) - case apiConnectPlan(userId: Int64, connLink: String) + case apiConnectPlan(userId: Int64, connLink: String, linkOwnerSig: LinkOwnerSig?) case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) - case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) + case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) @@ -158,7 +167,6 @@ enum ChatCommand: ChatCmdProtocol { case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) // WebRTC calls / - case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) @@ -225,12 +233,14 @@ enum ChatCommand: ChatCmdProtocol { case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" case let .apiGetChat(chatId, scope, contentTag, pagination, search): let tag = contentTag != nil ? " content=\(contentTag!.rawValue)" : "" - return "/_get chat \(chatId)\(scopeRef(scope: scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") + return "/_get chat \(chatId)\(scopeRef(scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") + case let .apiGetChatContentTypes(chatId, scope): return "/_get content types \(chatId)\(scopeRef(scope))" case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" - case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" @@ -249,9 +259,13 @@ enum ChatCommand: ChatCmdProtocol { case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))" case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl): + case let .apiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl): let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" + let asGroup = sendAsGroup ? " as_group=on" : "" + return "/_forward \(ref(toChatType, toChatId, scope: toScope))\(asGroup) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" + case let .apiShareChatMsgContent(shareChatType, shareChatId, toChatType, toChatId, toScope, sendAsGroup): + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_share chat content \(ref(shareChatType, shareChatId, scope: nil)) \(ref(toChatType, toChatId, scope: toScope))\(asGroup)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -260,6 +274,9 @@ enum ChatCommand: ChatCmdProtocol { case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" + case let .apiNewPublicGroup(userId, incognito, relayIds, groupProfile): return "/_public group \(userId) incognito=\(onOff(incognito)) \(relayIds.map(String.init).joined(separator: ",")) \(encodeJSON(groupProfile))" + case let .apiGetGroupRelays(groupId): return "/_get relays #\(groupId)" + case let .apiAddGroupRelays(groupId, relayIds): return "/_add relays #\(groupId) \(relayIds.map(String.init).joined(separator: ","))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)" @@ -279,6 +296,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" case let .apiAcceptMemberContact(contactId): return "/_accept member contact @\(contactId)" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" + case let .apiTestChatRelay(userId, address): return "/_relay test \(userId) \(address)" case .apiGetServerOperators: return "/_operators" case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" case let .apiGetUserServers(userId): return "/_servers \(userId)" @@ -297,6 +315,7 @@ enum ChatCommand: ChatCmdProtocol { case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id, scope: nil)) \(encodeJSON(chatSettings))" case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" + case let .apiGetUpdatedGroupLinkData(groupId): return "/_get group link data #\(groupId)" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)" @@ -324,9 +343,11 @@ enum ChatCommand: ChatCmdProtocol { case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)" - case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)" + case let .apiConnectPlan(userId, connLink, linkOwnerSig): + let sigStr = if let linkOwnerSig { " sig=\(encodeJSON(linkOwnerSig))" } else { "" } + return "/_connect plan \(userId) \(connLink)\(sigStr)" case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))" - case let .apiPrepareGroup(userId, connLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(groupShortLinkData))" + case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") direct=\(onOff(directLink)) \(encodeJSON(groupShortLinkData))" case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)" case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)" case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito))\(maybeContent(mc))" @@ -359,7 +380,6 @@ enum ChatCommand: ChatCmdProtocol { case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" - case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))" case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(onOff(unreadChat))" @@ -419,6 +439,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetChatTags: return "apiGetChatTags" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" + case .apiGetChatContentTypes: return "apiGetChatContentTypes" case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiSendMessages: return "apiSendMessages" case .apiCreateChatTag: return "apiCreateChatTag" @@ -438,6 +459,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetReactionMembers: return "apiGetReactionMembers" case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" case .apiForwardChatItems: return "apiForwardChatItems" + case .apiShareChatMsgContent: return "apiShareChatMsgContent" case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiVerifyToken" @@ -446,6 +468,9 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetNtfConns: return "apiGetNtfConns" case .apiGetConnNtfMessages: return "apiGetConnNtfMessages" case .apiNewGroup: return "apiNewGroup" + case .apiNewPublicGroup: return "apiNewPublicGroup" + case .apiGetGroupRelays: return "apiGetGroupRelays" + case .apiAddGroupRelays: return "apiAddGroupRelays" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" case .apiAcceptMember: return "apiAcceptMember" @@ -465,6 +490,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" case .apiAcceptMemberContact: return "apiAcceptMemberContact" case .apiTestProtoServer: return "apiTestProtoServer" + case .apiTestChatRelay: return "apiTestChatRelay" case .apiGetServerOperators: return "apiGetServerOperators" case .apiSetServerOperators: return "apiSetServerOperators" case .apiGetUserServers: return "apiGetUserServers" @@ -483,6 +509,7 @@ enum ChatCommand: ChatCmdProtocol { case .reconnectServer: return "reconnectServer" case .apiSetChatSettings: return "apiSetChatSettings" case .apiSetMemberSettings: return "apiSetMemberSettings" + case .apiGetUpdatedGroupLinkData: return "apiGetUpdatedGroupLinkData" case .apiContactInfo: return "apiContactInfo" case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiContactQueueInfo: return "apiContactQueueInfo" @@ -534,7 +561,6 @@ enum ChatCommand: ChatCmdProtocol { case .apiEndCall: return "apiEndCall" case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" - case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiChatRead: return "apiChatRead" case .apiChatItemsRead: return "apiChatItemsRead" case .apiChatUnread: return "apiChatUnread" @@ -562,10 +588,10 @@ enum ChatCommand: ChatCmdProtocol { } func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String { - "\(type.rawValue)\(id)\(scopeRef(scope: scope))" + "\(type.rawValue)\(id)\(scopeRef(scope))" } - func scopeRef(scope: GroupChatScope?) -> String { + func scopeRef(_ scope: GroupChatScope?) -> String { switch (scope) { case .none: "" case let .memberSupport(groupMemberId_): @@ -643,6 +669,7 @@ enum ChatCommand: ChatCmdProtocol { } // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. +// Spec: spec/api.md#ChatResponse0 enum ChatResponse0: Decodable, ChatAPIResult { case activeUser(user: User) case usersList(users: [UserInfo]) @@ -651,16 +678,19 @@ enum ChatResponse0: Decodable, ChatAPIResult { case chatStopped case apiChats(user: UserRef, chats: [ChatData]) case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?) + case chatContentTypes(contentTypes: [MsgContentTag]) case chatTags(user: UserRef, userTags: [ChatTag]) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) + case chatRelayTestResult(user: UserRef, relayProfile: RelayProfile?, relayTestFailure: RelayTestFailure?) case serverOperatorConditions(conditions: ServerOperatorConditions) case userServers(user: UserRef, userServers: [UserOperatorServers]) - case userServersValidation(user: UserRef, serverErrors: [UserServersError]) + case userServersValidation(user: UserRef, serverErrors: [UserServersError], serverWarnings: [UserServersWarning]) case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) case chatItemTTL(user: UserRef, chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) + case groupInfo(user: UserRef, groupInfo: GroupInfo) case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo) case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) @@ -683,9 +713,11 @@ enum ChatResponse0: Decodable, ChatAPIResult { case .chatStopped: "chatStopped" case .apiChats: "apiChats" case .apiChat: "apiChat" + case .chatContentTypes: "chatContentTypes" case .chatTags: "chatTags" case .chatItemInfo: "chatItemInfo" case .serverTestResult: "serverTestResult" + case .chatRelayTestResult: "chatRelayTestResult" case .serverOperatorConditions: "serverOperators" case .userServers: "userServers" case .userServersValidation: "userServersValidation" @@ -693,6 +725,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case .chatItemTTL: "chatItemTTL" case .networkConfig: "networkConfig" case .contactInfo: "contactInfo" + case .groupInfo: "groupInfo" case .groupMemberInfo: "groupMemberInfo" case .queueInfo: "queueInfo" case .contactSwitchStarted: "contactSwitchStarted" @@ -717,16 +750,19 @@ enum ChatResponse0: Decodable, ChatAPIResult { case .chatStopped: return noDetails case let .apiChats(u, chats): return withUser(u, String(describing: chats)) case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") + case let .chatContentTypes(types): return "content types: \(String(describing: types))" case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .chatRelayTestResult(u, relayProfile, relayTestFailure): return withUser(u, "relayProfile: \(String(describing: relayProfile))\nresult: \(String(describing: relayTestFailure))") case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") - case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") + case let .userServersValidation(u, serverErrors, serverWarnings): return withUser(u, "serverErrors: \(String(describing: serverErrors))\nserverWarnings: \(String(describing: serverWarnings))") case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupInfo(u, groupInfo): return withUser(u, "groupInfo: \(String(describing: groupInfo))") case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") case let .queueInfo(u, rcvMsgInfo, queueInfo): let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } @@ -761,6 +797,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatResponse1 enum ChatResponse1: Decodable, ChatAPIResult { case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) @@ -772,7 +809,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case sentConfirmation(user: UserRef, connection: PendingContactConnection) case sentInvitation(user: UserRef, connection: PendingContactConnection) case startedConnectionToContact(user: UserRef, contact: Contact) - case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo) + case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo, relayResults: [RelayConnectionResult]) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) @@ -793,8 +830,8 @@ enum ChatResponse1: Decodable, ChatAPIResult { case userContactLinkDeleted(user: User) case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef, contactRequest: UserContactRequest, contact_: Contact?) - case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) case newChatItems(user: UserRef, chatItems: [AChatItem]) + case chatMsgContent(user: UserRef, msgContent: MsgContent) case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) case chatItemUpdated(user: UserRef, chatItem: AChatItem) @@ -837,8 +874,8 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .userContactLinkDeleted: "userContactLinkDeleted" case .acceptingContactRequest: "acceptingContactRequest" case .contactRequestRejected: "contactRequestRejected" - case .networkStatuses: "networkStatuses" case .newChatItems: "newChatItems" + case .chatMsgContent: "chatMsgContent" case .groupChatItemsDeleted: "groupChatItemsDeleted" case .forwardPlan: "forwardPlan" case .chatItemUpdated: "chatItemUpdated" @@ -870,10 +907,10 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .userContactLinkDeleted: return noDetails case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestRejected(u, contactRequest, contact_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\ncontact_: \(String(describing: contact_))") - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .chatMsgContent(u, mc): return withUser(u, String(describing: mc)) case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") @@ -896,16 +933,22 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .startedConnectionToContact(u, contact): return withUser(u, String(describing: contact)) - case let .startedConnectionToGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .startedConnectionToGroup(u, groupInfo, relayResults): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nrelayResults: \(String(describing: relayResults))") case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) } } } +// Spec: spec/api.md#ChatResponse2 enum ChatResponse2: Decodable, ChatAPIResult { // group responses case groupCreated(user: UserRef, groupInfo: GroupInfo) + case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay]) + case publicGroupCreationFailed(user: UserRef, addRelayResults: [AddRelayResult]) + case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay]) + case groupRelaysAdded(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay]) + case groupRelaysAddFailed(user: UserRef, addRelayResults: [AddRelayResult]) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) @@ -956,6 +999,11 @@ enum ChatResponse2: Decodable, ChatAPIResult { var responseType: String { switch self { case .groupCreated: "groupCreated" + case .publicGroupCreated: "publicGroupCreated" + case .publicGroupCreationFailed: "publicGroupCreationFailed" + case .groupRelays: "groupRelays" + case .groupRelaysAdded: "groupRelaysAdded" + case .groupRelaysAddFailed: "groupRelaysAddFailed" case .sentGroupInvitation: "sentGroupInvitation" case .userAcceptedGroupSent: "userAcceptedGroupSent" case .userDeletedMembers: "userDeletedMembers" @@ -1002,6 +1050,11 @@ enum ChatResponse2: Decodable, ChatAPIResult { var details: String { switch self { case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)") + case let .publicGroupCreationFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)") + case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)") + case let .groupRelaysAdded(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)") + case let .groupRelaysAddFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)") case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") @@ -1046,6 +1099,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatEvent enum ChatEvent: Decodable, ChatAPIResult { case chatSuspended case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) @@ -1059,9 +1113,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest, chat_: ChatData?) case contactUpdated(user: UserRef, toContact: Contact) case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) - case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) - case networkStatus(networkStatus: NetworkStatus, connections: [String]) - case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case subscriptionStatus(subscriptionStatus: SubscriptionStatus, connections: [String]) case chatInfoUpdated(user: UserRef, chatInfo: ChatInfo) case newChatItems(user: UserRef, chatItems: [AChatItem]) case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) @@ -1082,10 +1134,12 @@ enum ChatEvent: Decodable, ChatAPIResult { case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) + case userJoinedGroup(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) case groupUpdated(user: UserRef, toGroup: GroupInfo) + case groupLinkDataUpdated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay], relaysChanged: Bool) + case groupRelayUpdated(user: UserRef, groupInfo: GroupInfo, member: GroupMember, groupRelay: GroupRelay) case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) // receiving file events case rcvFileAccepted(user: UserRef, chatItem: AChatItem) @@ -1138,9 +1192,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .receivedContactRequest: "receivedContactRequest" case .contactUpdated: "contactUpdated" case .groupMemberUpdated: "groupMemberUpdated" - case .contactsMerged: "contactsMerged" - case .networkStatus: "networkStatus" - case .networkStatuses: "networkStatuses" + case .subscriptionStatus: "subscriptionStatus" case .chatInfoUpdated: "chatInfoUpdated" case .newChatItems: "newChatItems" case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" @@ -1164,6 +1216,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .joinedGroupMember: "joinedGroupMember" case .connectedToGroupMember: "connectedToGroupMember" case .groupUpdated: "groupUpdated" + case .groupLinkDataUpdated: "groupLinkDataUpdated" + case .groupRelayUpdated: "groupRelayUpdated" case .newMemberContactReceivedInv: "newMemberContactReceivedInv" case .rcvFileAccepted: "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" @@ -1212,9 +1266,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .receivedContactRequest(u, contactRequest, chat_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\nchat_: \(String(describing: chat_))") case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") - case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") - case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) + case let .subscriptionStatus(status, conns): return "subscriptionStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" case let .chatInfoUpdated(u, chatInfo): return withUser(u, String(describing: chatInfo)) case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") @@ -1242,10 +1294,12 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .userJoinedGroup(u, groupInfo, _): return withUser(u, String(describing: groupInfo)) case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .groupLinkDataUpdated(u, groupInfo, groupLink, groupRelays, relaysChanged): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)\nrelaysChanged: \(relaysChanged)") + case let .groupRelayUpdated(u, groupInfo, member, groupRelay): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\ngroupRelay: \(groupRelay)") case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails @@ -1284,6 +1338,7 @@ enum ChatEvent: Decodable, ChatAPIResult { struct NewUser: Encodable { var profile: Profile? var pastTimestamp: Bool + var userChatRelay: Bool = false } enum ChatPagination { @@ -1308,6 +1363,11 @@ enum ChatPagination { } } +enum OwnerVerification: Decodable, Hashable { + case verified + case failed(reason: String) +} + enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) case contactAddress(contactAddressPlan: ContactAddressPlan) @@ -1316,14 +1376,14 @@ enum ConnectionPlan: Decodable, Hashable { } enum InvitationLinkPlan: Decodable, Hashable { - case ok(contactSLinkData_: ContactShortLinkData?) + case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?) case ownLink case connecting(contact_: Contact?) case known(contact: Contact) } enum ContactAddressPlan: Decodable, Hashable { - case ok(contactSLinkData_: ContactShortLinkData?) + case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?) case ownLink case connectingConfirmReconnect case connectingProhibit(contact: Contact) @@ -1331,12 +1391,19 @@ enum ContactAddressPlan: Decodable, Hashable { case contactViaAddress(contact: Contact) } +public struct GroupShortLinkInfo: Decodable, Hashable { + public var direct: Bool + public var groupRelays: [String] + public var publicGroupId: String? +} + enum GroupLinkPlan: Decodable, Hashable { - case ok(groupSLinkData_: GroupShortLinkData?) + case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?) case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect case connectingProhibit(groupInfo_: GroupInfo?) case known(groupInfo: GroupInfo) + case noRelays(groupSLinkData_: GroupShortLinkData?) } struct ChatTagData: Encodable { @@ -1374,38 +1441,6 @@ enum ChatDeleteMode: Codable { } } -enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(connectionError: String) - - var statusString: LocalizedStringKey { - switch self { - case .connected: "connected" - case .error: "error" - default: "connecting" - } - } - - var statusExplanation: LocalizedStringKey { - switch self { - case .connected: "You are connected to the server used to receive messages from this contact." - case let .error(err): "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: "Trying to connect to the server used to receive messages from this contact." - } - } - - var imageName: String { - switch self { - case .unknown: "circle.dotted" - case .connected: "circle.fill" - case .disconnected: "ellipsis.circle.fill" - case .error: "exclamationmark.circle.fill" - } - } -} - enum ForwardConfirmation: Decodable, Hashable { case filesNotAccepted(fileIds: [Int64]) case filesInProgress(filesCount: Int) @@ -1413,11 +1448,6 @@ enum ForwardConfirmation: Decodable, Hashable { case filesFailed(filesCount: Int) } -struct ConnNetworkStatus: Decodable { - var agentConnId: String - var networkStatus: NetworkStatus -} - struct UserMsgReceiptSettings: Codable { var enable: Bool var clearOverrides: Bool @@ -1749,6 +1779,7 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { var `operator`: ServerOperator? var smpServers: [UserServer] var xftpServers: [UserServer] + var chatRelays: [UserChatRelay] var id: String { if let op = self.operator { @@ -1778,21 +1809,28 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { static var sampleData1 = UserOperatorServers( operator: ServerOperator.sampleData1, smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] + xftpServers: [UserServer.sampleData.xftpPreset], + chatRelays: [] ) static var sampleDataNilOperator = UserOperatorServers( operator: nil, smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] + xftpServers: [UserServer.sampleData.xftpPreset], + chatRelays: [] ) } +public enum UserServersWarning: Decodable { + case noChatRelays(user: UserRef?) +} + enum UserServersError: Decodable { case noServers(protocol: ServerProtocol, user: UserRef?) case storageMissing(protocol: ServerProtocol, user: UserRef?) case proxyMissing(protocol: ServerProtocol, user: UserRef?) case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String) var globalError: String? { switch self { @@ -1950,6 +1988,16 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable { } } +struct RelayConnectionResult: Decodable { + var relayMember: GroupMember + var relayError: ChatError? +} + +struct AddRelayResult: Decodable { + var relay: UserChatRelay + var relayError: ChatError? +} + enum ProtocolTestStep: String, Decodable, Equatable { case connect case disconnect @@ -2001,6 +2049,41 @@ struct ProtocolTestFailure: Decodable, Error, Equatable { } } +public enum RelayTestStep: String, Decodable { + case getLink + case decodeLink + case connect + case waitResponse + case verify + + var text: String { + switch self { + case .getLink: return NSLocalizedString("Get link", comment: "relay test step") + case .decodeLink: return NSLocalizedString("Decode link", comment: "relay test step") + case .connect: return NSLocalizedString("Connect", comment: "relay test step") + case .waitResponse: return NSLocalizedString("Wait response", comment: "relay test step") + case .verify: return NSLocalizedString("Verify", comment: "relay test step") + } + } +} + +public struct RelayTestFailure: Decodable, Error { + public var rtfStep: RelayTestStep + public var rtfError: ChatError + + var localizedDescription: String { + let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "relay test failure"), rtfStep.text) + switch rtfError { + case .errorAgent(agentError: .SMP(_, .AUTH)): + return err + " " + NSLocalizedString("Server requires authorization to connect to relay, check password.", comment: "relay test error") + case .errorAgent(agentError: .BROKER(_, .NETWORK(.unknownCAError))): + return err + " " + NSLocalizedString("Fingerprint in server address does not match certificate.", comment: "relay test error") + default: + return err + " " + String.localizedStringWithFormat(NSLocalizedString("Error: %@.", comment: "relay test error"), String(describing: rtfError)) + } + } +} + struct MigrationFileLinkData: Codable { let networkConfig: NetworkConfig? @@ -2039,6 +2122,7 @@ struct AppSettings: Codable, Equatable { var privacyAskToApproveRelays: Bool? = nil var privacyAcceptImages: Bool? = nil var privacyLinkPreviews: Bool? = nil + var privacySanitizeLinks: Bool? = nil var privacyShowChatPreviews: Bool? = nil var privacySaveLastDraft: Bool? = nil var privacyProtectScreen: Bool? = nil @@ -2074,6 +2158,7 @@ struct AppSettings: Codable, Equatable { if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacySanitizeLinks != def.privacySanitizeLinks { empty.privacySanitizeLinks = privacySanitizeLinks } if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } @@ -2110,6 +2195,7 @@ struct AppSettings: Codable, Equatable { privacyAskToApproveRelays: true, privacyAcceptImages: true, privacyLinkPreviews: true, + privacySanitizeLinks: false, privacyShowChatPreviews: true, privacySaveLastDraft: true, privacyProtectScreen: false, diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 25eab6c69e..aa4dfa24f8 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 08/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import BackgroundTasks @@ -25,6 +26,7 @@ private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes private let maxTimerCount = 9 +// Spec: spec/services/notifications.md#BGManager class BGManager { static let shared = BGManager() var chatReceiver: ChatReceiver? @@ -32,6 +34,7 @@ class BGManager { var completed = true var timerCount = 0 + // Spec: spec/services/notifications.md#register func register() { logger.debug("BGManager.register") BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in @@ -39,6 +42,7 @@ class BGManager { } } + // Spec: spec/services/notifications.md#schedule func schedule(interval: TimeInterval? = nil) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.schedule: disabled") @@ -66,6 +70,7 @@ class BGManager { Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval } + // Spec: spec/services/notifications.md#handleRefresh private func handleRefresh(_ task: BGAppRefreshTask) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.handleRefresh: disabled") @@ -103,6 +108,7 @@ class BGManager { } } + // Spec: spec/services/notifications.md#receiveMessages-BG func receiveMessages(_ completeReceiving: @escaping (String) -> Void) { if (!self.completed) { logger.debug("BGManager.receiveMessages: in progress, exiting") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e5fd6362a3..a1d28b8e22 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 22/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/state.md import Foundation import Combine @@ -53,6 +54,7 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { } // analogue for SecondaryContextFilter in Kotlin +// Spec: spec/state.md#SecondaryItemsModelFilter enum SecondaryItemsModelFilter { case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) case msgContentTagContext(contentTag: MsgContentTag) @@ -68,6 +70,7 @@ enum SecondaryItemsModelFilter { } // analogue for ChatsContext in Kotlin +// Spec: spec/state.md#ItemsModel class ItemsModel: ObservableObject { static let shared = ItemsModel(secondaryIMFilter: nil) public var secondaryIMFilter: SecondaryItemsModelFilter? @@ -103,12 +106,14 @@ class ItemsModel: ObservableObject { .store(in: &bag) } + // Spec: spec/state.md#loadSecondaryChat static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) { let im = ItemsModel(secondaryIMFilter: chatFilter) ChatModel.shared.secondaryIM = im im.loadOpenChat(chatId, willNavigate: willNavigate) } + // Spec: spec/state.md#loadOpenChat func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -134,6 +139,7 @@ class ItemsModel: ObservableObject { } } + // Spec: spec/state.md#loadOpenChatNoWait func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -179,6 +185,7 @@ class PreloadState { } } +// Spec: spec/state.md#ChatTagsModel class ChatTagsModel: ObservableObject { static let shared = ChatTagsModel() @@ -283,29 +290,6 @@ class ChatTagsModel: ObservableObject { } } -class NetworkModel: ObservableObject { - // map of connections network statuses, key is agent connection id - @Published var networkStatuses: Dictionary = [:] - - static let shared = NetworkModel() - - private init() { } - - func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { - if let conn = contact.activeConn { - networkStatuses[conn.agentConnId] = status - } - } - - func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { - if let conn = contact.activeConn { - networkStatuses[conn.agentConnId] ?? .unknown - } else { - .unknown - } - } -} - /// ChatItemWithMenu can depend on previous or next item for it's appearance /// This dummy model is used to force an update of all chat items, /// when they might have changed appearance. @@ -349,6 +333,33 @@ class ConnectProgressManager: ObservableObject { } } +class ChannelRelaysModel: ObservableObject { + static let shared = ChannelRelaysModel() + @Published var groupId: Int64? = nil + @Published var groupRelays: [GroupRelay] = [] + + func set(groupId: Int64, groupRelays: [GroupRelay]) { + self.groupId = groupId + self.groupRelays = groupRelays + } + + func updateRelay(_ groupInfo: GroupInfo, _ relay: GroupRelay) { + if groupId == groupInfo.groupId { + if let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) { + groupRelays[i] = relay + } else { + groupRelays.append(relay) + } + } + } + + func reset() { + groupId = nil + groupRelays = [] + } +} + +// Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var setDeliveryReceipts = false @@ -374,11 +385,17 @@ final class ChatModel: ObservableObject { @Published var deletedChats: Set = [] // current chat @Published var chatId: String? + @Published var chatAgentConnId: String? + @Published var chatSubStatus: SubscriptionStatus? @Published var openAroundItemId: ChatItem.ID? = nil @Published var chatToTop: String? + @Published var creatingChannelId: String? @Published var groupMembers: [GMember] = [] @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list @Published var membersLoaded = false + // Runtime-only relay hostnames for pre-join channel display, not persisted — lost on app restart. + // APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join. + @Published var channelRelayHostnames: [Int64: [String]] = [:] // items in the terminal view @Published var showingTerminal = false @Published var terminalItems: [TerminalItem] = [] @@ -404,6 +421,7 @@ final class ChatModel: ObservableObject { @Published var showCallView = false @Published var activeCallViewIsCollapsed = false // remote desktop + // Spec: spec/architecture.md#remoteCtrlSession @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation @Published var showingInvitation: ShowingInvitation? @@ -444,6 +462,7 @@ final class ChatModel: ObservableObject { userAddress?.shortLinkDataSet ?? true } + // Spec: spec/state.md#getUser func getUser(_ userId: Int64) -> User? { currentUser?.userId == userId ? currentUser @@ -454,6 +473,7 @@ final class ChatModel: ObservableObject { users.firstIndex { $0.user.userId == user.userId } } + // Spec: spec/state.md#updateUser func updateUser(_ user: User) { if let i = getUserIndex(user) { users[i].user = user @@ -463,6 +483,7 @@ final class ChatModel: ObservableObject { } } + // Spec: spec/state.md#removeUser func removeUser(_ user: User) { if let i = getUserIndex(user) { users.remove(at: i) @@ -473,6 +494,7 @@ final class ChatModel: ObservableObject { chats.first(where: { $0.id == id }) != nil } + // Spec: spec/state.md#getChat func getChat(_ id: String) -> Chat? { chats.first(where: { $0.id == id }) } @@ -527,6 +549,7 @@ final class ChatModel: ObservableObject { chats.firstIndex(where: { $0.id == id }) } + // Spec: spec/state.md#addChat func addChat(_ chat: Chat) { if chatId == nil { withAnimation { addChat_(chat, at: 0) } @@ -540,6 +563,7 @@ final class ChatModel: ObservableObject { chats.insert(chat, at: position) } + // Spec: spec/state.md#updateChatInfo func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil { @@ -591,6 +615,7 @@ final class ChatModel: ObservableObject { } } + // Spec: spec/state.md#replaceChat func replaceChat(_ id: String, _ chat: Chat) { if let i = getChatIndex(id) { chats[i] = chat @@ -787,11 +812,6 @@ final class ChatModel: ObservableObject { } func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) { - // this should not happen, only another member can "remove" user, user can only "leave" (another event). - if byMember.groupMemberId == groupInfo.membership.groupMemberId { - logger.debug("exiting removeMemberItems") - return - } if chatId == groupInfo.id { for i in 0.. Int { var unread: Int = 0 for chat in chats { @@ -1179,6 +1200,7 @@ final class ChatModel: ObservableObject { return (prevMember, memberIds.count) } + // Spec: spec/state.md#popChat func popChat(_ id: String) { if let i = getChatIndex(id) { // no animation here, for it not to look like it just moved when leaving the chat @@ -1202,14 +1224,26 @@ final class ChatModel: ObservableObject { showingInvitation?.connChatUsed = true } + // Spec: spec/state.md#removeChat func removeChat(_ id: String) { + var groupId: Int64? withAnimation { if let i = getChatIndex(id) { let removed = chats.remove(at: i) + groupId = removed.chatInfo.groupInfo?.groupId ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats) removeWallpaperFilesFromChat(removed) } } + if chatId == id { + groupMembers = [] + groupMembersIndexes.removeAll() + // Remove channelRelayHostnames for this channel only, preserving other prepared channels + if let gId = groupId { + channelRelayHostnames.removeValue(forKey: gId) + } + membersLoaded = false + } } func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool { @@ -1218,13 +1252,19 @@ final class ChatModel: ObservableObject { updateGroup(groupInfo) return false } - // update current chat - if chatId == groupInfo.id { + // update current chat or channel being created + if chatId == groupInfo.id || creatingChannelId == groupInfo.id { if let i = groupMembersIndexes[member.groupMemberId] { + let connStatusChanged = self.groupMembers[i].wrapped.activeConn?.connStatus != member.activeConn?.connStatus withAnimation(.default) { self.groupMembers[i].wrapped = member self.groupMembers[i].created = Date.now } + // Updating wrapped on a reference-type GMember doesn't mutate the groupMembers array, + // so ChatModel.objectWillChange doesn't fire automatically — notify views explicitly. + if connStatusChanged { + objectWillChange.send() + } return false } else { withAnimation { @@ -1274,6 +1314,7 @@ struct NTFContactRequest { var chatId: String } +// Spec: spec/state.md#Chat final class Chat: ObservableObject, Identifiable, ChatLike { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 79f4ef2f09..c6c6e88d8c 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 08/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UserNotifications @@ -22,6 +23,7 @@ enum NtfCallAction { case reject } +// Spec: spec/services/notifications.md#NtfManager class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { static let shared = NtfManager() @@ -48,6 +50,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { handler() } + // Spec: spec/services/notifications.md#processNotificationResponse func processNotificationResponse(_ ntfResponse: UNNotificationResponse) { let chatModel = ChatModel.shared let content = ntfResponse.notification.request.content @@ -149,6 +152,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { return false } + // Spec: spec/services/notifications.md#registerCategories func registerCategories() { logger.debug("NtfManager.registerCategories") UNUserNotificationCenter.current().setNotificationCategories([ @@ -207,6 +211,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { ]) } + // Spec: spec/services/notifications.md#requestAuthorization func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) { logger.debug("NtfManager.requestAuthorization") let center = UNUserNotificationCenter.current() @@ -230,6 +235,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { } } + // Spec: spec/services/notifications.md#notifyContactRequest func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") addNotification(createContactRequestNtf(user, contactRequest, 0)) @@ -240,6 +246,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createContactConnectedNtf(user, contact, 0)) } + // Spec: spec/services/notifications.md#notifyMessageReceived func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") if cInfo.ntfsEnabled(chatItem: cItem) { @@ -247,16 +254,19 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { } } + // Spec: spec/services/notifications.md#notifyCallInvitation func notifyCallInvitation(_ invitation: RcvCallInvitation) { logger.debug("NtfManager.notifyCallInvitation") addNotification(createCallInvitationNtf(invitation, 0)) } + // Spec: spec/services/notifications.md#setNtfBadgeCount func setNtfBadgeCount(_ count: Int) { UIApplication.shared.applicationIconBadgeNumber = count ntfBadgeCountGroupDefault.set(count) } + // Spec: spec/services/notifications.md#changeNtfBadgeCount func changeNtfBadgeCount(by count: Int = 1) { setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count)) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f95e6ac2dd..ea2de31569 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md | spec/architecture.md import Foundation import UIKit @@ -15,8 +16,6 @@ import SwiftUI private var chatController: chat_ctrl? -private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock") - enum TerminalItem: Identifiable { case cmd(Date, ChatCommand) case res(Date, ChatAPIResult) @@ -51,6 +50,7 @@ enum TerminalItem: Identifiable { } } +// Spec: spec/architecture.md#beginBGTask func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { var id: UIBackgroundTaskIdentifier! var running = true @@ -88,12 +88,14 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } +// Spec: spec/api.md#chatSendCmdSync @inline(__always) func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R { let res: APIResult = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) return try apiResult(res) } +// Spec: spec/api.md#chatApiSendCmdSync func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult { if log { logger.debug("chatSendCmd \(cmd.cmdType)") @@ -114,12 +116,14 @@ func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = tru return resp } +// Spec: spec/api.md#chatSendCmd @inline(__always) func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R { let res: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) return try apiResult(res) } +// Spec: spec/api.md#chatApiSendCmdWithRetry func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? { let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum) if inProgress == nil || inProgress?.boxedValue == true, @@ -212,6 +216,7 @@ func proxyDestinationErrorAlertMessage(proxyServer: String, destServer: String) String.localizedStringWithFormat(NSLocalizedString("Forwarding server %@ failed to connect to destination server %@. Please try later.", comment: "alert message"), serverHostname(proxyServer), serverHostname(destServer)) } +// Spec: spec/api.md#chatApiSendCmd @inline(__always) func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult { await withCheckedContinuation { cont in @@ -228,6 +233,7 @@ func apiResult(_ res: APIResult) throws -> R { } } +// Spec: spec/api.md#chatRecvMsg func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult? { await withCheckedContinuation { cont in _ = withBGTask(bgDelay: msgDelay) { () -> APIResult? in @@ -348,6 +354,7 @@ func apiStopChat() async throws { } } +// Spec: spec/architecture.md#apiActivateChat func apiActivateChat() { chatReopenStore() do { @@ -357,6 +364,7 @@ func apiActivateChat() { } } +// Spec: spec/architecture.md#apiSuspendChat func apiSuspendChat(timeoutMicroseconds: Int) { do { try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) @@ -365,12 +373,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) { } } +// Spec: spec/services/files.md#apiSetAppFilePaths func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws { let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl) if case .cmdOk = r { return } throw r.unexpected } +// Spec: spec/services/files.md#apiSetEncryptLocalFiles func apiSetEncryptLocalFiles(_ enable: Bool) throws { try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable)) } @@ -446,11 +456,17 @@ func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTa throw r.unexpected } -func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async { - await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems) +func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope? = nil) async throws -> [MsgContentTag] { + let r: ChatResponse0 = try await chatSendCmd(.apiGetChatContentTypes(chatId: chatId, scope: scope)) + if case let .chatContentTypes(types) = r { return types.filter { if case .unknown = $0 { return false }; return true } } + throw r.unexpected } -func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { +func loadChat(chat: Chat, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", clearItems: Bool = true) async { + await loadChat(chatId: chat.chatInfo.id, im: im, contentTag: contentTag, search: search, clearItems: clearItems) +} + +func loadChat(chatId: ChatId, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { await MainActor.run { if clearItems { im.reversedChatItems = [] @@ -464,10 +480,11 @@ func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundIte openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : ( - search == "" + contentTag == nil && search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage) ) ), + contentTag, search, openAroundItemId, { 0...0 } @@ -486,8 +503,14 @@ func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, throw r.unexpected } -func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { - let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) +func apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) async throws -> MsgContent { + let r: ChatResponse1 = try await chatSendCmd(.apiShareChatMsgContent(shareChatType: shareChatType, shareChatId: shareChatId, toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup)) + if case let .chatMsgContent(_, mc) = r { return mc } + throw r.unexpected +} + +func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool = false, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { + let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) } @@ -519,8 +542,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws { try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds)) } -func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages) +func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) } @@ -741,6 +764,15 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail throw r.unexpected } +func testChatRelay(address: String) async throws -> (RelayProfile?, RelayTestFailure?) { + let userId = try currentUserId("testChatRelay") + let r: ChatResponse0 = try await chatSendCmd(.apiTestChatRelay(userId: userId, address: address)) + if case let .chatRelayTestResult(_, relayProfile, relayTestFailure) = r { + return (relayProfile, relayTestFailure) + } + throw r.unexpected +} + func getServerOperators() async throws -> ServerOperatorConditions { let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators) if case let .serverOperatorConditions(conditions) = r { return conditions } @@ -778,10 +810,10 @@ func setUserServers(userServers: [UserOperatorServers]) async throws { throw r.unexpected } -func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] { +func validateServers(userServers: [UserOperatorServers]) async throws -> ([UserServersError], [UserServersWarning]) { let userId = try currentUserId("validateServers") let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) - if case let .userServersValidation(_, serverErrors) = r { return serverErrors } + if case let .userServersValidation(_, serverErrors, serverWarnings) = r { return (serverErrors, serverWarnings) } logger.error("validateServers error: \(String(describing: r))") throw r.unexpected } @@ -873,6 +905,12 @@ func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSett try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings)) } +func apiGetUpdatedGroupLinkData(_ groupId: Int64) async -> GroupInfo? { + let r: APIResult = await chatApiSendCmd(.apiGetUpdatedGroupLinkData(groupId: groupId)) + if case let .result(.groupInfo(_, groupInfo)) = r { return groupInfo } + return nil +} + func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r: ChatResponse0 = try await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } @@ -988,12 +1026,12 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi if let r { throw r.unexpected } else { return nil } } -func apiConnectPlan(connLink: String, inProgress: BoxedValue) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { +func apiConnectPlan(connLink: String, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnectPlan: no current user") return (nil, nil) } - let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink), inProgress: inProgress) + let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, linkOwnerSig: linkOwnerSig), inProgress: inProgress) if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) } let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil } return (nil, alert) @@ -1079,6 +1117,27 @@ private func apiConnectResponseAlert(_ r: APIResult) -> Alert { } } +func connErrorText(_ e: ChatError) -> String { + switch e { + case .error(.invalidConnReq): + NSLocalizedString("Invalid connection link", comment: "conn error description") + case .error(.unsupportedConnReq): + NSLocalizedString("Unsupported connection link", comment: "conn error description") + case .errorAgent(.SMP(_, .AUTH)): + NSLocalizedString("Connection error (AUTH)", comment: "conn error description") + case let .errorAgent(.SMP(_, .BLOCKED(info))): + NSLocalizedString("Connection blocked: \(info.reason.text)", comment: "conn error description") + case .errorAgent(.SMP(_, .QUOTA)): + NSLocalizedString("The connection reached the limit of undelivered messages", comment: "conn error description") + default: + if getNetworkErrorAlert(e) != nil { + NSLocalizedString("Network error", comment: "conn error description") + } else { + "\(NSLocalizedString("Error", comment: "conn error description")): \(responseError(e))" + } + } +} + func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { mkAlert( title: "Contact already exists", @@ -1104,9 +1163,9 @@ func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactS throw r.unexpected } -func apiPrepareGroup(connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) async throws -> ChatData { +func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) async throws -> ChatData { let userId = try currentUserId("apiPrepareGroup") - let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, groupShortLinkData: groupShortLinkData)) + let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData)) if case let .newPreparedChat(_, chat) = r { return chat } throw r.unexpected } @@ -1130,9 +1189,9 @@ func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgConten return nil } -func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> GroupInfo? { +func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg)) - if case let .result(.startedConnectionToGroup(_, groupInfo)) = r { return groupInfo } + if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) } if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) } return nil } @@ -1319,6 +1378,10 @@ func apiCreateUserAddress() async throws -> CreatedConnLink? { let userId = try currentUserId("apiCreateUserAddress") let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateMyAddress(userId: userId)) if case let .result(.userContactLinkCreated(_, connLink)) = r { return connLink } + if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r { + showClientNotice(server, preset, expires) + return nil + } if let r { throw r.unexpected } else { return nil } } @@ -1446,6 +1509,7 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF } } +// Spec: spec/services/files.md#receiveFile func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async { await receiveFiles( user: user, @@ -1564,6 +1628,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool } } +// Spec: spec/services/files.md#cancelFile func cancelFile(user: User, fileId: Int64) async { if let chatItem = await apiCancelFile(fileId: fileId) { await chatItemSimpleUpdate(user, chatItem) @@ -1586,12 +1651,14 @@ func setLocalDeviceName(_ displayName: String) throws { try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName)) } +// Spec: spec/architecture.md#connectRemoteCtrl func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { let r: ChatResponse2 = try await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } throw r.unexpected } +// Spec: spec/architecture.md#findKnownRemoteCtrl func findKnownRemoteCtrl() async throws { try await sendCommandOkResp(.findKnownRemoteCtrl) } @@ -1640,7 +1707,6 @@ func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: } else { ChatModel.shared.replaceChat(contactRequestChatId(contactRequestId), chat) } - NetworkModel.shared.setContactNetworkStatus(contact, .connected) inProgress?.wrappedValue = false } if contact.sndReady { @@ -1728,12 +1794,6 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } -func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { - let r: ChatResponse1 = try chatSendCmdSync(.apiGetNetworkStatuses) - if case let .networkStatuses(_, statuses) = r { return statuses } - throw r.unexpected -} - func markChatRead(_ im: ItemsModel, _ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { @@ -1808,6 +1868,45 @@ func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInf throw r.unexpected } +enum PublicGroupCreationResult { + case created(GroupInfo, GroupLink, [GroupRelay]) + case creationFailed([AddRelayResult]) +} + +func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> PublicGroupCreationResult? { + let userId = try currentUserId("apiNewPublicGroup") + let r: APIResult? = await chatApiSendCmdWithRetry(.apiNewPublicGroup(userId: userId, incognito: incognito, relayIds: relayIds, groupProfile: groupProfile)) + switch r { + case let .result(.publicGroupCreated(_, groupInfo, groupLink, groupRelays)): + return .created(groupInfo, groupLink, groupRelays) + case let .result(.publicGroupCreationFailed(_, addRelayResults)): + return .creationFailed(addRelayResults) + default: if let r { throw r.unexpected } else { return nil } + } +} + +func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] { + let r: APIResult = await chatApiSendCmd(.apiGetGroupRelays(groupId: groupId)) + if case let .result(.groupRelays(_, _, relays)) = r { return relays } + return [] +} + +enum AddGroupRelaysResult { + case added(GroupInfo, GroupLink, [GroupRelay]) + case addFailed([AddRelayResult]) +} + +func apiAddGroupRelays(_ groupId: Int64, relayIds: [Int64]) async throws -> AddGroupRelaysResult? { + let r: APIResult? = await chatApiSendCmdWithRetry(.apiAddGroupRelays(groupId: groupId, relayIds: relayIds)) + switch r { + case let .result(.groupRelaysAdded(_, groupInfo, groupLink, groupRelays)): + return .added(groupInfo, groupLink, groupRelays) + case let .result(.groupRelaysAddFailed(_, addRelayResults)): + return .addFailed(addRelayResults) + default: if let r { throw r.unexpected } else { return nil } + } +} + func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) if case let .sentGroupInvitation(_, _, _, member) = r { return member } @@ -1842,7 +1941,7 @@ func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async throw r.unexpected } -func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> (GroupInfo, [GroupMember]) { +func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) { let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) } throw r.unexpected @@ -1899,6 +1998,10 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink } + if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r { + showClientNotice(server, preset, expires) + return nil + } if let r { throw r.unexpected } else { return nil } } @@ -1955,7 +2058,6 @@ func acceptMemberContact(contactId: Int64, inProgress: Binding? = nil) asy if let contact = await apiAcceptMemberContact(contactId: contactId) { await MainActor.run { ChatModel.shared.updateContact(contact) - NetworkModel.shared.setContactNetworkStatus(contact, .connected) inProgress?.wrappedValue = false } if contact.sndReady { @@ -2073,6 +2175,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws { } } +// Spec: spec/architecture.md#startChat func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { logger.debug("startChat") let m = ChatModel.shared @@ -2096,7 +2199,7 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws withAnimation { let savedOnboardingStage = onboardingStageDefault.get() m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 - ? .step3_ChooseServerOperators + ? .step4_NetworkCommitments : savedOnboardingStage if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { m.setDeliveryReceipts = true @@ -2194,6 +2297,7 @@ private func getUserChatDataAsync(keepingChatId: String?) async throws { } } +// Spec: spec/architecture.md#ChatReceiver class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true @@ -2239,9 +2343,9 @@ class ChatReceiver { } } +// Spec: spec/api.md#processReceivedMsg func processReceivedMsg(_ res: ChatEvent) async { let m = ChatModel.shared - let n = NetworkModel.shared logger.debug("processReceivedMsg: \(res.responseType)") switch res { case let .contactDeletedByContact(user, contact): @@ -2258,14 +2362,15 @@ func processReceivedMsg(_ res: ChatEvent) async { m.dismissConnReqView(conn.id) m.removeChat(conn.id) } + if contact.id == m.chatId, let conn = contact.activeConn { + m.chatAgentConnId = conn.agentConnId + m.chatSubStatus = .active + } } } if contact.directOrUsed { NtfManager.shared.notifyContactConnected(user, contact) } - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } case let .contactConnecting(user, contact): if active(user) && contact.directOrUsed { await MainActor.run { @@ -2286,9 +2391,6 @@ func processReceivedMsg(_ res: ChatEvent) async { } } } - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } case let .receivedContactRequest(user, contactRequest, chat_): if active(user) { await MainActor.run { @@ -2327,39 +2429,10 @@ func processReceivedMsg(_ res: ChatEvent) async { _ = m.upsertGroupMember(groupInfo, toMember) } } - case let .contactsMerged(user, intoContact, mergedContact): - if active(user) && m.hasChat(mergedContact.id) { + case let .subscriptionStatus(status, connections): + if let chatAgentConnId = m.chatAgentConnId, connections.contains(chatAgentConnId) { await MainActor.run { - if m.chatId == mergedContact.id { - ItemsModel.shared.loadOpenChat(mergedContact.id) - } - m.removeChat(mergedContact.id) - } - } - case let .networkStatus(status, connections): - // dispatch queue to synchronize access - networkStatusesLock.sync { - var ns = n.networkStatuses - // slow loop is on the background thread - for cId in connections { - ns[cId] = status - } - // fast model update is on the main thread - DispatchQueue.main.sync { - n.networkStatuses = ns - } - } - case let .networkStatuses(_, statuses): () - // dispatch queue to synchronize access - networkStatusesLock.sync { - var ns = n.networkStatuses - // slow loop is on the background thread - for s in statuses { - ns[s.agentConnId] = s.networkStatus - } - // fast model update is on the main thread - DispatchQueue.main.sync { - n.networkStatuses = ns + m.chatSubStatus = status } } case let .chatInfoUpdated(user, chatInfo): @@ -2469,9 +2542,9 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .groupLinkConnecting(user, groupInfo, hostMember): if !active(user) { return } - await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, hostMember) if let hostConn = hostMember.activeConn { m.dismissConnReqView(hostConn.id) m.removeChat(hostConn.id) @@ -2534,10 +2607,11 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateGroup(groupInfo) } } - case let .userJoinedGroup(user, groupInfo): + case let .userJoinedGroup(user, groupInfo, hostMember): if active(user) { await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, hostMember) } if m.chatId == groupInfo.id { if groupInfo.membership.memberPending { @@ -2563,17 +2637,29 @@ func processReceivedMsg(_ res: ChatEvent) async { _ = m.upsertGroupMember(groupInfo, member) } } - if let contact = memberContact { - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } - } case let .groupUpdated(user, toGroup): if active(user) { await MainActor.run { m.updateGroup(toGroup) } } + case let .groupLinkDataUpdated(user, groupInfo, _, groupRelays, _): + if active(user) { + await MainActor.run { + m.updateGroup(groupInfo) + let relaysModel = ChannelRelaysModel.shared + if relaysModel.groupId == groupInfo.groupId { + relaysModel.set(groupId: groupInfo.groupId, groupRelays: groupRelays) + } + } + } + case let .groupRelayUpdated(user, groupInfo, member, groupRelay): + if active(user) { + await MainActor.run { + _ = m.upsertGroupMember(groupInfo, member) + ChannelRelaysModel.shared.updateRelay(groupInfo, groupRelay) + } + } case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _): if active(user) { await MainActor.run { @@ -2793,13 +2879,10 @@ func processReceivedMsg(_ res: ChatEvent) async { func switchToLocalSession() { let m = ChatModel.shared - let n = NetworkModel.shared m.remoteCtrlSession = nil do { m.users = try listUsers() try getUserChatData() - let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) } - n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses) } catch let error { logger.debug("error updating chat data: \(responseError(error))") } @@ -2907,3 +2990,26 @@ private struct UserResponse: Decodable { var user: User? var error: String? } + +private func showClientNotice(_ server: String, _ preset: Bool, _ expiresAt: Date?) { + DispatchQueue.main.async { + var message = "Server: \(server).\nConditions of use violation notice received from \(preset ? "preset" : "this") server.\nNo IDs shared, see How it works." + if let expiresAt { + message += "\n\nNew addresses can be created after \(expiresAt.formatted(date: .abbreviated, time: .shortened))." + } + showAlert("Not allowed", message: message) { + let howItWorks = UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(contentModerationPostLink) + }) + return preset + ? [ + okAlertAction, + UIAlertAction(title: NSLocalizedString("Conditions of use", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(conditionsURL) + }), + howItWorks + ] + : [okAlertAction, howItWorks] + } + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index e1a6bb61e8..1e9a97c31b 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -4,6 +4,7 @@ // // Created by Evgeny Poberezkin on 17/01/2022. // +// Spec: spec/architecture.md import SwiftUI import OSLog @@ -12,6 +13,7 @@ import SimpleXChat let logger = Logger() @main +// Spec: spec/architecture.md#SimpleXApp struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @@ -60,6 +62,7 @@ struct SimpleXApp: App { } } } +// Spec: spec/architecture.md#scenePhaseHandling .onChange(of: scenePhase) { phase in logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") AppSheetState.shared.scenePhaseActive = phase == .active diff --git a/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json b/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift index 3bd8f00c25..1f98b23a1d 100644 --- a/apps/ios/Shared/Theme/Theme.swift +++ b/apps/ios/Shared/Theme/Theme.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI import SimpleXChat +// Spec: spec/services/theme.md#CurrentColors var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get()) var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } } @@ -17,6 +18,7 @@ var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 } func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight } +// Spec: spec/services/theme.md#AppTheme class AppTheme: ObservableObject, Equatable { static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper) @@ -89,6 +91,7 @@ struct ThemedBackground: ViewModifier { } } +// Spec: spec/services/theme.md#systemInDarkThemeCurrently var systemInDarkThemeCurrently: Bool { return UITraitCollection.current.userInterfaceStyle == .dark } diff --git a/apps/ios/Shared/Theme/ThemeManager.swift b/apps/ios/Shared/Theme/ThemeManager.swift index 4166619d04..b9a35163cf 100644 --- a/apps/ios/Shared/Theme/ThemeManager.swift +++ b/apps/ios/Shared/Theme/ThemeManager.swift @@ -5,12 +5,15 @@ // Created by Avently on 03.06.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/services/theme.md import Foundation import SwiftUI import SimpleXChat +// Spec: spec/services/theme.md#ThemeManager class ThemeManager { + // Spec: spec/services/theme.md#ActiveTheme struct ActiveTheme: Equatable { let name: String let base: DefaultTheme @@ -41,6 +44,7 @@ class ThemeManager { } } + // Spec: spec/services/theme.md#defaultActiveTheme static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? { let nonSystemThemeName = nonSystemThemeName() let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName] @@ -56,6 +60,7 @@ class ThemeManager { return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil)) } + // Spec: spec/services/theme.md#currentColors static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme { let themeName = currentThemeDefault.get() let nonSystemThemeName = nonSystemThemeName() @@ -96,6 +101,7 @@ class ThemeManager { ) } + // Spec: spec/services/theme.md#currentThemeOverridesForExport static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides { let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get()) let wType = current.wallpaper.type @@ -114,6 +120,7 @@ class ThemeManager { ) } + // Spec: spec/services/theme.md#applyTheme static func applyTheme(_ theme: String) { currentThemeDefault.set(theme) CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get()) @@ -125,6 +132,7 @@ class ThemeManager { // applyNavigationBarColors(CurrentColors.toAppTheme()) } + // Spec: spec/services/theme.md#adjustWindowStyle static func adjustWindowStyle() { let style = switch currentThemeDefault.get() { case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light @@ -161,6 +169,7 @@ class ThemeManager { AppTheme.shared.updateFromCurrentColors() } + // Spec: spec/services/theme.md#saveAndApplyThemeColor static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) { let nonSystemThemeName = baseTheme.themeName let pref = pref ?? themeOverridesDefault @@ -178,6 +187,7 @@ class ThemeManager { pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex()) } + // Spec: spec/services/theme.md#saveAndApplyWallpaper static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) { let nonSystemThemeName = baseTheme.themeName let pref = pref ?? themeOverridesDefault @@ -253,6 +263,7 @@ class ThemeManager { pref.wrappedValue = prevValue } + // Spec: spec/services/theme.md#saveAndApplyThemeOverrides static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) { let wallpaper = theme.wallpaper?.importFromString() let nonSystemThemeName = theme.base.themeName @@ -273,6 +284,7 @@ class ThemeManager { applyTheme(nonSystemThemeName) } + // Spec: spec/services/theme.md#resetAllThemeColors static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) { let nonSystemThemeName = nonSystemThemeName() let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault @@ -295,6 +307,7 @@ class ThemeManager { pref.wrappedValue = prevValue } + // Spec: spec/services/theme.md#removeTheme static func removeTheme(_ themeId: String?) { var themes = themeOverridesDefault.get().map { $0 } themes.removeAll(where: { $0.themeId == themeId }) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index ab7a47b944..754bcb2715 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 05/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import SwiftUI import WebKit import SimpleXChat import AVFoundation +// Spec: spec/services/calls.md#ActiveCallView struct ActiveCallView: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @@ -282,6 +284,7 @@ struct ActiveCallView: View { } } +// Spec: spec/services/calls.md#ActiveCallOverlay struct ActiveCallOverlay: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var call: Call @@ -350,6 +353,7 @@ struct ActiveCallOverlay: View { } } + // Spec: spec/services/calls.md#audioCallInfoView private func audioCallInfoView(_ call: Call) -> some View { VStack { Text(call.contact.chatViewName) @@ -399,6 +403,7 @@ struct ActiveCallOverlay: View { } } + // Spec: spec/services/calls.md#endCallButton private func endCallButton() -> some View { let cc = CallController.shared return callButton("phone.down.fill", .red, padding: 10) { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 1f28180e87..9df0c2f0b7 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 21/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import Foundation import CallKit @@ -14,6 +15,7 @@ import AVFoundation import SimpleXChat import WebRTC +// Spec: spec/services/calls.md#CallController class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject { static let shared = CallController() static let isInChina = SKStorefront().countryCode == "CHN" @@ -49,6 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse logger.debug("CallController.providerDidReset") } + // Spec: spec/services/calls.md#CXStartCallAction func provider(_ provider: CXProvider, perform action: CXStartCallAction) { logger.debug("CallController.provider CXStartCallAction") if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) { @@ -59,6 +62,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXAnswerCallAction func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { logger.debug("CallController.provider CXAnswerCallAction") Task { @@ -88,6 +92,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXEndCallAction func provider(_ provider: CXProvider, perform action: CXEndCallAction) { logger.debug("CallController.provider CXEndCallAction") // Should be nil here if connection was in connected state @@ -103,6 +108,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXSetMutedCallAction func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) { action.fulfill() @@ -192,6 +198,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)") } + // Spec: spec/services/calls.md#pushRegistryDidReceive func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { logger.debug("CallController: did receive push with type \(type.rawValue)") if type != .voIP { @@ -276,6 +283,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse reportExpiredCall(update: update, completion) } + // Spec: spec/services/calls.md#reportNewIncomingCall func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))") if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { @@ -316,6 +324,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#reportOutgoingCall func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { logger.debug("CallController: reporting outgoing call connected") if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) { @@ -422,6 +431,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse provider.configuration = conf } + // Spec: spec/services/calls.md#hasActiveCalls func hasActiveCalls() -> Bool { controller.callObserver.calls.count > 0 } diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index db7910836e..2ce04e4b80 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -2,12 +2,14 @@ // Created by Avently on 09.02.2023. // Copyright (c) 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import WebRTC import LZString import SwiftUI import SimpleXChat +// Spec: spec/services/calls.md#WebRTCClient final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDelegate, RTCFrameDecryptorDelegate { private static let factory: RTCPeerConnectionFactory = { RTCInitializeSSL() @@ -87,6 +89,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), ] + // Spec: spec/services/calls.md#initializeCall func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) connection.delegate = self @@ -132,6 +135,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg ) } + // Spec: spec/services/calls.md#createPeerConnection func createPeerConnection(_ iceServers: [WebRTC.RTCIceServer], _ relay: Bool?) -> RTCPeerConnection { let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: ["DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue]) @@ -157,6 +161,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg return config } + // Spec: spec/services/calls.md#addIceCandidates func addIceCandidates(_ connection: RTCPeerConnection, _ remoteIceCandidates: [RTCIceCandidate]) { remoteIceCandidates.forEach { candidate in connection.add(candidate.toWebRTCCandidate()) { error in @@ -167,6 +172,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#sendCallCommand func sendCallCommand(command: WCallCommand) async { var resp: WCallResponse? = nil let pc = activeCall?.connection @@ -295,6 +301,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#sendIceCandidates func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { await self.sendCallResponse(.init( corrId: nil, @@ -353,6 +360,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#enableMedia @MainActor func enableMedia(_ source: CallMediaSource, _ enable: Bool) { logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)") @@ -411,6 +419,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg localRendererAspectRatio.wrappedValue = size.width / size.height } + // Spec: spec/services/calls.md#setupLocalTracks func setupLocalTracks(_ incomingCall: Bool, _ call: Call) { let pc = call.connection let transceivers = call.connection.transceivers @@ -490,6 +499,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } // Should be called after local description set + // Spec: spec/services/calls.md#setupEncryptionForLocalTracks func setupEncryptionForLocalTracks(_ call: Call) { if let encryptor = call.frameEncryptor { call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) } @@ -567,6 +577,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#startCaptureLocalVideo func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) { #if targetEnvironment(simulator) guard @@ -630,6 +641,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg return (localCamera, localVideoTrack) } + // Spec: spec/services/calls.md#endCall func endCall() { if #available(iOS 16.0, *) { _endCall() diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index b60842a4a0..e158b9374f 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -12,6 +12,7 @@ import SimpleXChat struct ChatInfoToolbar: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme + @EnvironmentObject var m: ChatModel @ObservedObject var chat: Chat var imageSize: CGFloat = 32 @@ -56,11 +57,41 @@ struct ChatInfoToolbar: View { .padding(.top, -2) } } + .if (channelSubscriberCount != nil) { v in + VStack(spacing: 0) { + v + if let count = channelSubscriberCount { + Text(subscriberCountStr(count)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .lineLimit(1) + .padding(.top, -2) + } + } + } + if let contact = chat.chatInfo.contact, + contact.ready && contact.active, + let chatSubStatus = m.chatSubStatus, + chatSubStatus != .active { + SubStatusView(status: chatSubStatus) + .padding(.leading, 4) + } } .foregroundColor(theme.colors.onBackground) .frame(width: 220) } + private var channelSubscriberCount: Int64? { + if case let .group(groupInfo, _) = chat.chatInfo, + groupInfo.useRelays, + let count = groupInfo.groupSummary.publicMemberCount, + count > 0 { + count + } else { + nil + } + } + private var contactVerifiedShield: Text { (Text(Image(systemName: "checkmark.shield")) + textSpace) .font(.caption) @@ -68,6 +99,36 @@ struct ChatInfoToolbar: View { .baselineOffset(1) .kerning(-2) } + + struct SubStatusView: View { + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + @EnvironmentObject var theme: AppTheme + var status: SubscriptionStatus + + var body: some View { + switch status { + case .active: EmptyView() + case .pending: ProgressView() + case .removed: subStatusError() + case .noSub: subStatusError() + } + } + + @ViewBuilder private func subStatusError() -> some View { + let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) + .foregroundColor(theme.colors.secondary) + } + } +} + +public func subscriberCountStr(_ count: Int64) -> String { + count == 1 + ? String.localizedStringWithFormat(NSLocalizedString("%d subscriber", comment: "channel subscriber count"), count) + : String.localizedStringWithFormat(NSLocalizedString("%d subscribers", comment: "channel subscriber count"), count) } struct ChatInfoToolbar_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 77c1db341a..c17d8e23a8 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 05/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI @preconcurrency import SimpleXChat @@ -88,11 +89,11 @@ enum SendReceipts: Identifiable, Hashable { } } +// Spec: spec/client/chat-view.md#ChatInfoView struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction - @ObservedObject var networkModel = NetworkModel.shared @ObservedObject var chat: Chat @State var contact: Contact @State var localAlias: String @@ -115,7 +116,7 @@ struct ChatInfoView: View { enum ChatInfoViewAlert: Identifiable { case clearChatAlert - case networkStatusAlert + case subStatusAlert(status: SubscriptionStatus) case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert @@ -126,7 +127,7 @@ struct ChatInfoView: View { var id: String { switch self { case .clearChatAlert: return "clearChatAlert" - case .networkStatusAlert: return "networkStatusAlert" + case let .subStatusAlert(status): return "subStatusAlert \(status)" case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" @@ -236,10 +237,12 @@ struct ChatInfoView: View { if contact.ready && contact.active { Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - networkStatusRow() - .onTapGesture { - alert = .networkStatusAlert - } + if let chatSubStatus = chatModel.chatSubStatus { + SubStatusRow(status: chatSubStatus) + .onTapGesture { + alert = .subStatusAlert(status: chatSubStatus) + } + } if let connStats = connectionStats { Button("Change receiving address") { alert = .switchAddressAlert @@ -325,7 +328,7 @@ struct ChatInfoView: View { .alert(item: $alert) { alertItem in switch(alertItem) { case .clearChatAlert: return clearChatAlert() - case .networkStatusAlert: return networkStatusAlert() + case let .subStatusAlert(status): return subStatusAlert(status) case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .syncConnectionForceAlert: @@ -546,26 +549,6 @@ struct ChatInfoView: View { } } - private func networkStatusRow() -> some View { - HStack { - Text("Network status") - Image(systemName: "info.circle") - .foregroundColor(theme.colors.primary) - .font(.system(size: 14)) - Spacer() - Text(networkModel.contactNetworkStatus(contact).statusString) - .foregroundColor(theme.colors.secondary) - serverImage() - } - } - - private func serverImage() -> some View { - let status = networkModel.contactNetworkStatus(contact) - return Image(systemName: status.imageName) - .foregroundColor(status == .connected ? .green : theme.colors.secondary) - .font(.system(size: 12)) - } - private func deleteContactButton() -> some View { Button(role: .destructive) { deleteContactDialog( @@ -605,10 +588,10 @@ struct ChatInfoView: View { ) } - private func networkStatusAlert() -> Alert { + private func subStatusAlert(_ status: SubscriptionStatus) -> Alert { Alert( title: Text("Network status"), - message: Text(networkModel.contactNetworkStatus(contact).statusExplanation) + message: Text(status.statusExplanation) ) } @@ -667,6 +650,30 @@ struct ChatInfoView: View { } } +struct SubStatusRow: View { + @EnvironmentObject var theme: AppTheme + var status: SubscriptionStatus + + var body: some View { + HStack { + Text("Network status") + Image(systemName: "info.circle") + .foregroundColor(theme.colors.primary) + .font(.system(size: 14)) + Spacer() + Text(status.statusString) + .foregroundColor(theme.colors.secondary) + serverImage(status) + } + } + + private func serverImage(_ status: SubscriptionStatus) -> some View { + return Image(systemName: status.imageName) + .foregroundColor(status == .active ? .green : theme.colors.secondary) + .font(.system(size: 12)) + } +} + struct ChatTTLOption: View { @ObservedObject var chat: Chat @Binding var progressIndicator: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift index 30f5e7a589..93ffb9f042 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift @@ -2,10 +2,12 @@ // Created by Avently on 19.12.2022. // Copyright (c) 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import UIKit import SwiftUI +// Spec: spec/client/chat-view.md#AnimatedImageView class AnimatedImageView: UIView { var image: UIImage? = nil var imageView: UIImageView? = nil diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 0283e9c07e..e5f3c05eed 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 20/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index b2b4441646..5521470d07 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 21/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIChatFeatureView struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel @Environment(\.revealed) var revealed: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift new file mode 100644 index 0000000000..aaaa29929d --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift @@ -0,0 +1,68 @@ +import SwiftUI +import SimpleXChat + +struct CIChatLinkHeader: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.showTimestamp) var showTimestamp: Bool + var chatItem: ChatItem + var chatLink: MsgChatLink + var ownerSig: LinkOwnerSig? + var hasText: Bool + + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + + var body: some View { + VStack(alignment: .leading) { + linkProfileView() + .padding(.horizontal, 2) + .padding(.top, 8) + .padding(.bottom, 6) + .overlay(DetermineWidth()) + Divider() + VStack(alignment: .leading, spacing: 2) { + if let descr = chatLink.shortDescription { + Text(descr) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .lineLimit(2) + .padding(.bottom, 2) + } + Text(chatLink.infoLine(signed: ownerSig != nil)) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .padding(.bottom, 2) + let t = Text("Tap to open").foregroundColor(theme.colors.primary).font(.callout) + if hasText { + t + } else { + t + + Text(verbatim: " ") + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + } + } + .overlay(DetermineWidth()) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func linkProfileView() -> some View { + HStack(alignment: .top) { + ProfileImage( + imageStr: chatLink.image, + iconName: chatLink.iconName, + size: 44, + color: Color(uiColor: .tertiaryLabel) + ) + .padding(.trailing, 4) + VStack(alignment: .leading) { + Text(chatLink.displayName).font(.headline).lineLimit(2) + let fn = chatLink.fullName + if fn != "" && fn != chatLink.displayName { + Text(fn).font(.subheadline).lineLimit(2) + } + } + .frame(minHeight: 44) + } + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift index 1375b87a5a..49a086d45a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 20.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIEventView struct CIEventView: View { var eventText: Text diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index 67f7b69e2c..dcd6ea579c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 21/12/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIFeaturePreferenceView struct CIFeaturePreferenceView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 1b9376b5db..639de1dbc9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 28/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIFileView struct CIFileView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 3fcf578875..ddb58fdfd1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 15.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIGroupInvitationView struct CIGroupInvitationView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index d1f49f635a..b56f1f9f2a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 12/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIImageView struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem @@ -96,12 +98,13 @@ struct CIImageView: View { if img.imageData == nil { Image(uiImage: img) .resizable() - .scaledToFit() - .frame(width: w) + .scaledToFill() + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() } else { - SwiftyGif(image: img) - .frame(width: w, height: w * img.size.height / img.size.width) - .scaledToFit() + SwiftyGif(image: img, contentMode: .scaleAspectFill) + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() } if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { loadingIndicator() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift index 5e9fa691de..80cccbf907 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 29.12.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIInvalidJSONView struct CIInvalidJSONView: View { @EnvironmentObject var theme: AppTheme var json: Data? diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index f07e90b953..4eb187bcac 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -5,13 +5,16 @@ // Created by Ian Davies on 07/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CILinkView struct CILinkView: View { @EnvironmentObject var theme: AppTheme let linkPreview: LinkPreview + let maxWidth: CGFloat @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0 var body: some View { @@ -19,7 +22,9 @@ struct CILinkView: View { if let uiImage = imageFromBase64(linkPreview.image) { Image(uiImage: uiImage) .resizable() - .scaledToFit() + .scaledToFill() + .frame(width: maxWidth, height: maxWidth * heightRatio(uiImage.size)) + .clipped() .modifier(PrivacyBlur(blurred: $blurred)) .if(!blurred) { v in v.simultaneousGesture(TapGesture().onEnded { @@ -113,7 +118,7 @@ struct LargeLinkPreview_Previews: PreviewProvider { description: "", image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z" ) - CILinkView(linkPreview: preview) + CILinkView(linkPreview: preview, maxWidth: 360) .previewLayout(.fixed(width: 360, height: 200)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index 2898a318a9..4719c3dcdc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -5,10 +5,12 @@ // Created by spaced4ndy on 19.09.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIMemberCreatedContactView struct CIMemberCreatedContactView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index fc73778239..e3bc654ac9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 11/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIMetaView struct CIMetaView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 3201332c1e..ec23dc15a4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 15/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." +// Spec: spec/client/chat-view.md#CIRcvDecryptionError struct CIRcvDecryptionError: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index eacbe9360a..e1172dab92 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -5,12 +5,14 @@ // Created by Avently on 30/03/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import AVKit import SimpleXChat import Combine +// Spec: spec/client/chat-view.md#CIVideoView struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem @@ -185,7 +187,8 @@ struct CIVideoView: View { ZStack(alignment: .center) { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) VideoPlayerView(player: player, url: url, showControls: false) - .frame(width: w, height: w * preview.size.height / preview.size.width) + .frame(width: w, height: w * heightRatio(preview.size)) + .clipped() .onChange(of: m.stopPreviousRecPlay) { playingUrl in if playingUrl != url { player.pause() @@ -313,8 +316,9 @@ struct CIVideoView: View { return ZStack(alignment: .topTrailing) { Image(uiImage: img) .resizable() - .scaledToFit() - .frame(width: w) + .scaledToFill() + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() .modifier(PrivacyBlur(blurred: $blurred)) if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { fileStatusIcon() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 47aee2a586..820074542f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 22.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIVoiceView struct CIVoiceView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index ed2340b6c4..fb5d36ab12 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#DeletedItemView struct DeletedItemView: View { @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 250d9d5636..04f36c97a4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#EmojiItemView struct EmojiItemView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 0b6f249b9c..123f7289bb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -5,12 +5,14 @@ // Created by JRoberts on 22.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#FramedCIVoiceView struct FramedCIVoiceView: View { @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index c9c9952688..d09289c1d5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#FramedItemView struct FramedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @@ -165,8 +167,18 @@ struct FramedItemView: View { case let .report(text, reason): ciMsgContentView(chatItem, txtPrefix: reason.attrString) case let .link(_, preview): - CILinkView(linkPreview: preview) + CILinkView(linkPreview: preview, maxWidth: maxWidth) ciMsgContentView(chatItem) + case let .chat(text, chatLink, ownerSig): + let hasText = text != chatLink.connLinkStr + CIChatLinkHeader(chatItem: chatItem, chatLink: chatLink, ownerSig: ownerSig, hasText: hasText) + .overlay(DetermineWidth()) + .simultaneousGesture(TapGesture().onEnded { + planAndConnect(chatLink.connLinkStr, linkOwnerSig: ownerSig, theme: theme, dismiss: false) + }) + if hasText { + ciMsgContentView(chatItem, stripLink: chatLink.connLinkStr) + } case let .unknown(_, text: text): if chatItem.file == nil { ciMsgContentView(chatItem) @@ -242,6 +254,11 @@ struct FramedItemView: View { ciQuotedMsgView(qi) .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading) ciQuoteIconView("mic.fill") + case let .chat(text, chatLink, _): + let prefix = NSAttributedString(string: chatLink.displayName + (text != chatLink.connLinkStr ? " - " : "")) + ciQuotedMsgView(qi, stripLink: chatLink.connLinkStr, prefix: prefix) + .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading) + ciQuoteIconView(chatLink.smallIconName) default: ciQuotedMsgView(qi) } @@ -258,7 +275,7 @@ struct FramedItemView: View { } } - private func ciQuotedMsgView(_ qi: CIQuote) -> some View { + private func ciQuotedMsgView(_ qi: CIQuote, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { Group { if let sender = qi.getSender(membership()) { VStack(alignment: .leading, spacing: 2) { @@ -266,10 +283,10 @@ struct FramedItemView: View { .font(.caption) .foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary) .lineLimit(1) - ciQuotedMsgTextView(qi, lines: 2) + ciQuotedMsgTextView(qi, lines: 2, stripLink: stripLink, prefix: prefix) } } else { - ciQuotedMsgTextView(qi, lines: 3) + ciQuotedMsgTextView(qi, lines: 3, stripLink: stripLink, prefix: prefix) } } .fixedSize(horizontal: false, vertical: true) @@ -278,8 +295,8 @@ struct FramedItemView: View { } @inline(__always) - private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline) + private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) .lineLimit(lines) .padding(.bottom, 6) } @@ -301,7 +318,7 @@ struct FramedItemView: View { } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil, stripLink: String? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText @@ -314,7 +331,8 @@ struct FramedItemView: View { mentions: ci.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, - prefix: txtPrefix + prefix: txtPrefix, + stripLink: stripLink ) .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index f243a83142..e14683684d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 08/10/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat import SwiftyGif import AVKit +// Spec: spec/client/chat-view.md#FullScreenMediaView struct FullScreenMediaView: View { @EnvironmentObject var m: ChatModel @State var chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index 47a30f6cf3..d831333c20 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 28/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#IntegrityErrorItemView struct IntegrityErrorItemView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme @@ -75,6 +77,25 @@ struct CIMsgError: View { } } +struct RcvMsgErrorItemView: View { + @ObservedObject var chat: Chat + var rcvMsgError: RcvMsgError + var chatItem: ChatItem + + var body: some View { + CIMsgError(chat: chat, chatItem: chatItem) { + let message: LocalizedStringKey = switch rcvMsgError { + case let .dropped(attempts): "The app removed this message after \(attempts) attempts to receive it." + case let .parseError(parseError): "\(parseError)" + } + AlertManager.shared.showAlertMsg( + title: "Message error", + message: message + ) + } + } +} + struct IntegrityErrorItemView_Previews: PreviewProvider { static var previews: some View { IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c6a5d0353c..953f4e8c82 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 30.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#MarkedDeletedItemView struct MarkedDeletedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 2a1b526893..2f4338c0af 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 13/03/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -23,6 +24,7 @@ private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont. return res } +// Spec: spec/client/chat-view.md#MsgContentView struct MsgContentView: View { @ObservedObject var chat: Chat @Environment(\.showTimestamp) var showTimestamp: Bool @@ -37,6 +39,7 @@ struct MsgContentView: View { var userMemberId: String? = nil var rightToLeft = false var prefix: NSAttributedString? = nil + var stripLink: String? = nil @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? @@ -103,7 +106,8 @@ struct MsgContentView: View { showSecrets: showSecrets, commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady, backgroundColor: containerBackground, - prefix: prefix + prefix: prefix, + stripLink: stripLink ) let s = r.string let t: Text @@ -287,8 +291,11 @@ func messageText( showSecrets: Set?, commands: Bool = false, backgroundColor: UIColor, - prefix: NSAttributedString? = nil + prefix: NSAttributedString? = nil, + stripLink: String? = nil ) -> MsgTextResult { + let text = if let stripLink { stripTextLink(text, stripLink) } else { text } + let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } let res = NSMutableAttributedString() let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) let font = UIFont.preferredFont(forTextStyle: textStyle) @@ -320,6 +327,7 @@ func messageText( var bold: UIFont? var italic: UIFont? var snippet: UIFont? + var small: UIFont? var mention: UIFont? var secretIdx: Int = 0 for ft in fts { @@ -351,6 +359,10 @@ func messageText( attrs[.backgroundColor] = secretColor } hasSecrets = true + case .small: + small = small ?? UIFont.preferredFont(forTextStyle: .footnote) + attrs[.font] = small + attrs[.foregroundColor] = UIColor.secondaryLabel case let .colored(color): if let c = color.uiColor { attrs[.foregroundColor] = UIColor(c) @@ -458,6 +470,24 @@ func viaHost(_ smpHosts: [String]) -> String { "(via \(smpHosts.first ?? "?"))" } +func stripTextLink(_ text: String, _ link: String) -> String { + text == link + ? "" + : text.hasSuffix("\n" + link) + ? String(text.dropLast(link.count + 1)) + : text +} + +func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? { + guard var ft, ft.last?.text == link else { return ft } + ft.removeLast() + if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { + ft[i].text = String(ft[i].text.dropLast()) + if ft[i].text.isEmpty { ft.removeLast() } + } + return ft.isEmpty ? nil : ft +} + struct MsgContentView_Previews: PreviewProvider { static var previews: some View { let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index dfc620c402..92bab973c9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -14,13 +14,17 @@ struct ChatItemForwardingView: View { @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss - var chatItems: [ChatItem] - var fromChatInfo: ChatInfo - @Binding var composeState: ComposeState + var title: LocalizedStringKey = "Forward" + var chatItems: [ChatItem] = [] + var fromChatInfo: ChatInfo? = nil + var composeState: Binding? = nil + var isProhibited: ((Chat) -> Bool)? = nil + var onSelectChat: ((Chat) -> Void)? = nil + var includeLocal: Bool = true @State private var searchText: String = "" @State private var alert: SomeAlert? - private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) + private var chatsToForwardTo: [Chat] { filterChatsToForwardTo(chats: ChatModel.shared.chats, includeLocal: includeLocal) } var body: some View { NavigationView { @@ -32,7 +36,7 @@ struct ChatItemForwardingView: View { } } ToolbarItem(placement: .principal) { - Text("Forward") + Text(title) .bold() } } @@ -71,7 +75,7 @@ struct ChatItemForwardingView: View { } @ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View { - let prohibited = chatItems.map { ci in + let prohibited = isProhibited?(chat) ?? chatItems.map { ci in chat.prohibitedByPref( hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text), isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false, @@ -88,16 +92,19 @@ struct ChatItemForwardingView: View { ), id: "forward prohibited by preferences" ) - } else { + } else if let onSelectChat { + dismiss() + onSelectChat(chat) + } else if let fromChatInfo, let composeState { dismiss() if chat.id == fromChatInfo.id { - composeState = ComposeState( - message: composeState.message, - preview: composeState.linkPreview != nil ? composeState.preview : .noPreview, + composeState.wrappedValue = ComposeState( + message: composeState.wrappedValue.message, + preview: composeState.wrappedValue.linkPreview != nil ? composeState.wrappedValue.preview : .noPreview, contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo) ) } else { - composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo) + composeState.wrappedValue = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo) ItemsModel.shared.loadOpenChat(chat.id) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 87c6ba92f8..3858d15252 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -9,6 +9,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#ChatItemInfoView struct ChatItemInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5f48c18881..1839651daa 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -38,6 +38,7 @@ extension EnvironmentValues { } } +// Spec: spec/client/chat-view.md#ChatItemView struct ChatItemView: View { @ObservedObject var chat: Chat @ObservedObject var im: ItemsModel @@ -144,6 +145,7 @@ struct ChatItemContentView: View { } else { ZStack {} } + case let .rcvMsgError(rcvMsgError): RcvMsgErrorItemView(chat: chat, rcvMsgError: rcvMsgError, chatItem: chatItem) case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) @@ -170,8 +172,8 @@ struct ChatItemContentView: View { case .rcvBlocked: deletedItemView() case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) - case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) - case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) + case let .sndGroupE2EEInfo(e2eeInfo): CIEventView(eventText: groupE2EEInfoText(e2eeInfo)) + case let .rcvGroupE2EEInfo(e2eeInfo): CIEventView(eventText: groupE2EEInfoText(e2eeInfo)) case .chatBanner: EmptyView() case let .invalidJSON(json): CIInvalidJSONView(json: json) } @@ -194,7 +196,7 @@ struct ChatItemContentView: View { } private func pendingReviewEventItemText() -> Text { - Text(chatItem.content.text) + Text(chatItem.content.text(isChannel: chat.chatInfo.isChannel)) .font(.caption) .foregroundColor(theme.colors.secondary) .fontWeight(.bold) @@ -208,9 +210,9 @@ struct ChatItemContentView: View { .font(.caption) .foregroundColor(secondaryColor) .fontWeight(.light) - + chatEventText(chatItem, secondaryColor) + + chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel) } else { - return chatEventText(chatItem, secondaryColor) + return chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel) } } @@ -233,7 +235,7 @@ struct ChatItemContentView: View { return if count <= 1 { nil } else if ns.count == 0 { - Text("\(count) group events") + chat.chatInfo.isChannel ? Text("\(count) channel events") : Text("\(count) group events") } else if count > ns.count { Text(members) + textSpace + Text("and \(count - ns.count) other events") } else { @@ -255,6 +257,12 @@ struct ChatItemContentView: View { e2eeInfoText("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.") } + private func groupE2EEInfoText(_ info: E2EEInfo) -> Text { + info.public == true + ? e2eeInfoText("Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages.") + : e2eeInfoNoPQText() + } + private func e2eeInfoText(_ s: LocalizedStringKey) -> Text { Text(s) .font(.caption) @@ -274,8 +282,8 @@ func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor chatEventText(Text(eventText) + textSpace + ts, secondaryColor) } -func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { - chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor) +func chatEventText(_ ci: ChatItem, _ secondaryColor: Color, isChannel: Bool = false) -> Text { + chatEventText("\(ci.content.text(isChannel: isChannel))", ci.timestampText, secondaryColor) } struct ChatItemView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 93ecf870eb..9987fb4697 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -15,6 +15,7 @@ func apiLoadMessages( _ chatId: ChatId, _ im: ItemsModel, _ pagination: ChatPagination, + _ contentTag: MsgContentTag? = nil, _ search: String = "", _ openAroundItemId: ChatItem.ID? = nil, _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } @@ -22,7 +23,7 @@ func apiLoadMessages( let chat: Chat let navInfo: NavigationInfo do { - (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search) + (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: contentTag ?? im.contentTag, pagination: pagination, search: search) } catch let error { logger.error("apiLoadMessages error: \(responseError(error))") return diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index 5f2102b8bc..0b074c6370 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -267,6 +267,7 @@ struct ListItem: Hashable { case .directRcv: 1 case .groupSnd: 2 case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash + case .channelRcv: 3 case .localSnd: 4 case .localRcv: 5 } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index fa53045391..66148034df 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -13,6 +14,7 @@ import Combine private let memberImageSize: CGFloat = 34 +// Spec: spec/client/chat-view.md#ChatView struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared @@ -44,6 +46,8 @@ struct ChatView: View { @State private var showSearch = false @State private var searchText: String = "" @FocusState private var searchFocussed + @State private var contentFilter: ContentFilter? = nil + @State private var availableContent: [ContentFilter] = [.images, .files, .links] // opening GroupMemberInfoView on member icon @State private var selectedMember: GMember? = nil // opening GroupLinkView on link button (incognito) @@ -61,13 +65,13 @@ struct ChatView: View { @State private var showUserSupportChatSheet = false @State private var showCommandsMenu = false @State private var supportChatMemberInfoLinkActive = false - @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + // Spec: spec/client/chat-view.md#body var body: some View { if #available(iOS 16.0, *) { viewBody @@ -130,12 +134,6 @@ struct ChatView: View { .padding(.top) } if selectedChatItems == nil { - let reason = chat.chatInfo.userCantSendReason - let composeEnabled = ( - chat.chatInfo.sendMsgEnabled || - (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || // allow to join prepared group without message - (chat.chatInfo.contact?.nextAcceptContactRequest ?? false) // allow to accept or reject contact request - ) ComposeView( chat: chat, im: im, @@ -143,18 +141,8 @@ struct ChatView: View { showCommandsMenu: $showCommandsMenu, keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, - selectedRange: $selectedRange, - disabledText: reason?.composeLabel + selectedRange: $selectedRange ) - .disabled(!composeEnabled) - .if(!composeEnabled) { v in - v.disabled(true).onTapGesture { - AlertManager.shared.showAlertMsg( - title: "You can't send messages!", - message: reason?.alertMessage - ) - } - } } else { SelectedItemsBottomToolbar( im: im, @@ -264,6 +252,20 @@ struct ChatView: View { AddGroupMembersView(chat: chat, groupInfo: groupInfo) } } + .appSheet(isPresented: $showGroupLinkSheet) { + if case let .group(groupInfo, _) = cInfo { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false, + isChannel: groupInfo.useRelays, + groupInfo: groupInfo, + composeState: $composeState + ) + } + } .sheet(isPresented: Binding( get: { !forwardedChatItems.isEmpty }, set: { isPresented in @@ -346,6 +348,13 @@ struct ChatView: View { if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { scrollView.scrollToItem(index) + } else if let viewedIdx = mergedItems.boxedValue.items.firstIndex(where: { !$0.hasUnread() }) { + // scroll to first unread after last viewed item (items reversed: 0 = newest) + if viewedIdx > 0 { + scrollView.scrollToItem(viewedIdx - 1) + } else { + scrollView.scrollToBottom() + } } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { scrollView.scrollToItem(unreadIndex) } else { @@ -393,11 +402,14 @@ struct ChatView: View { if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { + chatModel.chatAgentConnId = nil + chatModel.chatSubStatus = nil im.reversedChatItems = [] im.chatState.clear() chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false + ChannelRelaysModel.shared.reset() } } } @@ -501,6 +513,7 @@ struct ChatView: View { } ), scrollToItemId: $scrollToItemId, + composeState: $composeState, onSearch: { focusSearch() }, localAlias: groupInfo.localAlias ) @@ -526,49 +539,66 @@ struct ChatView: View { 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) - } - } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { + let canStartCall = callsPrefEnabled && contact.ready && contact.active && chatModel.activeCall == nil + if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) + } else if canStartCall { + // Call button always in toolbar; tap opens Audio/Video submenu + Menu { + Button { + CallController.shared.startCall(contact, .audio) + } label: { + Label("Audio call", systemImage: "phone") + } Button { CallController.shared.startCall(contact, .video) } label: { Label("Video call", systemImage: "video") } - .disabled(!contact.ready || !contact.active) + } label: { + Image(systemName: "phone") } + } else if chatModel.activeCall == nil { + // Calls unavailable: show filter button in place of call button + contentFilterMenu(withLabel: false) + } + Menu { searchButton() ToggleNtfsButton(chat: chat) .disabled(!contact.ready || !contact.active) + // Filter options in menu when call button is shown (or during any active call) + if !availableContent.isEmpty && (canStartCall || chatModel.activeCall != nil) { + Divider() + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } } 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() - } - } + contentFilterMenu(withLabel: false) Menu { + if groupInfo.canAddMembers { + if chat.chatInfo.incognito || groupInfo.useRelays { + groupLinkButton() + } else { + addMembersButton() + } + } searchButton() ToggleNtfsButton(chat: chat) } label: { @@ -576,7 +606,29 @@ struct ChatView: View { } } case .local: - searchButton() + HStack { + if !availableContent.isEmpty { + Menu { + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } label: { + Image(systemName: "ellipsis") + } + } + searchButton() + } default: EmptyView() } @@ -654,23 +706,56 @@ struct ChatView: View { .frame(width: 220) } + // Spec: spec/client/chat-view.md#initChatView private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. - if case .active = scenePhase, - case let .direct(contact) = cInfo { - Task { - do { - let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId) - await MainActor.run { - if let s = stats { - chatModel.updateContactConnectionStats(contact, s) + if case .active = scenePhase { + if case let .direct(contact) = cInfo { + Task { + do { + let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId) + await MainActor.run { + if let s = stats { + chatModel.updateContactConnectionStats(contact, s) + if let conn = contact.activeConn { + chatModel.chatAgentConnId = conn.agentConnId + chatModel.chatSubStatus = s.subStatus + } + } } + } catch let error { + logger.error("apiContactInfo error: \(responseError(error))") + } + } + } else { + Task { + await MainActor.run { + chatModel.chatAgentConnId = nil + chatModel.chatSubStatus = nil } - } catch let error { - logger.error("apiContactInfo error: \(responseError(error))") } } + if case let .group(groupInfo, _) = cInfo, groupInfo.useRelays { + Task { await chatModel.loadGroupMembers(groupInfo) } + if groupInfo.membership.memberRole == .owner { + Task { + let relays = await apiGetGroupRelays(groupInfo.groupId) + await MainActor.run { + ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays) + } + } + } else if groupInfo.membership.memberCurrent { + Task { + if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) { + await MainActor.run { + chatModel.updateGroup(gInfo) + } + } + } + } + } + updateAvailableContent() } if chatModel.draftChatId == cInfo.id && !composeState.forwarding, let draft = chatModel.draft { @@ -684,6 +769,23 @@ struct ChatView: View { floatingButtonModel.updateOnListChange(scrollView.listState) } + private func updateAvailableContent() { + Task { + let content: [ContentFilter] + do { + let contentTags = Set(try await apiGetChatContentTypes(chatId: chat.chatInfo.id)).union(ContentFilter.alwaysShow) + content = ContentFilter.allCases.filter { contentTags.contains($0.contentTag) } + } catch let error { + logger.error("apiGetChatContentTypes error: \(responseError(error))") + content = ContentFilter.allCases + } + await MainActor.run { + availableContent = content + } + } + } + + // Spec: spec/client/chat-view.md#scrollToItem private func scrollToItem(_ itemId: ChatItem.ID) { Task { do { @@ -717,11 +819,16 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#searchToolbar private func searchToolbar() -> some View { - HStack(spacing: 12) { + let placeholder: LocalizedStringKey = contentFilter?.searchPlaceholder ?? "Search" + return HStack(spacing: 12) { HStack(spacing: 4) { Image(systemName: "magnifyingglass") - TextField("Search", text: $searchText) + if let contentFilter { + Image(systemName: contentFilter.icon) + } + TextField(placeholder, text: $searchText) .focused($searchFocussed) .foregroundColor(theme.colors.onBackground) .frame(maxWidth: .infinity) @@ -750,6 +857,7 @@ struct ChatView: View { ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } + // Spec: spec/client/chat-view.md#filtered private func filtered(_ reversedChatItems: Array) -> Array { reversedChatItems .enumerated() @@ -763,6 +871,7 @@ struct ChatView: View { .map { $0.element } } + // Spec: spec/client/chat-view.md#chatItemsList private func chatItemsList() -> some View { let cInfo = chat.chatInfo return GeometryReader { g in @@ -810,6 +919,7 @@ struct ChatView: View { selectedChatItems: $selectedChatItems, forwardedChatItems: $forwardedChatItems, searchText: $searchText, + contentFilter: $contentFilter, closeKeyboardAndRun: closeKeyboardAndRun ) } @@ -974,12 +1084,12 @@ struct ChatView: View { switch groupInfo.businessChat?.chatType { case .none: if groupInfo.nextConnectPrepared { - "Tap Join group" + groupInfo.useRelays ? "Tap Join channel" : "Tap Join group" } else { switch (groupInfo.membership.memberStatus) { - case .memInvited: "Join group" - case .memCreator: "Your group" - default: "Group" + case .memInvited: groupInfo.useRelays ? "Join channel" : "Join group" + case .memCreator: groupInfo.useRelays ? "Your channel" : "Your group" + default: groupInfo.useRelays ? "Channel" : "Group" } } case .business: @@ -1007,10 +1117,14 @@ struct ChatView: View { nil } case let .group(groupInfo, _): - switch (groupInfo.membership.memberStatus) { - case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil - case .memAccepted: "connecting…" - default: nil + if groupInfo.useRelays { + nil + } else { + switch (groupInfo.membership.memberStatus) { + case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil + case .memAccepted: "connecting…" + default: nil + } } default: nil } @@ -1036,9 +1150,10 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#searchTextChanged private func searchTextChanged(_ s: String) { Task { - await loadChat(chat: chat, im: im, search: s) + await loadChat(chat: chat, im: im, contentTag: contentFilter?.contentTag, search: s) mergedItems.boxedValue = MergedItems.create(im, revealedItems) await MainActor.run { scrollView.updateItems(mergedItems.boxedValue.items) @@ -1213,6 +1328,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#callButton private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { Button { CallController.shared.startCall(contact, media) @@ -1241,16 +1357,52 @@ struct ChatView: View { } } + private func contentFilterMenu(withLabel: Bool) -> some View { + Menu { + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } label: { + let icon = contentFilter == nil ? "photo.on.rectangle" : "photo.on.rectangle.fill" + if withLabel { + Label("Filter", systemImage: icon) + } else { + Image(systemName: icon) + } + } + } + private func focusSearch() { showSearch = true searchFocussed = true searchText = "" } + private func setContentFilter(_ type: ContentFilter) { + if (contentFilter == type) { return } + contentFilter = type + showSearch = true + searchText = "" + searchTextChanged("") + } + private func closeSearch() { showSearch = false searchText = "" searchFocussed = false + contentFilter = nil + updateAvailableContent() } private func closeKeyboardAndRun(_ action: @escaping () -> Void) { @@ -1271,7 +1423,7 @@ struct ChatView: View { Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { - Image(systemName: "person.crop.circle.badge.plus") + Label("Invite member", systemImage: "person.crop.circle.badge.plus") } } @@ -1291,7 +1443,11 @@ struct ChatView: View { } } } label: { - Image(systemName: "link.badge.plus") + if case let .group(gInfo, _) = chat.chatInfo, gInfo.useRelays { + Label("Channel link", systemImage: "link") + } else { + Label("Group link", systemImage: "link.badge.plus") + } } } @@ -1314,6 +1470,7 @@ struct ChatView: View { )) } + // Spec: spec/client/chat-view.md#deletedSelectedMessages private func deletedSelectedMessages() async { await MainActor.run { withAnimation { @@ -1322,6 +1479,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#forwardSelectedMessages private func forwardSelectedMessages() { Task { do { @@ -1432,6 +1590,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#loadChatItems private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { if loadingMoreItems { return false } await MainActor.run { @@ -1459,6 +1618,7 @@ struct ChatView: View { chat.chatInfo.id, im, pagination, + contentFilter?.contentTag, searchText, nil, { visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) } @@ -1471,6 +1631,7 @@ struct ChatView: View { VoiceItemState.chatView = [:] } + // Spec: spec/client/chat-view.md#onChatItemsUpdated func onChatItemsUpdated() { if !mergedItems.boxedValue.isActualState() { //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)") @@ -1498,6 +1659,7 @@ struct ChatView: View { ) } + // Spec: spec/client/chat-view.md#ChatItemWithMenu private struct ChatItemWithMenu: View { @ObservedObject var im: ItemsModel @EnvironmentObject var m: ChatModel @@ -1532,12 +1694,14 @@ struct ChatView: View { @Binding var forwardedChatItems: [ChatItem] @Binding var searchText: String + @Binding var contentFilter: ContentFilter? var closeKeyboardAndRun: (@escaping () -> Void) -> Void @State private var allowMenu: Bool = true @State private var markedRead = false @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil + @State private var swipeOffset: CGFloat = 0 var revealed: Bool { revealedItems.contains(chatItem.id) } @@ -1554,6 +1718,8 @@ struct ChatView: View { let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir { groupMember.groupMemberId == prevGroupMember.groupMemberId + } else if case .channelRcv = chatItem.chatDir, case .channelRcv = prevItem.chatDir { + true } else { chatItem.chatDir.sent == prevItem.chatDir.sent } @@ -1569,16 +1735,21 @@ struct ChatView: View { func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool { let oldIsGroupRcv = switch older?.chatDir { case .groupRcv: true + case .channelRcv: true default: false } let sameMember = switch (older?.chatDir, current.chatDir) { case (.groupRcv(let oldMember), .groupRcv(let member)): oldMember.memberId == member.memberId + case (.channelRcv, .channelRcv): + true default: false } if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) { return true + } else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) { + return true } else { return false } @@ -1689,7 +1860,7 @@ struct ChatView: View { private var searchIsNotBlank: Bool { get { - searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + (searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) || contentFilter != nil } } @@ -1744,7 +1915,74 @@ struct ChatView: View { _ itemSeparation: ItemSeparation ) -> some View { let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2 - if case let .groupRcv(member) = ci.chatDir, + if case .channelRcv = ci.chatDir, + case let .group(groupInfo, _) = chat.chatInfo { + if showAvatar { + VStack(alignment: .leading, spacing: 4) { + if ci.content.showMemberName { + Group { + Group { + if #available(iOS 16.0, *) { + MemberLayout(spacing: 16, msgWidth: msgWidth) { + Text(groupInfo.chatViewName) + .lineLimit(1) + Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages")) + .fontWeight(.semibold) + .lineLimit(1) + .padding(.trailing, 8) + } + } else { + HStack(spacing: 16) { + Text(groupInfo.chatViewName) + .lineLimit(1) + Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages")) + .fontWeight(.semibold) + .lineLimit(1) + .layoutPriority(1) + } + } + } + .frame( + maxWidth: maxWidth, + alignment: chatItem.chatDir.sent ? .trailing : .leading + ) + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0)) + .padding(.top, 3) + } + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.trailing, 12) + } + HStack(alignment: .top, spacing: 10) { + ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + .simultaneousGesture(TapGesture().onEnded { + showChatInfoSheet = true + }) + chatItemWithMenu(ci, range, maxWidth, itemSeparation) + .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } + } + } + } + .padding(.bottom, bottomPadding) + .padding(.trailing) + .padding(.leading, 12) + } else { + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading, 12) + } + chatItemWithMenu(ci, range, maxWidth, itemSeparation) + .padding(.trailing) + .padding(.leading, 10 + memberImageSize + 12) + } + .padding(.bottom, bottomPadding) + } + } else if case let .groupRcv(member) = ci.chatDir, case let .group(groupInfo, _) = chat.chatInfo { if showAvatar { VStack(alignment: .leading, spacing: 4) { @@ -1872,37 +2110,79 @@ struct ChatView: View { func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - return VStack(alignment: alignment.horizontal, spacing: 3) { - HStack { - if ci.chatDir.sent { - goToItemButton(true) + let live = composeState.liveMessage != nil + let canReply = ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote && selectedChatItems == nil && chat.chatInfo.sendMsgEnabled + return ZStack(alignment: .trailing) { + Image(systemName: "arrowshape.turn.up.left") + .font(.system(size: 18)) + .foregroundColor(.secondary) + .opacity(min(1, -swipeOffset / 30)) + .offset(x: swipeOffset + 40) + VStack(alignment: alignment.horizontal, spacing: 3) { + HStack { + if ci.chatDir.sent { + goToItemButton(true) + } + ChatItemView( + chat: chat, + im: im, + chatItem: ci, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, + maxWidth: maxWidth, + allowMenu: $allowMenu + ) + .environment(\.revealed, revealed) + .environment(\.showTimestamp, itemSeparation.timestamp) + .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) + .contextMenu { menu(ci, range, live: live) } + .accessibilityLabel("") + if !ci.chatDir.sent { + goToItemButton(false) + } } - ChatItemView( - chat: chat, - im: im, - chatItem: ci, - scrollToItem: scrollToItem, - scrollToItemId: $scrollToItemId, - maxWidth: maxWidth, - allowMenu: $allowMenu - ) - .environment(\.revealed, revealed) - .environment(\.showTimestamp, itemSeparation.timestamp) - .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) - .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } - .accessibilityLabel("") - if !ci.chatDir.sent { - goToItemButton(false) + if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { + chatItemReactions(ci) + .padding(.bottom, 4) } } - if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { - chatItemReactions(ci) - .padding(.bottom, 4) - } + .offset(x: swipeOffset) + .contentShape(Rectangle()) + .simultaneousGesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + guard canReply else { return } + let x = value.translation.width + if x < 0 { + swipeOffset = max(x * 0.63, -56) + } + } + .onEnded { _ in + if swipeOffset < -42 { + withAnimation { + if composeState.editing { + composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) + } else { + composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + } + } + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + withAnimation(.spring(response: 0.25)) { + swipeOffset = 0 + } + } + ) } .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { - Button("Delete for me", role: .destructive) { - deleteMessage(.cidmInternal, moderate: false) + if publicGroupEditor(chat) { + Button("Delete from history", role: .destructive) { + deleteMessage(.cidmHistory, moderate: false) + } + } else { + Button("Delete for me", role: .destructive) { + deleteMessage(.cidmInternal, moderate: false) + } } if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport { Button(broadcastDeleteButtonText(chat), role: .destructive) { @@ -1911,8 +2191,14 @@ struct ChatView: View { } } .confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) { - Button("Delete for me", role: .destructive) { - deleteMessages(chat, deletingItems, moderate: false) + if publicGroupEditor(chat) { + Button("Delete from history", role: .destructive) { + deleteMessages(chat, deletingItems, .cidmHistory, moderate: false) + } + } else { + Button("Delete for me", role: .destructive) { + deleteMessages(chat, deletingItems, moderate: false) + } } } .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) { @@ -1944,6 +2230,7 @@ struct ChatView: View { switch (prevItem?.chatDir) { case .groupSnd: return true case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId + case .channelRcv: return true default: return false } } @@ -2004,7 +2291,7 @@ struct ChatView: View { availableReactions.count > 0 { reactionsGroup } - if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote { + if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote && chat.chatInfo.sendMsgEnabled { replyButton } let fileSource = getLoadedFileSource(ci.file) @@ -2542,6 +2829,9 @@ struct ChatView: View { } } catch { logger.error("ChatView.deleteMessage error: \(error)") + await MainActor.run { + showAlert(NSLocalizedString("Error deleting message", comment: "alert title"), message: responseError(error)) + } } } } @@ -2609,6 +2899,7 @@ struct ChatView: View { } } +// Spec: spec/client/chat-view.md#FloatingButtonModel class FloatingButtonModel: ObservableObject { @ObservedObject var im: ItemsModel @@ -2687,10 +2978,19 @@ class FloatingButtonModel: ObservableObject { } +private func publicGroupEditor(_ chat: Chat) -> Bool { + if case let .group(groupInfo, _) = chat.chatInfo { + groupInfo.useRelays && groupInfo.membership.memberRole >= .moderator + } else { + false + } +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } +// Spec: spec/client/chat-view.md#deleteMessages private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) { let itemIds = deletingItems if itemIds.count > 0 { @@ -2733,6 +3033,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe await onSuccess() } catch { logger.error("ChatView.deleteMessages error: \(error.localizedDescription)") + await MainActor.run { + showAlert(NSLocalizedString("Error deleting message", comment: "alert title"), message: responseError(error)) + } } } } @@ -2794,6 +3097,7 @@ private func buildTheme() -> AppTheme { } } +// Spec: spec/client/chat-view.md#ReactionContextMenu struct ReactionContextMenu: View { @EnvironmentObject var m: ChatModel let groupInfo: GroupInfo @@ -2943,6 +3247,67 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { } } +// Spec: spec/client/chat-view.md#ContentFilter +enum ContentFilter: CaseIterable { + case images + case videos + case voice + case files + case links + + static let alwaysShow: Set = [.image, .link] + + var contentTag: MsgContentTag { + switch self { + case .images: .image + case .videos: .video + case .voice: .voice + case .files: .file + case .links: .link + } + } + + var label: LocalizedStringKey { + switch self { + case .images: "Images" + case .videos: "Videos" + case .voice: "Voice messages" + case .files: "Files" + case .links: "Links" + } + } + + var searchPlaceholder: LocalizedStringKey { + switch self { + case .images: "Search images" + case .videos: "Search videos" + case .voice: "Search voice messages" + case .files: "Search files" + case .links: "Search links" + } + } + + var icon: String { + switch self { + case .images: "photo" + case .videos: "video" + case .voice: "mic" + case .files: "doc" + case .links: "link" + } + } + + var iconFilled: String { + switch self { + case .images: "photo.fill" + case .videos: "video.fill" + case .voice: "mic.fill" + case .files: "doc.fill" + case .links: "link.circle.fill" + } + } +} + struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift new file mode 100644 index 0000000000..650ea8a87f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import SimpleXChat + +struct ComposeChatLinkView: View { + @EnvironmentObject var theme: AppTheme + var chatLink: MsgChatLink + var cancelPreview: () -> Void + let cancelEnabled: Bool + + var body: some View { + HStack(alignment: .center, spacing: 8) { + ProfileImage( + imageStr: chatLink.image, + iconName: chatLink.iconName, + size: 44 + ) + .padding(.leading, 12) + VStack(alignment: .leading, spacing: 2) { + Text(chatLink.displayName) + .font(.headline) + .lineLimit(1) + if let descr = chatLink.shortDescription { + Text(descr) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 5) + Spacer() + if cancelEnabled { + Button { cancelPreview() } label: { + Image(systemName: "multiply") + } + } + } + .padding(.vertical, 8) + .padding(.trailing, 12) + .background(theme.appColors.sentMessage) + .frame(maxWidth: .infinity) + } +} diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 683dea0f56..5242923258 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1,3 +1,4 @@ +// Spec: spec/client/compose.md import SwiftUI import SimpleXChat @@ -6,14 +7,17 @@ import PhotosUI let MAX_NUMBER_OF_MENTIONS = 3 +// Spec: spec/client/compose.md#ComposePreview enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) + case chatLinkPreview(chatLink: MsgChatLink, ownerSig: LinkOwnerSig?) case mediaPreviews(mediaPreviews: [(String, UploadContent?)]) case voicePreview(recordingFileName: String, duration: Int) case filePreview(fileName: String, file: URL) } +// Spec: spec/client/compose.md#ComposeContextItem enum ComposeContextItem: Equatable { case noContextItem case quotedItem(chatItem: ChatItem) @@ -22,12 +26,14 @@ enum ComposeContextItem: Equatable { case reportedItem(chatItem: ChatItem, reason: ReportReason) } +// Spec: spec/client/compose.md#VoiceMessageRecordingState enum VoiceMessageRecordingState { case noRecording case recording case finished } +// Spec: spec/client/compose.md#LiveMessage struct LiveMessage { var chatItem: ChatItem var typedMsg: String @@ -36,6 +42,7 @@ struct LiveMessage { typealias MentionedMembers = [String: CIMention] +// Spec: spec/client/compose.md#ComposeState struct ComposeState { var message: String var parsedMessage: [FormattedText] @@ -67,7 +74,11 @@ struct ComposeState { } init(editingItem: ChatItem) { - let text = editingItem.content.text + let text = if case let .chat(t, chatLink, _) = editingItem.content.msgContent { + stripTextLink(t, chatLink.connLinkStr) + } else { + editingItem.content.text + } self.message = text self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text) self.preview = chatItemPreview(chatItem: editingItem) @@ -166,6 +177,7 @@ struct ComposeState { switch preview { case let .mediaPreviews(media): return !media.isEmpty case .voicePreview: return voiceMessageRecordingState == .finished + case .chatLinkPreview: return true case .filePreview: return true default: return !whitespaceOnly || forwarding || liveMessage != nil || submittingValidReport } @@ -177,6 +189,7 @@ struct ComposeState { var linkPreviewAllowed: Bool { switch preview { + case .chatLinkPreview: return false case .mediaPreviews: return false case .voicePreview: return false case .filePreview: return false @@ -232,6 +245,7 @@ struct ComposeState { switch preview { case .noPreview: false case .linkPreview: false + case .chatLinkPreview: false case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty case .voicePreview: false case .filePreview: true @@ -256,6 +270,7 @@ struct ComposeState { } } +// Spec: spec/client/compose.md#chatItemPreview func chatItemPreview(chatItem: ChatItem) -> ComposePreview { switch chatItem.content.msgContent { case .text: @@ -276,6 +291,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview { } } +// Spec: spec/client/compose.md#UploadContent enum UploadContent: Equatable { case simpleImage(image: UIImage) case animatedImage(image: UIImage) @@ -317,6 +333,7 @@ enum UploadContent: Equatable { } } +// Spec: spec/client/compose.md#ComposeView struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -327,7 +344,6 @@ struct ComposeView: View { @Binding var keyboardVisible: Bool @Binding var keyboardHiddenDate: Date @Binding var selectedRange: NSRange - var disabledText: LocalizedStringKey? = nil @State var linkUrl: String? = nil @State var hasSimplexLink: Bool = false @@ -354,13 +370,19 @@ struct ComposeView: View { @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false + @AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true + @AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, store: groupDefaults) private var linkPreviewsShowAlert = true @State private var updatingCompose = false + @State private var relayListExpanded = false + @StateObject private var channelRelaysModel = ChannelRelaysModel.shared + // Spec: spec/client/compose.md#body var body: some View { VStack(spacing: 0) { Divider() if chat.chatInfo.nextConnectPrepared, + !composeState.inProgress, let user = chatModel.currentUser { ContextProfilePickerView( chat: chat, @@ -369,85 +391,150 @@ struct ComposeView: View { Divider() } - if let groupInfo = chat.chatInfo.groupInfo, - case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, - case let .memberSupport(member) = groupScopeInfo, - let member = member, - member.memberPending, - composeState.contextItem == .noContextItem, - composeState.noPreview { - ContextPendingMemberActionsView( - groupInfo: groupInfo, - member: member - ) - Divider() - } - - if case let .reportedItem(_, reason) = composeState.contextItem { - reportReasonView(reason) - Divider() - } - // preference checks should match checks in forwarding list - let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) - let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) - let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) - let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited - if simplexLinkProhibited { - msgNotAllowedView("SimpleX links not allowed", icon: "link") - Divider() - } else if fileProhibited { - msgNotAllowedView("Files and media not allowed", icon: "doc") - Divider() - } else if voiceProhibited { - msgNotAllowedView("Voice messages not allowed", icon: "mic") - Divider() - } - contextItemView() - switch (composeState.editing, composeState.preview) { - case (true, .filePreview): EmptyView() - case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed - default: previewView() - } - - let contact = chat.chatInfo.contact - - if chat.chatInfo.groupInfo?.nextConnectPrepared == true { - if chat.chatInfo.groupInfo?.businessChat == nil { - connectButtonView("Join group", icon: "person.2.fill", connect: connectPreparedGroup) + let ownerState = ownerRelayState + if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays, + ![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) { + if gInfo.membership.memberRole == .owner { + if let s = ownerState, s.relays.isEmpty || s.activeCount < s.relays.count { + ownerChannelRelayBar(relays: s.relays, activeCount: s.activeCount, failedCount: s.failedCount, removedCount: s.removedCount) + } } else { - sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup) - } - } else if contact?.nextSendGrpInv == true { - contextSendMessageToConnect("Send direct message to connect") - Divider() - HStack (alignment: .center) { - attachmentAndCommandsButtons().disabled(true) - sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation) - } - .padding(.horizontal, 12) - } else if let contact, - contact.nextConnectPrepared == true, - let linkType = contact.preparedContact?.uiConnLinkType { - switch linkType { - case .inv: - connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact) - case .con: - if contact.isBot { - connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact) - } else { - sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest) + let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted() + let relayMembers = chatModel.groupMembers + .filter { $0.wrapped.memberRole == .relay && ![.memRemoved, .memGroupDeleted].contains($0.wrapped.memberStatus) } + .sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") } + let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress + let removedCount = relayMembers.filter { relayMemberRemoved($0.wrapped.memberStatus) }.count + let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count + let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count + let resolvedCount = connectedCount + removedCount + failedCount + let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count + if total == 0 || removedCount + failedCount > 0 || resolvedCount < total { + subscriberChannelRelayBar( + hostnames: hostnames, + relayMembers: relayMembers, + connectedCount: connectedCount, + removedCount: removedCount, + failedCount: failedCount, + total: total, + showProgress: showProgress + ) } } - } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId { - ContextContactRequestActionsView(contactRequestId: crId) - } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv { - ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv) - } else { - HStack (alignment: .center) { - attachmentAndCommandsButtons() - sendMessageView(disableSendButton) + } + + let userCantSendReason = chat.chatInfo.userCantSendReason(allRelaysBroken: ownerState?.noActiveRelays ?? false) + let composeEnabled = ( + userCantSendReason == nil || + (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || + (chat.chatInfo.contact?.nextAcceptContactRequest ?? false) + ) + Group { + + if let groupInfo = chat.chatInfo.groupInfo, + case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, + case let .memberSupport(member) = groupScopeInfo, + let member = member, + member.memberPending, + composeState.contextItem == .noContextItem, + composeState.noPreview { + ContextPendingMemberActionsView( + groupInfo: groupInfo, + member: member + ) + Divider() + } + + if case let .reportedItem(_, reason) = composeState.contextItem { + reportReasonView(reason) + Divider() + } + // preference checks should match checks in forwarding list + let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) + let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) + let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) + let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited + if simplexLinkProhibited { + msgNotAllowedView("SimpleX links not allowed", icon: "link") + Divider() + } else if fileProhibited { + msgNotAllowedView("Files and media not allowed", icon: "doc") + Divider() + } else if voiceProhibited { + msgNotAllowedView("Voice messages not allowed", icon: "mic") + Divider() + } + contextItemView() + switch (composeState.editing, composeState.preview) { + case (true, .filePreview): EmptyView() + case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed + default: previewView() + } + + let contact = chat.chatInfo.contact + + if chat.chatInfo.groupInfo?.nextConnectPrepared == true { + if chat.chatInfo.groupInfo?.businessChat == nil { + let isChannel = chat.chatInfo.groupInfo?.useRelays == true + connectButtonView( + isChannel ? "Join channel" : "Join group", + icon: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.fill", + connect: connectPreparedGroup + ) + } else { + sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup) + } + } else if contact?.nextSendGrpInv == true { + contextSendMessageToConnect("Send direct message to connect") + Divider() + HStack (alignment: .center) { + attachmentAndCommandsButtons().disabled(true) + sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation) + } + .padding(.horizontal, 12) + } else if let contact, + contact.nextConnectPrepared == true, + let linkType = contact.preparedContact?.uiConnLinkType { + switch linkType { + case .inv: + connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact) + case .con: + if contact.isBot { + connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact) + } else { + sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest) + } + } + } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId { + ContextContactRequestActionsView(contactRequestId: crId) + } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv { + ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv) + } else { + HStack (alignment: .center) { + attachmentAndCommandsButtons() + sendMessageView( + disableSendButton, + placeholder: chat.chatInfo.groupInfo.map { gi in + gi.useRelays && gi.membership.memberRole >= .owner && chat.chatInfo.groupChatScope() == nil + ? NSLocalizedString("Broadcast", comment: "compose placeholder for channel owner") + : nil + } ?? nil + ) + } + .padding(.horizontal, 12) + } + + } // Group + .disabled(!composeEnabled) + .if(!composeEnabled) { v in + v.onTapGesture { + if let reason = userCantSendReason { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: reason.alertMessage + ) + } } - .padding(.horizontal, 12) } } .background { @@ -476,7 +563,7 @@ struct ComposeView: View { } else { composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg)) } - if composeState.linkPreviewAllowed && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) { + if composeState.linkPreviewAllowed && useLinkPreviews { if !msg.isEmpty { showLinkPreview(parsedMsg) } else { @@ -643,18 +730,270 @@ struct ComposeView: View { } } + private var ownerRelayState: (relays: [GroupRelay], activeCount: Int, failedCount: Int, removedCount: Int, noActiveRelays: Bool)? { + guard let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays, + gInfo.membership.memberRole == .owner, + ![.memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) + else { return nil } + guard channelRelaysModel.groupId == gInfo.groupId else { return nil } + let relays = channelRelaysModel.groupRelays + guard !relays.isEmpty else { return ([], 0, 0, 0, true) } + let relayMembers = relays.map { relay in + (relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped) + } + let removedCount = relayMembers.filter { (_, m) in relayMemberRemoved(m?.memberStatus) }.count + let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .active && m?.activeConn?.connFailedErr == nil }.count + let failedCount = relayMembers.filter { (_, m) in !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != nil }.count + let noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.count + return (relays, activeCount, failedCount, removedCount, noActiveRelays) + } + + private var disabledText: LocalizedStringKey? { + chat.chatInfo.userCantSendReason(allRelaysBroken: ownerRelayState?.noActiveRelays ?? false)?.composeLabel + } + + @ViewBuilder private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int, removedCount: Int) -> some View { + let total = relays.count + let allBroken = activeCount == 0 && (failedCount + removedCount) == total + let sorted = relays.map { relay in + (relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped) + }.sorted { relayDisplayName($0.0) < relayDisplayName($1.0) } + VStack(spacing: 0) { + relayBarHeader { + if !allBroken && activeCount + failedCount + removedCount < total { + RelayProgressIndicator(active: activeCount, total: total) + } + if total == 0 { + Text("No relays") + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + } else if allBroken { + if removedCount == total { + Text("All relays removed") + } else if failedCount == total { + Text("All relays failed") + } else { + Text("No active relays") + } + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + } else if activeCount + failedCount + removedCount >= total { + if failedCount > 0 && removedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d relays not active", comment: "channel relay bar"), failedCount + removedCount)) + } else if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d relays failed", comment: "channel relay bar"), failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d relays removed", comment: "channel relay bar"), removedCount)) + } + } else if failedCount > 0 && removedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d errors", comment: "channel relay bar"), activeCount, total, failedCount + removedCount)) + } else if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar"), activeCount, total, failedCount)) + } else if removedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d removed", comment: "channel relay bar"), activeCount, total, removedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total)) + } + } + if relayListExpanded { + if allBroken { + Text("Add relays to restore message delivery.") + .frame(maxWidth: .infinity, alignment: .leading) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .padding(.leading, 12) + .padding(.trailing) + .padding(.bottom, 4) + } + ForEach(sorted, id: \.0.id) { (relay, m) in + if let err = m?.activeConn?.connFailedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + ownerRelayDetailRow(relay, connFailed: true, memberStatus: m?.memberStatus) + } + .buttonStyle(.plain) + } else { + ownerRelayDetailRow(relay, connFailed: false, memberStatus: m?.memberStatus) + } + } + } + } + .padding(.bottom, relayListExpanded ? 4 : 0) + .animation(nil, value: relayListExpanded) + } + + private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool, memberStatus: GroupMemberStatus?) -> some View { + relayBarDetailRow { + Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed, memberStatus: memberStatus) + } + } + + @ViewBuilder private func subscriberChannelRelayBar( + hostnames: [String], + relayMembers: [GMember], + connectedCount: Int, + removedCount: Int, + failedCount: Int, + total: Int, + showProgress: Bool + ) -> some View { + let errorCount = removedCount + failedCount + let allBroken = connectedCount == 0 && errorCount == total + VStack(spacing: 0) { + relayBarHeader { + if total == 0 { + Text("No relays") + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + } else if allBroken { + if removedCount == total { + Text("All relays removed") + } else if failedCount == total { + Text("All relays failed") + } else { + Text("No active relays") + } + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + } else { + if showProgress && connectedCount + errorCount < total { + RelayProgressIndicator(active: connectedCount, total: total) + } + if connectedCount + removedCount + failedCount >= total, removedCount + failedCount > 0 { + if failedCount > 0 && removedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d relays not active", comment: "channel subscriber relay bar"), failedCount + removedCount)) + } else if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d relays failed", comment: "channel subscriber relay bar"), failedCount)) + } else if removedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d relays removed", comment: "channel subscriber relay bar"), removedCount)) + } + } else if failedCount > 0 && removedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar"), connectedCount, total, errorCount)) + } else if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d failed", comment: "channel subscriber relay bar"), connectedCount, total, failedCount)) + } else if removedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d removed", comment: "channel subscriber relay bar"), connectedCount, total, removedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, total)) + } + } + } + if relayListExpanded { + if allBroken { + Text("Waiting for channel owner to add relays.") + .frame(maxWidth: .infinity, alignment: .leading) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .padding(.leading, 12) + .padding(.trailing) + .padding(.bottom, 4) + } + if relayMembers.isEmpty { + ForEach(hostnames, id: \.self) { relay in + relayBarDetailRow { + Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(relay))) + .foregroundColor(theme.colors.secondary) + Spacer() + } + } + } else { + ForEach(relayMembers) { member in + let m = member.wrapped + let host = m.relayLink.map { hostFromRelayLink($0) } + let failedErr = m.activeConn?.connFailedErr + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + subscriberRelayDetailRow(m, host: host, connFailed: true) + } + .buttonStyle(.plain) + } else { + subscriberRelayDetailRow(m, host: host, connFailed: false) + } + } + } + } + } + .padding(.bottom, relayListExpanded ? 4 : 0) + .animation(nil, value: relayListExpanded) + } + + private func subscriberRelayDetailRow(_ m: GroupMember, host: String?, connFailed: Bool) -> some View { + relayBarDetailRow { + Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName)) + .foregroundColor(theme.colors.secondary) + Spacer() + let status = relayConnStatus(m) + Circle() + .fill(status.color) + .frame(width: 8, height: 8) + Text(status.text) + .foregroundColor(theme.colors.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + } + } + } + + private func relayBarHeader(@ViewBuilder content: () -> Content) -> some View { + Button { + withAnimation(nil) { relayListExpanded.toggle() } + } label: { + HStack(spacing: 8) { + content() + Spacer() + Image(systemName: relayListExpanded ? "chevron.down" : "chevron.up") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(theme.colors.secondary) + .opacity(0.7) + } + .font(.callout) + .foregroundColor(theme.colors.secondary) + .padding(.top, 8) + .padding(.bottom, relayListExpanded ? 4 : 8) + .padding(.leading, 12) + .padding(.trailing) + } + } + + private func relayBarDetailRow(@ViewBuilder content: () -> Content) -> some View { + HStack { + content() + } + .font(.caption) + .padding(.leading, 12) + .padding(.trailing) + .padding(.vertical, 2) + } + + + private func relayMemberRemoved(_ status: GroupMemberStatus?) -> Bool { + status.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false + } + private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View { Button(action: connect) { ZStack(alignment: .trailing) { Label(label, systemImage: icon) .frame(maxWidth: .infinity) - if composeState.progressByTimeout { + if composeState.progressByTimeout && chat.chatInfo.groupInfo?.useRelays != true { ProgressView() .padding() } } } - .frame(height: 60) + .frame(height: 57) .disabled(composeState.inProgress) } @@ -679,10 +1018,11 @@ struct ComposeView: View { .padding(.horizontal, 12) } + // Spec: spec/client/compose.md#sendMessageView private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View { ZStack(alignment: .leading) { SendMessageView( - placeholder: placeholder, + placeholder: disabledText != nil ? nil : placeholder, composeState: $composeState, selectedRange: $selectedRange, sendMessage: { ttl in @@ -788,7 +1128,6 @@ struct ComposeView: View { await MainActor.run { self.chatModel.updateContact(contact) clearState() - NetworkModel.shared.setContactNetworkStatus(contact, .connected) } } else { AlertManager.shared.showAlertMsg(title: "Empty message!") @@ -827,7 +1166,6 @@ struct ComposeView: View { if let contact = await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { await MainActor.run { self.chatModel.updateContact(contact) - NetworkModel.shared.setContactNetworkStatus(contact, .connected) clearState() } } else { @@ -842,9 +1180,12 @@ struct ComposeView: View { await sending() let mc = connectCheckLinkPreview() let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault - if let groupInfo = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { + if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { await MainActor.run { self.chatModel.updateGroup(groupInfo) + self.chatModel.channelRelayHostnames.removeValue(forKey: groupInfo.groupId) + self.chatModel.groupMembers = relayResults.map { GMember($0.relayMember) } + self.chatModel.populateGroupMembersIndexes() clearState() } } else { @@ -880,6 +1221,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#addMediaContent private func addMediaContent(_ content: UploadContent) async { if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { var newMedia: [(String, UploadContent?)] = [] @@ -908,6 +1250,7 @@ struct ComposeView: View { getMaxFileSize(.xftp) } + // Spec: spec/client/compose.md#sendLiveMessage private func sendLiveMessage() async { let typedMsg = composeState.message let lm = composeState.liveMessage @@ -925,6 +1268,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#updateLiveMessage private func updateLiveMessage() async { let typedMsg = composeState.message if let liveMessage = composeState.liveMessage { @@ -943,6 +1287,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#liveMessageToSend private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? { let s = t != lm.typedMsg ? truncateToWords(t) : t return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil @@ -973,6 +1318,15 @@ struct ComposeView: View { cancelEnabled: !composeState.inProgress ) Divider() + case let .chatLinkPreview(chatLink, _): + ComposeChatLinkView( + chatLink: chatLink, + cancelPreview: { + composeState = composeState.copy(preview: .noPreview) + }, + cancelEnabled: !composeState.inProgress + ) + Divider() case let .mediaPreviews(mediaPreviews: media): ComposeImageView( images: media.map { (img, _) in img }, @@ -1089,6 +1443,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#sendMessage private func sendMessage(ttl: Int?) { logger.debug("ChatView sendMessage") Task { @@ -1097,6 +1452,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#sendMessageAsync private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? { var sent: ChatItem? let msgText = text ?? composeState.message @@ -1129,6 +1485,10 @@ struct ComposeView: View { sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case .linkPreview: sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions) + case let .chatLinkPreview(chatLink, ownerSig): + let linkStr = chatLink.connLinkStr + let text = msgText.isEmpty ? linkStr : msgText + "\n" + linkStr + sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case let .mediaPreviews(media): // TODO: CHECK THIS let last = media.count - 1 @@ -1231,9 +1591,9 @@ struct ComposeView: View { return .file(msgText) case .report(_, let reason): return .report(text: msgText, reason: reason) - // TODO [short links] update chat link - case let .chat(_, chatLink): - return .chat(text: msgText, chatLink: chatLink) + case let .chat(_, chatLink, ownerSig): + let text = msgText.isEmpty ? chatLink.connLinkStr : msgText + "\n" + chatLink.connLinkStr + return .chat(text: text, chatLink: chatLink, ownerSig: ownerSig) case .unknown(let type, _): return .unknown(type: type, text: msgText) } @@ -1307,6 +1667,7 @@ struct ComposeView: View { type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, scope: chat.chatInfo.groupChatScope(), + sendAsGroup: chat.chatInfo.sendAsGroup, live: live, ttl: ttl, composedMessages: msgs @@ -1332,6 +1693,7 @@ struct ComposeView: View { toChatType: chat.chatInfo.chatType, toChatId: chat.chatInfo.apiId, toScope: chat.chatInfo.groupChatScope(), + sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, fromChatType: fromChatInfo.chatType, fromChatId: fromChatInfo.apiId, fromScope: fromChatInfo.groupChatScope(), @@ -1363,6 +1725,7 @@ struct ComposeView: View { await MainActor.run { composeState.inProgress = true } } + // Spec: spec/client/compose.md#startVoiceMessageRecording private func startVoiceMessageRecording() async { startingRecording = true let fileName = generateNewFileName("voice", "m4a") @@ -1403,6 +1766,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#finishVoiceMessageRecording private func finishVoiceMessageRecording() { audioRecorder?.stop() audioRecorder = nil @@ -1413,6 +1777,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#allowVoiceMessagesToContact private func allowVoiceMessagesToContact() { if case let .direct(contact) = chat.chatInfo { allowFeatureToContact(contact, .voice) @@ -1438,12 +1803,14 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#cancelVoiceMessageRecording private func cancelVoiceMessageRecording(_ fileName: String) { stopPlayback.toggle() audioRecorder?.stop() removeFile(fileName) } + // Spec: spec/client/compose.md#clearState private func clearState(live: Bool = false) { if live { composeState.inProgress = false @@ -1457,11 +1824,13 @@ struct ComposeView: View { startingRecording = false } + // Spec: spec/client/compose.md#saveCurrentDraft private func saveCurrentDraft() { chatModel.draft = composeState chatModel.draftChatId = chat.id } + // Spec: spec/client/compose.md#clearCurrentDraft private func clearCurrentDraft() { if chatModel.draftChatId == chat.id { chatModel.draft = nil @@ -1469,6 +1838,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#showLinkPreview private func showLinkPreview(_ parsedMsg: [FormattedText]?) { prevLinkUrl = linkUrl (linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg) @@ -1488,6 +1858,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#getMessageLinks private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) { guard let parsedMsg else { return (nil, false) } let simplexLink = parsedMsgHasSimplexLink(parsedMsg) @@ -1514,23 +1885,58 @@ struct ComposeView: View { composeState = composeState.copy(preview: .noPreview) } + // Spec: spec/client/compose.md#loadLinkPreview private func loadLinkPreview(_ urlStr: String) { if pendingLinkUrl == urlStr, let url = URL(string: urlStr) { - composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) - getLinkPreview(url: url) { linkPreview in - if let linkPreview, pendingLinkUrl == urlStr { - privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5 - composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - composeState = composeState.copy(preview: .noPreview) + if linkPreviewsShowAlert { + showLinkPreviewsConfirmAlert { enable in + if let enable { + linkPreviewsShowAlert = false + useLinkPreviews = enable + UserDefaults.standard.set(enable, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + if enable { + fetchLinkPreview(url, urlStr: urlStr) + } else { + pendingLinkUrl = nil + composeState = composeState.copy(preview: .noPreview) + } + } else { + cancelLinkPreview() } } - pendingLinkUrl = nil + return } + fetchLinkPreview(url, urlStr: urlStr) } } + private func fetchLinkPreview(_ url: URL, urlStr: String) { + composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) + getLinkPreview(url: url) { linkPreview in + if let linkPreview, pendingLinkUrl == urlStr { + composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + composeState = composeState.copy(preview: .noPreview) + } + } + pendingLinkUrl = nil + } + } + + private func showLinkPreviewsConfirmAlert(onChoice: @escaping (Bool?) -> Void) { + showAlert( + NSLocalizedString("Enable link previews?", comment: "alert title"), + message: NSLocalizedString("Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later.", comment: "alert message"), + actions: { + [ + UIAlertAction(title: NSLocalizedString("Disable", comment: "alert button"), style: .destructive) { _ in onChoice(false) }, + UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .default) { _ in onChoice(true) } + ] + } + ) + } + private func resetLinkPreview() { linkUrl = nil prevLinkUrl = nil diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 845442c75f..1f328b2061 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> some View { - let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background)) + let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) let t = attachment() + Text(AttributedString(r.string)) return t.if(r.hasSecrets, transform: hiddenSecretsView) @@ -83,6 +83,9 @@ struct ContextItemView: View { case .file: return isFileLoaded ? image("doc.fill") : Text("") case .image: return image("photo") case .voice: return isFileLoaded ? image("play.fill") : Text("") + case let .chat(_, chatLink, _): + let hasText = contextItem.text != chatLink.connLinkStr + return image(chatLink.smallIconName) + Text(chatLink.displayName) + Text(verbatim: hasText ? " - " : "") default: return Text("") } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index 143bf42ea4..e9913053ea 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -48,7 +48,7 @@ func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismis showAlert( title: NSLocalizedString("Reject member?", comment: "alert title"), buttonTitle: "Reject", - buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, + buttonAction: { removeMember(groupInfo, member, withMessages: false, dismiss: dismiss) }, cancelButton: true ) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 07cd61583b..713f462c27 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -11,6 +11,7 @@ import SimpleXChat private let liveMsgInterval: UInt64 = 3000_000000 +// Spec: spec/client/compose.md#SendMessageView struct SendMessageView: View { var placeholder: String? @Binding var composeState: ComposeState diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 3154f16f5b..6b18c0c5ef 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 22.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift new file mode 100644 index 0000000000..82b89beaa5 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift @@ -0,0 +1,161 @@ +// +// AddGroupRelayView.swift +// SimpleX (iOS) +// +// Created by simplex on 29.04.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct AddGroupRelayView: View { + var groupInfo: GroupInfo + var existingRelayIds: Set + var onRelayAdded: () -> Void + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) var dismiss + @State private var availableRelays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = [] + @State private var selectedRelayIds: Set = [] + @State private var isLoading = true + @State private var isAdding = false + + var body: some View { + NavigationView { + List { + if isLoading { + Section { + ProgressView() + .frame(maxWidth: .infinity) + } + } else if availableRelays.isEmpty { + Section { + Text("No available relays") + .foregroundColor(theme.colors.secondary) + } + } else { + Section { + ForEach(availableRelays, id: \.relayId) { item in + relayCheckRow(item.relayId, item.relay, operatorName: item.operatorName) + } + } + } + } + .modifier(ThemedBackground(grouped: true)) + .navigationTitle("Add relays") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { addSelectedRelays() } + .disabled(selectedRelayIds.isEmpty || isAdding) + } + } + } + .task { await loadAvailableRelays() } + } + + private func relayCheckRow(_ relayId: Int64, _ relay: UserChatRelay, operatorName: String?) -> some View { + let selected = selectedRelayIds.contains(relayId) + return Button { + if selected { + selectedRelayIds.remove(relayId) + } else { + selectedRelayIds.insert(relayId) + } + } label: { + HStack { + VStack(alignment: .leading) { + Text(chatRelayDisplayName(relay)) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + if let opName = operatorName { + Text(opName) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .lineLimit(1) + } + } + Spacer() + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundColor(selected ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)) + } + } + } + + private func loadAvailableRelays() async { + do { + let servers = try await getUserServers() + var relays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = [] + for op in servers { + if let oper = op.operator, oper.enabled != true { continue } + let opName: String? = op.operator?.operatorTag != nil ? op.operator?.tradeName : nil + for relay in op.chatRelays { + if relay.enabled && !relay.deleted, + let relayId = relay.chatRelayId, + !existingRelayIds.contains(relayId) { + relays.append((relayId, relay, opName)) + } + } + } + await MainActor.run { + availableRelays = relays + isLoading = false + } + } catch { + logger.error("loadAvailableRelays error: \(responseError(error))") + await MainActor.run { + isLoading = false + } + } + } + + private func addSelectedRelays() { + let relayIds = Array(selectedRelayIds) + guard !relayIds.isEmpty else { return } + isAdding = true + Task { + do { + guard let result = try await apiAddGroupRelays(groupInfo.groupId, relayIds: relayIds) else { + await MainActor.run { isAdding = false } + return + } + await MainActor.run { + isAdding = false + switch result { + case let .added(gInfo, _, relays): + ChannelRelaysModel.shared.set(groupId: gInfo.groupId, groupRelays: relays) + onRelayAdded() + dismiss() + case let .addFailed(results): + let successIds = Set(results.filter { $0.relayError == nil }.compactMap { $0.relay.chatRelayId }) + if !successIds.isEmpty { + selectedRelayIds.subtract(successIds) + availableRelays.removeAll { successIds.contains($0.relayId) } + onRelayAdded() + } + let errorLines = results.filter { $0.relayError != nil } + .map { "\(chatRelayDisplayName($0.relay)): \($0.relayError.map { connErrorText($0) } ?? "")" } + let successNames = results.filter { $0.relayError == nil } + .map { chatRelayDisplayName($0.relay) } + var msg = errorLines.joined(separator: "\n") + if !successNames.isEmpty { + msg += "\n" + String.localizedStringWithFormat(NSLocalizedString("Relays added: %@.", comment: "alert message"), successNames.joined(separator: ", ")) + } + showAlert( + NSLocalizedString("Error adding relays", comment: "alert title"), + message: msg + ) + } + } + } catch { + await MainActor.run { + isAdding = false + showAlert(NSLocalizedString("Error adding relays", comment: "alert title"), message: responseError(error)) + } + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift new file mode 100644 index 0000000000..abcadc6c3f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -0,0 +1,96 @@ +// +// ChannelMembersView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 20.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelMembersView: View { + @ObservedObject var chat: Chat + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + + var body: some View { + let members = chatModel.groupMembers + .filter { m in + let s = m.wrapped.memberStatus + return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay + } + if groupInfo.isOwner { + let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) + List { + Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) { + memberRow(GMember(groupInfo.membership), user: true, showRole: true) + ForEach(members) { member in + memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner) + } + } + } + } else { + let owners = members.filter { $0.wrapped.memberRole >= .owner } + List { + Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) { + ForEach(owners) { member in + memberRow(member, user: false, showRole: false) + } + } + } + } + } + + @ViewBuilder private func memberRow(_ gMember: GMember, user: Bool, showRole: Bool) -> some View { + let member = gMember.wrapped + let nameText = Text(member.chatViewName) + .foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) + let displayName = member.verified + ? (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption).baselineOffset(2).kerning(-2) + .foregroundColor(theme.colors.secondary) + nameText + : nameText + let row = HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + displayName + .lineLimit(1) + if user { + Text("you") + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + } + Spacer() + if showRole { + Text(member.memberRole.text) + .foregroundColor(theme.colors.secondary) + } + } + if user { + row + } else { + NavigationLink { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: gMember, + scrollToItemId: Binding.constant(nil) + ) + .navigationBarHidden(false) + } label: { + row + } + } + } +} + +#Preview { + ChannelMembersView( + chat: Chat.sampleData, + groupInfo: GroupInfo.sampleData + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift new file mode 100644 index 0000000000..27935768e3 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -0,0 +1,179 @@ +// +// ChannelRelaysView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 20.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelRelaysView: View { + @ObservedObject var chat: Chat + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @ObservedObject private var channelRelaysModel = ChannelRelaysModel.shared + @State private var showAddRelay = false + + private var groupRelays: [GroupRelay] { + channelRelaysModel.groupId == groupInfo.groupId ? channelRelaysModel.groupRelays : [] + } + + var body: some View { + List { + relaysList() + // TODO [relays] re-enable when relay management ships + // if groupInfo.isOwner { + // Section { + // Button { + // showAddRelay = true + // } label: { + // Label("Add relay", systemImage: "plus") + // } + // } + // } + } + // TODO [relays] re-enable when relay management ships + // .sheet(isPresented: $showAddRelay) { + // // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // // regardless of relayStatus, so all current rows must be excluded from the add list. + // let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId }) + // AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) { + // Task { await chatModel.loadGroupMembers(groupInfo) } + // } + // } + .onAppear { + Task { + await chatModel.loadGroupMembers(groupInfo) + if groupInfo.isOwner { + let relays = await apiGetGroupRelays(groupInfo.groupId) + await MainActor.run { + ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays) + } + } + } + } + } + + @ViewBuilder private func relaysList() -> some View { + let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberStatus != .memRemoved && $0.wrapped.memberStatus != .memGroupDeleted } + if relayMembers.isEmpty { + Section { + Text("No chat relays") + .foregroundColor(theme.colors.secondary) + } + } else { + Section { + ForEach(relayMembers) { member in + let link = NavigationLink { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: member, + scrollToItemId: Binding.constant(nil), + groupRelay: groupRelays.first(where: { $0.groupMemberId == member.wrapped.groupMemberId }) + ) + .navigationBarHidden(false) + } label: { + let statusText = groupInfo.isOwner + ? ownerRelayStatusText(member.wrapped) + : subscriberRelayStatusText(member.wrapped) + relayMemberRow(member.wrapped, statusText: statusText) + } + // TODO [relays] re-enable when relay management ships + // if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) { + // link.swipeActions(edge: .trailing) { + // Button { + // showRemoveMemberAlert(groupInfo, member.wrapped) + // } label: { + // Label("Remove relay", systemImage: "trash") + // } + // .tint(.red) + // } + // } else { + // link + // } + link + } + } footer: { + Text("Chat relays forward messages to channel subscribers.") + } + } + } + + private func subscriberRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { + if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + relayConnStatus(member).text + } + } + + private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { + let relayStatus = groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus + return if relayStatus == .rejected { + "rejected" + } else if [.memLeft, .memRemoved, .memGroupDeleted].contains(member.memberStatus) { + relayConnStatus(member).text + } else if case .failed = member.activeConn?.connStatus { + "failed" + } else if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + relayStatus?.text ?? relayConnStatus(member).text + } + } + + private func relayMemberRow(_ member: GroupMember, statusText: LocalizedStringKey) -> some View { + HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + Text(member.chatViewName) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + Text(statusText) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + } + } +} + +func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) { + switch member.memberStatus { + case .memLeft: ("removed by operator", .red) + case .memRemoved, .memGroupDeleted: (member.memberStatus.text, .red) + default: + switch member.activeConn?.connStatus { + case .ready: ("connected", .green) + case .deleted: ("deleted", .red) + case .failed: ("failed", .red) + default: ("connecting", .yellow) + } + } +} + +func hostFromRelayLink(_ link: String) -> String { + if let ft = parseSimpleXMarkdown(link) { + for f in ft { + if case let .simplexLink(_, _, _, smpHosts) = f.format, + let host = smpHosts.first { + return host + } + } + } + return link +} + +#Preview { + ChannelRelaysView(chat: Chat.sampleData, groupInfo: GroupInfo.sampleData) +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index d8929caa3e..34479fc6cb 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -5,12 +5,14 @@ // Created by JRoberts on 14.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 +// Spec: spec/client/chat-view.md#GroupChatInfoView struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -18,8 +20,10 @@ struct GroupChatInfoView: View { @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo @Binding var scrollToItemId: ChatItem.ID? + @Binding var composeState: ComposeState var onSearch: () -> Void @State var localAlias: String + @State private var showSharePicker = false @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: GroupLink? @@ -46,7 +50,6 @@ struct GroupChatInfoView: View { case unblockMemberAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember) - case removeMemberAlert(mem: GroupMember) case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { @@ -60,7 +63,6 @@ struct GroupChatInfoView: View { case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)" case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)" - case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" case let .error(title, _): return "error \(title)" } } @@ -90,22 +92,71 @@ struct GroupChatInfoView: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - Section { - if groupInfo.canAddMembers && groupInfo.businessChat == nil { - groupLinkButton() + if groupInfo.useRelays && groupInfo.membership.memberIncognito { + Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { + HStack { + Text("Your random profile") + Spacer() + Text(groupInfo.membership.chatViewName) + .foregroundStyle(.indigo) + } } - if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { - memberSupportButton() + } + + let showUserSupportChat = groupInfo.membership.memberActive + && ((groupInfo.fullGroupPreferences.support.on && groupInfo.membership.memberRole < .moderator) + || groupInfo.membership.supportChat != nil) + + if groupInfo.useRelays { + Section { + // TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership) + if groupInfo.isOwner && groupLink != nil { + channelLinkButton() + } else if let link = groupInfo.groupProfile.publicGroup?.groupLink { + SimpleXLinkQRCode(uri: link) + Button { + showShareSheet(items: [simplexChatLink(link)]) + } label: { + Label("Share link", systemImage: "square.and.arrow.up") + } + Button { + showSharePicker = true + } label: { + Label("Share via chat", systemImage: "arrowshape.turn.up.forward") + } + } + if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) { + channelMembersButton() + } + if groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if showUserSupportChat { + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + } footer: { + if !groupInfo.isOwner && groupInfo.groupProfile.publicGroup?.groupLink != nil { + Text("You can share a link or a QR code - anybody will be able to join the channel.") + .foregroundColor(theme.colors.secondary) + } } - if groupInfo.canModerate { - GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } else { + Section { + if groupInfo.canAddMembers && groupInfo.businessChat == nil { + groupLinkButton() + } + if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if groupInfo.canModerate { + GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + if showUserSupportChat { + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + } header: { + Text("") } - if groupInfo.membership.memberActive - && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { - UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) - } - } header: { - Text("") } Section { @@ -118,7 +169,9 @@ struct GroupChatInfoView: View { GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) } footer: { let label: LocalizedStringKey = ( - groupInfo.businessChat == nil + groupInfo.useRelays + ? "Only channel owners can change channel preferences." + : groupInfo.businessChat == nil ? "Only group owners can change group preferences." : "Only chat owners can change preferences." ) @@ -127,10 +180,12 @@ struct GroupChatInfoView: View { } Section { - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() + if !groupInfo.useRelays { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() + } else { + sendReceiptsOptionDisabled() + } } NavigationLink { ChatWallpaperEditorSheet(chat: chat) @@ -142,7 +197,7 @@ struct GroupChatInfoView: View { Text("Delete chat messages from your device.") } - if !groupInfo.nextConnectPrepared { + if !groupInfo.nextConnectPrepared && !groupInfo.useRelays { Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { if groupInfo.canAddMembers { if (chat.chatInfo.incognito) { @@ -174,12 +229,18 @@ struct GroupChatInfoView: View { } Section { + if groupInfo.useRelays && (groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole == .relay })) { + channelRelaysButton() + } clearChatButton() if groupInfo.canDelete { deleteGroupButton() } if groupInfo.membership.memberCurrentOrPending { - leaveGroupButton() + if !groupInfo.useRelays || !groupInfo.isOwner + || members.contains(where: { $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }) { + leaveGroupButton() + } } } @@ -201,6 +262,9 @@ struct GroupChatInfoView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .sheet(isPresented: $showSharePicker) { + shareChannelPicker(groupInfo: groupInfo, composeState: $composeState) + } .alert(item: $alert) { alertItem in switch(alertItem) { case .deleteGroupAlert: return deleteGroupAlert() @@ -212,7 +276,6 @@ struct GroupChatInfoView: View { case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) 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 mkAlert(title: title, message: error) } } @@ -221,13 +284,15 @@ struct GroupChatInfoView: View { sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups } sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) - do { - if let gLink = try apiGetGroupLink(groupInfo.groupId) { - groupLink = gLink - groupLinkMemberRole = gLink.acceptMemberRole + if !groupInfo.useRelays || groupInfo.isOwner { + do { + if let gLink = try apiGetGroupLink(groupInfo.groupId) { + groupLink = gLink + groupLinkMemberRole = gLink.acceptMemberRole + } + } catch let error { + logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } - } catch let error { - logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } } } @@ -260,6 +325,14 @@ struct GroupChatInfoView: View { .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } + if groupInfo.useRelays, + let count = groupInfo.groupSummary.publicMemberCount, + count > 0 { + Text(subscriberCountStr(count)) + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + .padding(.bottom, 2) + } } .frame(maxWidth: .infinity, alignment: .center) } @@ -300,7 +373,9 @@ struct GroupChatInfoView: View { let buttonWidth = g.size.width / 4 HStack(alignment: .center, spacing: 8) { searchButton(width: buttonWidth) - if groupInfo.canAddMembers { + if groupInfo.useRelays && groupInfo.isOwner { + channelLinkActionButton(width: buttonWidth) + } else if !groupInfo.useRelays && groupInfo.canAddMembers { addMembersActionButton(width: buttonWidth) } if let nextNtfMode = chat.chatInfo.nextNtfMode { @@ -361,6 +436,23 @@ struct GroupChatInfoView: View { .disabled(!groupInfo.ready) } + private func channelLinkActionButton(width: CGFloat) -> some View { + ZStack { + InfoViewButton(image: "link", title: "link", width: width) { + groupLinkNavLinkActive = true + } + + NavigationLink(isActive: $groupLinkNavLinkActive) { + groupLinkDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + .disabled(!groupInfo.ready) + } + private func addMembersButton() -> some View { let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType { case .customer: "Add team members" @@ -456,7 +548,9 @@ struct GroupChatInfoView: View { } private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { - if member.activeConn?.connDisabled ?? false { + if case .failed = member.activeConn?.connStatus { + return "failed" + } else if member.activeConn?.connDisabled ?? false { return "disabled" } else if member.activeConn?.connInactive ?? false { return "inactive" @@ -517,7 +611,7 @@ struct GroupChatInfoView: View { private func removeSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .trailing) { Button(role: .destructive) { - alert = .removeMemberAlert(mem: member) + showRemoveMemberAlert(groupInfo, member) } label: { Label("Remove member", systemImage: "trash") .foregroundColor(Color.red) @@ -546,19 +640,53 @@ struct GroupChatInfoView: View { } } + private func channelLinkButton() -> some View { + NavigationLink { + groupLinkDestinationView() + } label: { + Label("Channel link", systemImage: "link") + } + } + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole, showTitle: false, - creatingGroup: false + creatingGroup: false, + isChannel: groupInfo.useRelays, + groupInfo: groupInfo, + composeState: $composeState ) - .navigationBarTitle("Group link") + .navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } + private func channelMembersButton() -> some View { + let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners" + return NavigationLink { + ChannelMembersView(chat: chat, groupInfo: groupInfo) + .navigationTitle(label) + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label(label, systemImage: "person.2") + } + } + + private func channelRelaysButton() -> some View { + NavigationLink { + ChannelRelaysView(chat: chat, groupInfo: groupInfo) + .navigationTitle("Chat relays") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Chat relays", systemImage: "externaldrive.connected.to.line.below") + } + } + struct UserSupportChatNavLink: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme @@ -653,7 +781,7 @@ struct GroupChatInfoView: View { groupProfile: groupInfo.groupProfile ) } label: { - Label("Edit group profile", systemImage: "pencil") + Label(groupInfo.useRelays ? "Edit channel profile" : "Edit group profile", systemImage: "pencil") } } @@ -675,7 +803,7 @@ struct GroupChatInfoView: View { } @ViewBuilder private func deleteGroupButton() -> some View { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel" : groupInfo.businessChat == nil ? "Delete group" : "Delete chat" Button(role: .destructive) { alert = .deleteGroupAlert } label: { @@ -694,7 +822,7 @@ struct GroupChatInfoView: View { } private func leaveGroupButton() -> some View { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat" + let label: LocalizedStringKey = groupInfo.useRelays ? "Leave channel" : groupInfo.businessChat == nil ? "Leave group" : "Leave chat" return Button(role: .destructive) { alert = .leaveGroupAlert } label: { @@ -705,7 +833,7 @@ struct GroupChatInfoView: View { // TODO reuse this and clearChatAlert with ChatInfoView private func deleteGroupAlert() -> Alert { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), message: deleteGroupAlertMessage(groupInfo), @@ -742,9 +870,11 @@ struct GroupChatInfoView: View { } private func leaveGroupAlert() -> Alert { - let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" + let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil + groupInfo.useRelays + ? "You will stop receiving messages from this channel. Chat history will be preserved." + : groupInfo.businessChat == nil ? "You will stop receiving messages from this group. Chat history will be preserved." : "You will stop receiving messages from this chat. Chat history will be preserved." ) @@ -791,32 +921,70 @@ struct GroupChatInfoView: View { alert = .largeGroupReceiptsDisabled } } +} - private func removeMemberAlert(_ mem: GroupMember) -> Alert { - let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Member will be removed from group - this cannot be undone!" - : "Member will be removed from chat - this cannot be undone!" +func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { + if mem.memberRole == .relay { + let isLastActive = groupInfo.useRelays && mem.memberCurrent && { + let activeRelays = ChatModel.shared.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent } + return activeRelays.count <= 1 + }() + showAlert( + NSLocalizedString("Remove relay?", comment: "alert title"), + message: isLastActive + ? NSLocalizedString("This is the last active relay. Removing it will prevent message delivery to subscribers.", comment: "alert message") + : NSLocalizedString("Relay will be removed from channel - this cannot be undone!", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) + }, + cancelAlertAction + ]} ) - return Alert( - title: Text("Remove member?"), - message: Text(messageLabel), - primaryButton: .destructive(Text("Remove")) { - removeMember(groupInfo, mem) - }, - secondaryButton: .cancel() + } else if groupInfo.useRelays { + showAlert( + NSLocalizedString("Remove subscriber?", comment: "alert title"), + message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) + }, + UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) + }, + cancelAlertAction + ]} + ) + } else { + showAlert( + NSLocalizedString("Remove member?", comment: "alert title"), + message: groupInfo.businessChat == nil + ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message") + : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) + }, + UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) + }, + cancelAlertAction + ]} ) } } -func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { +func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) { Task { do { - let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) + let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages) await MainActor.run { ChatModel.shared.updateGroup(updatedGroupInfo) updatedMembers.forEach { updatedMember in _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) + if withMessages { + ChatModel.shared.removeMemberItems(updatedMember, byMember: groupInfo.membership, groupInfo) + } } dismiss?() } @@ -833,10 +1001,18 @@ func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAc } func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { - groupInfo.businessChat == nil ? ( - groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") + groupInfo.useRelays ? ( + groupInfo.membership.memberCurrent + ? Text("Channel will be deleted for all subscribers - this cannot be undone!") + : Text("Channel will be deleted for you - this cannot be undone!") + ) : groupInfo.businessChat == nil ? ( + groupInfo.membership.memberCurrent + ? Text("Group will be deleted for all members - this cannot be undone!") + : Text("Group will be deleted for you - this cannot be undone!") ) : ( - groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!") + groupInfo.membership.memberCurrent + ? Text("Chat will be deleted for all members - this cannot be undone!") + : Text("Chat will be deleted for you - this cannot be undone!") ) } @@ -847,7 +1023,9 @@ struct GroupPreferencesButton: View { var creatingGroup: Bool = false private var label: LocalizedStringKey { - groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences" + groupInfo.useRelays ? "Channel preferences" + : groupInfo.businessChat == nil ? "Group preferences" + : "Chat preferences" } var body: some View { @@ -864,7 +1042,9 @@ struct GroupPreferencesButton: View { .navigationBarTitleDisplayMode(.large) .onDisappear { let saveText = NSLocalizedString( - creatingGroup ? "Save" : "Save and notify group members", + creatingGroup ? "Save" + : groupInfo.useRelays ? "Save and notify subscribers" + : "Save and notify group members", comment: "alert button" ) @@ -919,12 +1099,67 @@ func largeGroupReceiptsDisabledAlert() -> Alert { ) } +@ViewBuilder +func shareChannelPicker(groupInfo: GroupInfo, composeState: Binding? = nil) -> some View { + let v = ChatItemForwardingView( + title: "Share channel", + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) }, + includeLocal: false + ) + if #available(iOS 16.0, *) { + v.presentationDetents([.fraction(0.8)]) + } else { + v + } +} + +func shareChatLink(_ destChat: Chat, sourceGroupInfo: GroupInfo, composeState: Binding? = nil) { + let sendAsGroup = if let gInfo = destChat.chatInfo.groupInfo { gInfo.useRelays && gInfo.membership.memberRole >= .owner } else { false } + Task { + do { + let mc = try await apiShareChatMsgContent( + shareChatType: .group, shareChatId: Int64(sourceGroupInfo.groupId), + toChatType: destChat.chatInfo.chatType, toChatId: destChat.chatInfo.apiId, + toScope: destChat.chatInfo.groupChatScope(), sendAsGroup: sendAsGroup + ) + if case let .chat(_, chatLink, ownerSig) = mc { + await MainActor.run { + dismissAllSheets { + let cs = ComposeState(preview: .chatLinkPreview(chatLink: chatLink, ownerSig: ownerSig)) + if let composeState { + composeState.wrappedValue = cs + } else { + ChatModel.shared.draft = cs + ChatModel.shared.draftChatId = destChat.id + } + if destChat.id != ChatModel.shared.chatId { + ItemsModel.shared.loadOpenChat(destChat.id) + } + } + } + } else { + logger.error("shareChatLink: unexpected MsgContent: \(String(describing: mc))") + await MainActor.run { + showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: String(describing: mc)) + } + } + } catch { + logger.error("shareChatLink error: \(error.localizedDescription)") + await MainActor.run { + showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: error.localizedDescription) + } + } + } +} + struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: Binding.constant(GroupInfo.sampleData), scrollToItemId: Binding.constant(nil), + composeState: Binding.constant(ComposeState()), onSearch: {}, localAlias: "" ) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index bc1ac4ab65..22253c4808 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 15.10.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -16,7 +17,11 @@ struct GroupLinkView: View { @Binding var groupLinkMemberRole: GroupMemberRole var showTitle: Bool = false var creatingGroup: Bool = false + var isChannel: Bool = false + var groupInfo: GroupInfo? = nil + var composeState: Binding? = nil var linkCreatedCb: (() -> Void)? = nil + @State private var showSharePicker = false @State private var showShortLink = true @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -59,12 +64,16 @@ struct GroupLinkView: View { List { Group { if showTitle { - Text("Group link") + Text(isChannel ? "Channel link" : "Group link") .font(.largeTitle) .bold() .fixedSize(horizontal: false, vertical: true) } - Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + if isChannel { + Text("You can share a link or a QR code - anybody will be able to join the channel.") + } else { + Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + } } .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -72,15 +81,17 @@ struct GroupLinkView: View { Section { if let groupLink = groupLink { - Picker("Initial role", selection: $groupLinkMemberRole) { - ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in - Text(role.text) + if !isChannel { + Picker("Initial role", selection: $groupLinkMemberRole) { + ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in + Text(role.text) + } } + .frame(height: 36) } - .frame(height: 36) SimpleXCreatedLinkQRCode(link: groupLink.connLinkContact, short: $showShortLink) .id("simplex-qrcode-view-for-\(groupLink.connLinkContact.simplexChatUri(short: showShortLink))") - if groupLink.shouldBeUpgraded { + if !isChannel && groupLink.shouldBeUpgraded { Button { upgradeAndShareLinkAlert() } label: { @@ -88,7 +99,7 @@ struct GroupLinkView: View { } } Button { - if groupLink.shouldBeUpgraded { + if !isChannel && groupLink.shouldBeUpgraded { upgradeAndShareLinkAlert(groupLink: groupLink) } else { groupLink.shareAddress(short: showShortLink) @@ -96,8 +107,13 @@ struct GroupLinkView: View { } label: { Label("Share link", systemImage: "square.and.arrow.up") } + if groupInfo?.groupProfile.publicGroup != nil { + Button { showSharePicker = true } label: { + Label("Share via chat", systemImage: "arrowshape.turn.up.forward") + } + } - if !creatingGroup { + if !creatingGroup && !isChannel { Button(role: .destructive) { alert = .deleteLink } label: { Label("Delete link", systemImage: "trash") } @@ -109,7 +125,7 @@ struct GroupLinkView: View { .disabled(creatingLink) } } header: { - if let groupLink, groupLink.connLinkContact.connShortLink != nil { + if !isChannel, let groupLink, groupLink.connLinkContact.connShortLink != nil { ToggleShortLinkHeader(text: Text(""), link: groupLink.connLinkContact, short: $showShortLink) } } @@ -152,6 +168,11 @@ struct GroupLinkView: View { } } .modifier(ThemedBackground(grouped: true)) + .sheet(isPresented: $showSharePicker) { + if let gInfo = groupInfo { + shareChannelPicker(groupInfo: gInfo, composeState: composeState) + } + } } private func createGroupLink() { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 2298af614e..dc14c7520b 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 25.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -19,6 +20,7 @@ struct GroupMemberInfoView: View { @Binding var scrollToItemId: ChatItem.ID? var navigation: Bool = false var openedFromSupportChat: Bool = false + var groupRelay: GroupRelay? = nil @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @State private var connectionLoaded: Bool = false @@ -31,12 +33,31 @@ struct GroupMemberInfoView: View { @State private var justOpened = true @State private var progressIndicator = false + private var channelMemberSectionHeader: LocalizedStringKey { + if groupInfo.useRelays { + switch groupMember.wrapped.memberRole { + case .relay: "Relay" + case .owner: "Owner" + default: "Subscriber" + } + } else { + "Member" + } + } + + private var relaySectionFooter: LocalizedStringKey { + if groupInfo.isOwner { + "Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." + } else { + "You connected to the channel via this relay link." + } + } + enum GroupMemberInfoViewAlert: Identifiable { case blockMemberAlert(mem: GroupMember) case unblockMemberAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember) - case removeMemberAlert(mem: GroupMember) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case switchAddressAlert case abortSwitchAddressAlert @@ -51,7 +72,6 @@ struct GroupMemberInfoView: View { case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)" case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)" - case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)" case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" @@ -90,24 +110,32 @@ struct GroupMemberInfoView: View { .listRowSeparator(.hidden) .padding(.bottom, 18) - infoActionButtons(member) - .padding(.horizontal) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + if !groupInfo.useRelays { + infoActionButtons(member) + .padding(.horizontal) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } if connectionLoaded { + let showMemberSupportChat = !openedFromSupportChat + && groupInfo.membership.memberRole >= .moderator + && member.memberRole != .relay + && ((groupInfo.fullGroupPreferences.support.on && member.memberRole < .moderator) + || member.supportChat != nil) if member.memberActive { Section { - if !openedFromSupportChat - && groupInfo.membership.memberRole >= .moderator - && (member.memberRole < .moderator || member.supportChat != nil) { + if showMemberSupportChat { MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) } - if let code = connectionCode { verifyCodeButton(code) } + if let code = connectionCode, + !(groupInfo.useRelays && member.memberRole == .relay) { + verifyCodeButton(code) + } if let connStats = connectionStats, connStats.ratchetSyncAllowed { synchronizeConnectionButton() @@ -116,6 +144,10 @@ struct GroupMemberInfoView: View { // synchronizeConnectionButtonForce() // } } + } else if groupInfo.useRelays && member.memberCurrent && showMemberSupportChat { + Section { + MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) + } } if let contactLink = member.contactLink { @@ -142,11 +174,11 @@ struct GroupMemberInfoView: View { } } - Section(header: Text("Member").foregroundColor(theme.colors.secondary)) { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat" + Section { + let label: LocalizedStringKey = groupInfo.useRelays ? "Channel" : groupInfo.businessChat == nil ? "Group" : "Chat" infoRow(label, groupInfo.displayName) - if let roles = member.canChangeRoleTo(groupInfo: groupInfo) { + if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) { Picker("Change role", selection: $newRole) { ForEach(roles) { role in Text(role.text) @@ -156,11 +188,39 @@ struct GroupMemberInfoView: View { } else { infoRow("Role", member.memberRole.text) } + if let link = member.relayLink { + infoRow("Relay link", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(link))) + } + if let address = groupRelay?.userChatRelay.address { + infoRow("Relay address", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(address))) + Button { + showShareSheet(items: [simplexChatLink(address)]) + } label: { + Label("Share relay address", systemImage: "square.and.arrow.up") + } + } + if groupRelay?.relayStatus == .rejected { + infoRow("Status", "rejected by relay operator") + } + } header: { + Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary) + } footer: { + if groupInfo.useRelays && member.memberRole == .relay { + Text(relaySectionFooter).foregroundColor(theme.colors.secondary) + } } if let connStats = connectionStats { Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - // TODO network connection status + if let subStatus = connStats.subStatus { + SubStatusRow(status: subStatus) + .onTapGesture { + showAlert( + NSLocalizedString("Network status", comment: "alert title"), + message: subStatus.statusExplanation + ) + } + } Button("Change receiving address") { alert = .switchAddressAlert } @@ -182,9 +242,22 @@ struct GroupMemberInfoView: View { } } + if let connFailedErr = member.activeConn?.connFailedErr { + Section { + Text(connFailedErr) + .foregroundColor(theme.colors.secondary) + } header: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Connection failed") + } + } + } + if groupInfo.membership.memberRole >= .moderator { adminDestructiveSection(member) - } else { + } else if !groupInfo.useRelays { nonAdminBlockSection(member) } @@ -196,16 +269,18 @@ struct GroupMemberInfoView: View { let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel) infoRow("Connection", connLevelDesc) } - Button ("Debug delivery") { - Task { - do { - if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) { - await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) } + if !groupInfo.useRelays || member.memberRole == .relay { + Button ("Debug delivery") { + Task { + do { + if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) { + await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) } + } + } catch let e { + logger.error("apiContactQueueInfo error: \(responseError(e))") + let a = getErrorAlert(e, "Error") + await MainActor.run { alert = .error(title: a.title, error: a.message) } } - } catch let e { - logger.error("apiContactQueueInfo error: \(responseError(e))") - let a = getErrorAlert(e, "Error") - await MainActor.run { alert = .error(title: a.title, error: a.message) } } } } @@ -265,7 +340,6 @@ struct GroupMemberInfoView: View { case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) 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 .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) @@ -396,7 +470,6 @@ struct GroupMemberInfoView: View { ItemsModel.shared.loadOpenChat(memberContact.id) { dismissAllSheets(animated: true) } - NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) } } catch let error { logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))") @@ -571,8 +644,13 @@ struct GroupMemberInfoView: View { blockForAllButton(mem) } } - if canRemove { - removeMemberButton(mem) + // TODO [relays] re-enable when relay management ships + if canRemove && mem.memberRole != .relay { + if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) { + removeMemberButton(mem) + } else if mem.memberRole != .relay { + deleteMemberMessagesButton(mem) + } } } } @@ -627,41 +705,35 @@ struct GroupMemberInfoView: View { private func removeMemberButton(_ mem: GroupMember) -> some View { Button(role: .destructive) { - alert = .removeMemberAlert(mem: mem) + showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss) } label: { - Label("Remove member", systemImage: "trash") + let text = mem.memberRole == .relay ? "Remove relay" + : groupInfo.useRelays ? "Remove subscriber" + : "Remove member" + Label(text, systemImage: "trash") .foregroundColor(.red) } } - private func removeMemberAlert(_ mem: GroupMember) -> Alert { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Member will be removed from group - this cannot be undone!" - : "Member will be removed from chat - this cannot be undone!" - ) - return Alert( - title: Text("Remove member?"), - message: Text(label), - primaryButton: .destructive(Text("Remove")) { - Task { - do { - let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) - await MainActor.run { - chatModel.updateGroup(updatedGroupInfo) - updatedMembers.forEach { updatedMember in - _ = chatModel.upsertGroupMember(updatedGroupInfo, updatedMember) - } - dismiss() - } - } catch let error { - logger.error("apiRemoveMembers error: \(responseError(error))") - let a = getErrorAlert(error, "Error removing member") - alert = .error(title: a.title, error: a.message) - } - } - }, - secondaryButton: .cancel() + private func deleteMemberMessagesButton(_ mem: GroupMember) -> some View { + Button(role: .destructive) { + showDeleteMemberMessagesAlert(mem) + } label: { + Label("Delete member messages", systemImage: "trash") + .foregroundColor(.red) + } + } + + func showDeleteMemberMessagesAlert(_ mem: GroupMember) { + showAlert( + NSLocalizedString("Delete member messages?", comment: "alert title"), + message: NSLocalizedString("Member messages will be deleted - this cannot be undone!", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Delete messages", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) + }, + cancelAlertAction + ]} ) } @@ -818,7 +890,7 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { Alert( - title: Text("Block member for all?"), + title: Text(gInfo.useRelays ? "Block subscriber for all?" : "Block member for all?"), message: Text("All new messages from \(mem.chatViewName) will be hidden!"), primaryButton: .destructive(Text("Block for all")) { blockMemberForAll(gInfo, mem, true) @@ -829,7 +901,7 @@ func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { Alert( - title: Text("Unblock member for all?"), + title: Text(gInfo.useRelays ? "Unblock subscriber for all?" : "Unblock member for all?"), message: Text("Messages from \(mem.chatViewName) will be shown!"), primaryButton: .default(Text("Unblock for all")) { blockMemberForAll(gInfo, mem, false) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 55b1dc6d2e..cc2feef706 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -27,26 +27,50 @@ struct GroupPreferencesView: View { @State private var showSaveDialogue = false var body: some View { - let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members" + let saveText: LocalizedStringKey = creatingGroup ? "Save" : groupInfo.useRelays ? "Save and notify subscribers" : "Save and notify group members" VStack { List { - Section { - MemberAdmissionButton( - groupInfo: $groupInfo, - admission: groupInfo.groupProfile.memberAdmission_, - currentAdmission: groupInfo.groupProfile.memberAdmission_, - creatingGroup: creatingGroup - ) + if !groupInfo.useRelays { + Section { + MemberAdmissionButton( + groupInfo: $groupInfo, + admission: groupInfo.groupProfile.memberAdmission_, + currentAdmission: groupInfo.groupProfile.memberAdmission_, + creatingGroup: creatingGroup + ) + } + featureSection(.timedMessages, $preferences.timedMessages.enable) + featureSection(.fullDelete, $preferences.fullDelete.enable) + featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role) + featureSection(.reactions, $preferences.reactions.enable) + featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) + featureSection(.files, $preferences.files.enable, $preferences.files.role) + featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) + featureSection(.reports, $preferences.reports.enable, disabled: true) // enable reports in 7.0 once directory support added + featureSection(.history, $preferences.history.enable) + featureSection(.support, $preferences.support.enable, disabled: true) + } else { + featureSection(.timedMessages, $preferences.timedMessages.enable) + featureSection(.fullDelete, $preferences.fullDelete.enable) + featureSection(.reactions, $preferences.reactions.enable) + featureSection(.history, $preferences.history.enable) + let supportNotice = NSLocalizedString("Chats with admins in public channels have no E2E encryption - use only with trusted chat relays.", comment: "alert message") + featureSection(.support, $preferences.support.enable, notice: supportNotice) + .onChange(of: preferences.support.enable) { enable in + if enable == .on { + showAlert( + NSLocalizedString("Enable chats with admins?", comment: "alert title"), + message: supportNotice, + actions: {[ + UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .destructive) { _ in }, + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel) { _ in + preferences.support.enable = .off + } + ]} + ) + } + } } - featureSection(.timedMessages, $preferences.timedMessages.enable) - featureSection(.fullDelete, $preferences.fullDelete.enable) - featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role) - featureSection(.reactions, $preferences.reactions.enable) - featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) - featureSection(.files, $preferences.files.enable, $preferences.files.role) - featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) - featureSection(.reports, $preferences.reports.enable) - featureSection(.history, $preferences.history.enable) if groupInfo.isOwner { Section { @@ -85,7 +109,7 @@ struct GroupPreferencesView: View { } } - private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View { + private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil, disabled: Bool = false, notice: String? = nil) -> some View { Section { let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon @@ -96,9 +120,9 @@ struct GroupPreferencesView: View { set: { on, _ in enableFeature.wrappedValue = on ? .on : .off } ) settingsRow(icon, color: color) { - Toggle(feature.text, isOn: enable) + Toggle(feature.text(isChannel: groupInfo.isChannel), isOn: enable) } - .disabled(feature == .reports) // remove in 6.4 + .disabled(disabled) if timedOn { DropdownCustomTimePicker( selection: $preferences.timedMessages.ttl, @@ -119,7 +143,7 @@ struct GroupPreferencesView: View { } } else { settingsRow(icon, color: color) { - infoRow(Text(feature.text), enableFeature.wrappedValue.text) + infoRow(Text(feature.text(isChannel: groupInfo.isChannel)), enableFeature.wrappedValue.text) } if timedOn { infoRow("Delete after", timeText(preferences.timedMessages.ttl)) @@ -137,8 +161,11 @@ struct GroupPreferencesView: View { } } } footer: { - Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner)) - .foregroundColor(theme.colors.secondary) + VStack(alignment: .leading) { + Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner, isChannel: groupInfo.isChannel)) + if let notice { Text(notice) } + } + .foregroundColor(theme.colors.secondary) } .onChange(of: enableFeature.wrappedValue) { enabled in if case .off = enabled { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 69587c0152..126fcc57b3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -37,12 +37,12 @@ struct GroupProfileView: View { var body: some View { List { - EditProfileImage(profileImage: $groupProfile.image, showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $groupProfile.image, iconName: groupInfo.chatIconName, showChooseSource: $showChooseSource) .if(!focusDisplayName) { $0.padding(.top) } Section { HStack { - TextField("Group display name", text: $groupProfile.displayName) + TextField(groupInfo.useRelays ? "Channel display name" : "Group display name", text: $groupProfile.displayName) .focused($focusDisplayName) if !validNewProfileName { Button { @@ -54,7 +54,7 @@ struct GroupProfileView: View { } let fullName = groupInfo.groupProfile.fullName if fullName != "" && fullName != groupProfile.displayName { - TextField("Group full name (optional)", text: $groupProfile.fullName) + TextField(groupInfo.useRelays ? "Channel full name (optional)" : "Group full name (optional)", text: $groupProfile.fullName) } HStack { TextField("Short description", text: $shortDescr) @@ -67,7 +67,7 @@ struct GroupProfileView: View { } } } footer: { - Text("Group profile is stored on members' devices, not on the servers.") + Text(groupInfo.useRelays ? "Channel profile is stored on subscribers' devices and on the chat relays." : "Group profile is stored on members' devices, not on the servers.") } Section { @@ -80,11 +80,11 @@ struct GroupProfileView: View { currentProfileHash == groupProfile.hashValue && (groupInfo.groupProfile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces) ) - Button("Save group profile", action: saveProfile) + Button(groupInfo.useRelays ? "Save channel profile" : "Save group profile", action: saveProfile) .disabled(!canUpdateProfile) } } - .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { + .confirmationDialog(groupInfo.useRelays ? "Channel image" : "Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true } @@ -130,9 +130,15 @@ struct GroupProfileView: View { .onDisappear { if canUpdateProfile { showAlert( - title: NSLocalizedString("Save group profile?", comment: "alert title"), - message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"), - buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"), + title: groupInfo.useRelays + ? NSLocalizedString("Save channel profile?", comment: "alert title") + : NSLocalizedString("Save group profile?", comment: "alert title"), + message: groupInfo.useRelays + ? NSLocalizedString("Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers.", comment: "alert message") + : NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"), + buttonTitle: groupInfo.useRelays + ? NSLocalizedString("Save (and notify subscribers)", comment: "alert button") + : NSLocalizedString("Save (and notify members)", comment: "alert button"), buttonAction: saveProfile, cancelButton: true ) @@ -142,14 +148,14 @@ struct GroupProfileView: View { switch a { case let .saveError(err): return Alert( - title: Text("Error saving group profile"), + title: Text(groupInfo.useRelays ? "Error saving channel profile" : "Error saving group profile"), message: Text(err) ) case let .invalidName(name): return createInvalidNameAlert(name, $groupProfile.displayName) } } - .navigationBarTitle("Group profile") + .navigationBarTitle(groupInfo.useRelays ? "Channel profile" : "Group profile") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large) } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 75a6840c4e..880933985c 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -45,7 +45,7 @@ struct MemberSupportView: View { : membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } if membersWithChats.isEmpty { - Text("No chats with members") + Text(groupInfo.fullGroupPreferences.support.on ? "No chats with members" : "Chats with members are disabled") .foregroundColor(.secondary) } else { List { @@ -196,7 +196,9 @@ struct MemberSupportView: View { } private func memberStatus(_ member: GroupMember) -> LocalizedStringKey { - if member.activeConn?.connDisabled ?? false { + if case .failed = member.activeConn?.connStatus { + return "failed" + } else if member.activeConn?.connDisabled ?? false { return "disabled" } else if member.activeConn?.connInactive ?? false { return "inactive" diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 4937bca20e..b4590fc124 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -40,6 +40,7 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes { dynamicSizes[font] ?? defaultDynamicSizes } +// Spec: spec/client/chat-list.md#ChatListNavLink struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -90,6 +91,7 @@ struct ChatListNavLink: View { .actionSheet(item: $actionSheet) { $0.actionSheet } } + // Spec: spec/client/chat-list.md#contactNavLink private func contactNavLink(_ contact: Contact) -> some View { Group { if contact.isContactCard { @@ -211,6 +213,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#groupNavLink @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: @@ -241,7 +244,7 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing) { tagChatButton(chat) - if (groupInfo.membership.memberCurrentOrPending) { + if groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner) { leaveGroupChatButton(groupInfo) } if groupInfo.canDelete { @@ -266,7 +269,7 @@ struct ChatListNavLink: View { let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator let showClearButton = !chat.chatItems.isEmpty let showDeleteGroup = groupInfo.canDelete - let showLeaveGroup = groupInfo.membership.memberCurrentOrPending + let showLeaveGroup = groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner) let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) if showClearButton && totalNumberOfButtons <= 3 { @@ -295,6 +298,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#noteFolderNavLink private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { NavLinkPlain( chatId: chat.chatInfo.id, @@ -325,6 +329,7 @@ struct ChatListNavLink: View { .tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary) } + // Spec: spec/client/chat-list.md#markReadButton @ViewBuilder private func markReadButton() -> some View { if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat { Button { @@ -344,6 +349,7 @@ struct ChatListNavLink: View { } + // Spec: spec/client/chat-list.md#toggleFavoriteButton @ViewBuilder private func toggleFavoriteButton() -> some View { if chat.chatInfo.chatSettings?.favorite == true { Button { @@ -362,6 +368,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#toggleNtfsButton @ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View { if let nextMode = chat.chatInfo.nextNtfMode { Button { @@ -382,6 +389,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#clearChatButton private func clearChatButton() -> some View { Button { AlertManager.shared.showAlert(clearChatAlert()) @@ -483,6 +491,7 @@ struct ChatListNavLink: View { .tint(.red) } + // Spec: spec/client/chat-list.md#contactRequestNavLink private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) .frameCompat(height: dynamicRowHeight) @@ -517,6 +526,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#contactConnectionNavLink private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { ContactConnectionView(chat: chat) .frameCompat(height: dynamicRowHeight) @@ -555,7 +565,7 @@ struct ChatListNavLink: View { } private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), message: deleteGroupAlertMessage(groupInfo), @@ -610,9 +620,11 @@ struct ChatListNavLink: View { } private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert { - let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" + let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil + groupInfo.useRelays + ? "You will stop receiving messages from this channel. Chat history will be preserved." + : groupInfo.businessChat == nil ? "You will stop receiving messages from this group. Chat history will be preserved." : "You will stop receiving messages from this chat. Chat history will be preserved." ) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index efaba518a9..dc4971aafa 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-list.md import SwiftUI import SimpleXChat @@ -31,13 +32,15 @@ enum UserPickerSheet: Identifiable { } } +// Spec: spec/client/chat-list.md#PresetTag enum PresetTag: Int, Identifiable, CaseIterable, Equatable { case groupReports = 0 case favorites = 1 case contacts = 2 case groups = 3 - case business = 4 - case notes = 5 + case channels = 4 + case business = 5 + case notes = 6 var id: Int { rawValue } @@ -46,6 +49,7 @@ enum PresetTag: Int, Identifiable, CaseIterable, Equatable { } } +// Spec: spec/client/chat-list.md#ActiveFilter enum ActiveFilter: Identifiable, Equatable { case presetTag(PresetTag) case userTag(ChatTag) @@ -61,13 +65,14 @@ enum ActiveFilter: Identifiable, Equatable { } class SaveableSettings: ObservableObject { - @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: []) + @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [], serverWarnings: []) } struct ServerSettings { public var currUserServers: [UserOperatorServers] public var userServers: [UserOperatorServers] public var serverErrors: [UserServersError] + public var serverWarnings: [UserServersWarning] } struct UserPickerSheetView: View { @@ -135,6 +140,7 @@ struct UserPickerSheetView: View { } } +// Spec: spec/client/chat-list.md#ChatListView struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared @@ -160,6 +166,7 @@ struct ChatListView: View { @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + // Spec: spec/client/chat-list.md#body var body: some View { if #available(iOS 16.0, *) { viewBody.scrollDismissesKeyboard(.immediately) @@ -287,36 +294,40 @@ struct ChatListView: View { @ToolbarContentBuilder var topToolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { leadingToolbarItem } - ToolbarItem(placement: .principal) { SubsStatusIndicator() } + ToolbarItem(placement: .principal) { if !shouldShowOnboarding { SubsStatusIndicator() } } ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem } } - + @ToolbarContentBuilder var bottomToolbar: some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 ToolbarItem(placement: .bottomBar) { HStack { leadingToolbarItem.padding(.bottom, padding) Spacer() - SubsStatusIndicator().padding(.bottom, padding) - Spacer() + if !shouldShowOnboarding { + SubsStatusIndicator().padding(.bottom, padding) + Spacer() + } trailingToolbarItem.padding(.bottom, padding) } .contentShape(Rectangle()) .onTapGesture { scrollToSearchBar = true } } } - + @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) { leadingToolbarItem.padding(.bottom, padding) Spacer() - SubsStatusIndicator().padding(.bottom, padding) - Spacer() + if !shouldShowOnboarding { + SubsStatusIndicator().padding(.bottom, padding) + Spacer() + } trailingToolbarItem.padding(.bottom, padding) } } - + @ViewBuilder var leadingToolbarItem: some View { let user = chatModel.currentUser ?? User.sampleData ZStack(alignment: .topTrailing) { @@ -342,7 +353,34 @@ struct ChatListView: View { } } - private var chatList: some View { + private var shouldShowOnboarding: Bool { + !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations + } + + private var hasConversations: Bool { + chatModel.chats.contains { chat in + switch chat.chatInfo { + case .local: return false + case let .direct(contact): return !contact.chatDeleted && !contact.isContactCard + case .group: return true + case .contactRequest: return false + case .contactConnection: return false + case .invalidJSON: return false + } + } + } + + @ViewBuilder private var chatList: some View { + if shouldShowOnboarding { + ConnectOnboardingView() + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .modifier(ThemedBackground()) + } else { + chatListContent + } + } + + private var chatListContent: some View { let cs = filteredChats() return ZStack { ScrollViewReader { scrollProxy in @@ -363,6 +401,13 @@ struct ChatListView: View { .padding(.top, oneHandUI ? 8 : 0) .id("searchBar") } + if !oneHandUICardShown { + OneHandUICard() + .padding(.vertical, 6) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in ChatListNavLink(chat: chat, parentSheet: $sheet) @@ -382,15 +427,8 @@ struct ChatListView: View { .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) } } - if !oneHandUICardShown { - OneHandUICard() - .padding(.vertical, 6) - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - if !addressCreationCardShown { - AddressCreationCard() + if !addressCreationCardShown && hasConversations { + ConnectBannerCard() .padding(.vertical, 6) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) @@ -445,6 +483,7 @@ struct ChatListView: View { } + // Spec: spec/client/chat-list.md#unreadBadge private func unreadBadge(size: CGFloat = 18) -> some View { Circle() .frame(width: size, height: size) @@ -464,11 +503,13 @@ struct ChatListView: View { } } + // Spec: spec/client/chat-list.md#stopAudioPlayer func stopAudioPlayer() { VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.smallView = [:] } + // Spec: spec/client/chat-list.md#filteredChats private func filteredChats() -> [Chat] { if let linkChatId = searchChatFilteredBySimplexLink { return chatModel.chats.filter { $0.id == linkChatId } @@ -511,6 +552,7 @@ struct ChatListView: View { } } + // Spec: spec/client/chat-list.md#searchString func searchString() -> String { searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase } @@ -574,6 +616,7 @@ struct SubsStatusIndicator: View { } } +// Spec: spec/client/chat-list.md#ChatListSearchBar struct ChatListSearchBar: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @@ -796,11 +839,11 @@ struct TagsView: View { nil } let active = tag == selectedPresetTag - let (icon, text) = presetTagLabel(tag: tag, active: active) + let (icon, menuIcon, text) = presetTagLabel(tag: tag, active: active) let color: Color = active ? .accentColor : .secondary HStack(spacing: 4) { - Image(systemName: icon) + Image(systemName: menuIcon ?? icon) .foregroundColor(color) ZStack { Text(text).fontWeight(.semibold).foregroundColor(.clear) @@ -843,9 +886,9 @@ struct TagsView: View { Button { setActiveFilter(filter: .presetTag(tag)) } label: { - let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag) + let (icon, _, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag) HStack { - Image(systemName: systemName) + Image(systemName: icon) Text(text) } } @@ -853,8 +896,8 @@ struct TagsView: View { } } label: { if let tag = selectedPresetTag, tag.сollapse { - let (systemName, _) = presetTagLabel(tag: tag, active: true) - Image(systemName: systemName) + let (icon, menuIcon, _) = presetTagLabel(tag: tag, active: true) + Image(systemName: menuIcon ?? icon) .foregroundColor(.accentColor) } else { Image(systemName: "list.bullet") @@ -864,17 +907,19 @@ struct TagsView: View { .frame(minWidth: 28) } - private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) { + private func presetTagLabel(tag: PresetTag, active: Bool) -> (item: String, menu: String?, label: LocalizedStringKey) { switch tag { - case .groupReports: (active ? "flag.fill" : "flag", "Reports") - case .favorites: (active ? "star.fill" : "star", "Favorites") - case .contacts: (active ? "person.fill" : "person", "Contacts") - case .groups: (active ? "person.2.fill" : "person.2", "Groups") - case .business: (active ? "briefcase.fill" : "briefcase", "Businesses") - case .notes: (active ? "folder.fill" : "folder", "Notes") + case .groupReports: (item: active ? "flag.fill" : "flag", menu: nil, label: "Reports") + case .favorites: (item: active ? "star.fill" : "star", menu: nil, label: "Favorites") + case .contacts: (item: active ? "person.fill" : "person", menu: nil, label: "Contacts") + case .groups: (item: active ? "person.2.fill" : "person.2", menu: nil, label: "Groups") + case .channels: (item: active ? "antenna.radiowaves.left.and.right.circle.fill" : "antenna.radiowaves.left.and.right", menu: "antenna.radiowaves.left.and.right", label: "Channels") + case .business: (item: active ? "briefcase.fill" : "briefcase", menu: nil, label: "Businesses") + case .notes: (item: active ? "folder.fill" : "folder", menu: nil, label: "Notes") } } + // Spec: spec/client/chat-list.md#setActiveFilter private func setActiveFilter(filter: ActiveFilter) { if filter != chatTagsModel.activeFilter { chatTagsModel.activeFilter = filter @@ -895,6 +940,7 @@ func chatStoppedIcon() -> some View { } } +// Spec: spec/client/chat-list.md#presetTagMatchesChat func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool { switch tag { case .groupReports: @@ -911,7 +957,12 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C } case .groups: switch chatInfo { - case let .group(groupInfo, _): groupInfo.businessChat == nil + case let .group(groupInfo, _): groupInfo.businessChat == nil && !groupInfo.isChannel + default: false + } + case .channels: + switch chatInfo { + case let .group(groupInfo, _): groupInfo.isChannel default: false } case .business: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index c56d947a5a..243d804685 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -9,6 +9,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-list.md#ChatPreviewView struct ChatPreviewView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -295,11 +296,23 @@ struct ChatPreviewView: View { } func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { - let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() - let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil + let (itemText, itemFormattedText) = chatItemPreviewText(cItem) let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) + func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) { + if ci.meta.itemDeleted != nil { + return (markedDeletedText(), nil) + } + if case let .chat(_, chatLink, _) = ci.content.msgContent { + let descr = if let descr = chatLink.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + descr != "" { "\n" + descr } else { "" } + let text = chatLink.displayName + descr + return (text, nil) + } + return (ci.text(isChannel: chat.chatInfo.isChannel), ci.formattedText) + } + // 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 func markedDeletedText() -> String { @@ -425,6 +438,18 @@ struct ChatPreviewView: View { smallContentPreviewFile(size: dynamicMediaSize) { CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize) } + case let .chat(_, chatLink, ownerSig): + smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { + ProfileImage( + imageStr: chatLink.image, + iconName: chatLink.iconName, + size: dynamicMediaSize, + color: Color(uiColor: .tertiaryLabel) + ) + .onTapGesture { + planAndConnect(chatLink.connLinkStr, linkOwnerSig: ownerSig, theme: theme, dismiss: false) + } + } default: EmptyView() } } @@ -460,12 +485,6 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { let size = dynamicSize(userFont).incognitoSize switch chat.chatInfo { - case let .direct(contact): - if contact.active, let status = contact.activeConn?.connStatus, status == .ready || status == .sndReady { - NetworkStatusView(contact: contact, size: size) - } else { - incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) - } case .group: if progressByTimeout { ProgressView() @@ -482,30 +501,6 @@ struct ChatPreviewView: View { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } } - - struct NetworkStatusView: View { - @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize - @EnvironmentObject var theme: AppTheme - @ObservedObject var networkModel = NetworkModel.shared - - let contact: Contact - let size: CGFloat - - var body: some View { - let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize - switch (networkModel.contactNetworkStatus(contact)) { - case .connected: incognitoIcon(contact.contactConnIncognito, theme.colors.secondary, size: size) - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) - .foregroundColor(theme.colors.secondary) - default: - ProgressView() - } - } - } } @ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View { @@ -528,12 +523,12 @@ func flagIcon(size: CGFloat, color: Color) -> some View { .foregroundColor(color) } -func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { +func smallContentPreview(size: CGFloat, borderColor: Color = .secondary, _ 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)) + .strokeBorder(borderColor, lineWidth: 0.3, antialiased: true)) .padding(.vertical, size / 6) .padding(.leading, 3) .offset(x: 6) diff --git a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift index 059f24cc82..132a19d7e7 100644 --- a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift +++ b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift @@ -11,27 +11,46 @@ import SimpleXChat struct OneHandUICard: View { @EnvironmentObject var theme: AppTheme - @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @State private var showOneHandUIAlert = false var body: some View { - ZStack(alignment: .topTrailing) { - VStack(alignment: .leading, spacing: 8) { - Text("Toggle chat list:").font(.title3) - Toggle("Reachable chat toolbar", isOn: $oneHandUI) + HStack(spacing: 2) { + segment( + icon: "platter.filled.bottom.and.arrow.down.iphone", + text: "Bottom bar", + isSelected: oneHandUI + ) { + withAnimation { oneHandUI = true } } - Image(systemName: "multiply") - .foregroundColor(theme.colors.secondary) - .onTapGesture { - showOneHandUIAlert = true + .background { if oneHandUI { Color(uiColor: .systemGray5) } } + .background(ToolbarMaterial.material(toolbarMaterial)) + ZStack(alignment: .trailing) { + segment( + icon: "platter.filled.top.and.arrow.up.iphone", + text: "Top bar", + isSelected: !oneHandUI + ) { + withAnimation { oneHandUI = false } } + Image(systemName: "multiply") + .foregroundColor(theme.colors.secondary) + .frame(width: 12, height: 12) + .padding(.vertical, 4) + .padding(.trailing, 16) + .padding(.leading, 4) + .contentShape(Rectangle()) + .onTapGesture { + showOneHandUIAlert = true + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { if !oneHandUI { Color(uiColor: .systemGray5) } } + .background(ToolbarMaterial.material(toolbarMaterial)) } - .padding() - .background(theme.appColors.sentMessage) - .cornerRadius(12) - .frame(height: dynamicSize(userFont).rowHeight) + .clipShape(Capsule()) .alert(isPresented: $showOneHandUIAlert) { Alert( title: Text("Reachable chat toolbar"), @@ -44,6 +63,22 @@ struct OneHandUICard: View { ) } } + + private func segment(icon: String, text: LocalizedStringKey, isSelected: Bool, action: @escaping () -> Void) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.body) + .foregroundColor(isSelected ? theme.colors.secondary : theme.colors.primary) + Text(text) + .font(.subheadline) + .foregroundColor(isSelected ? theme.colors.secondary : theme.colors.onBackground) + } + .padding(.leading, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { action() } + } } #Preview { diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift index 79d122eabf..f484ce8938 100644 --- a/apps/ios/Shared/Views/ChatList/TagListView.swift +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -16,6 +16,7 @@ struct TagEditorNavParams { let tagId: Int64? } +// Spec: spec/client/chat-list.md#TagListView struct TagListView: View { var chat: Chat? = nil @Environment(\.dismiss) var dismiss: DismissAction diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index b1cd4015c6..63d28e3624 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -6,6 +6,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-list.md#UserPicker struct UserPicker: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 441a164f8a..dbc25e536f 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 04/09/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -33,6 +34,7 @@ enum DatabaseEncryptionAlert: Identifiable { } } +// Spec: spec/database.md#DatabaseEncryptionView struct DatabaseEncryptionView: View { @EnvironmentObject private var m: ChatModel @EnvironmentObject private var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 02a1b87826..f7f253a617 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 04/09/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -78,12 +79,25 @@ struct DatabaseErrorView: View { fileNameText(dbFile) } case let .downgrade(downMigrations): + let warnings = downMigrationWarnings(downMigrations).reversed() titleText("Database downgrade") + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .frame(width: 40, height: 36) + .foregroundColor(.red) Text("Warning: you may lose some data!") .bold() .padding(.horizontal, 25) .multilineTextAlignment(.center) - + if !warnings.isEmpty { + ForEach(warnings, id: \.self) { warning in + Text(warning) + .bold() + .multilineTextAlignment(.center) + .padding(.horizontal, 25) + } + } migrationsText(downMigrations) Spacer() VStack(spacing: 10) { diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index a7e61b3105..d5d70abaea 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 19/06/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -41,6 +42,7 @@ enum DatabaseAlert: Identifiable { } } +// Spec: spec/database.md#DatabaseView struct DatabaseView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index 79c0a42ae0..56e343588d 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 20/06/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -109,8 +110,8 @@ struct MigrateToAppGroupView: View { do { resetChatCtrl() try initializeChat(start: true) - onboardingStageDefault.set(.step4_SetNotificationsMode) - chatModel.onboardingStage = .step4_SetNotificationsMode + onboardingStageDefault.set(.step4_NetworkCommitments) + chatModel.onboardingStage = .step4_NetworkCommitments setV3DBMigration(.ready) } catch let error { dbContainerGroupDefault.set(.documents) diff --git a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift index 980308f13c..0491b38575 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift +++ b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift @@ -37,6 +37,7 @@ struct ChatItemClipped: ViewModifier { .rcvMsgContent, .rcvDecryptionError, .rcvIntegrityError, + .rcvMsgError, .invalidJSON: let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty { false diff --git a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift index b05ab17089..54e9fe0e80 100644 --- a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift +++ b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift @@ -21,6 +21,19 @@ struct DetermineWidth: View { } } +struct DetermineHeight: View { + typealias Key = MaximumHeightPreferenceKey + var body: some View { + GeometryReader { proxy in + Color.clear + .preference( + key: MaximumHeightPreferenceKey.self, + value: proxy.size.height + ) + } + } +} + struct DetermineWidthImageVideoItem: View { typealias Key = MaximumWidthImageVideoPreferenceKey var body: some View { @@ -41,6 +54,13 @@ struct MaximumWidthPreferenceKey: PreferenceKey { } } +struct MaximumHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + struct MaximumWidthImageVideoPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 86a5dc7aaa..9f2fc833ba 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -86,6 +86,40 @@ func showSheet( } } +func openExternalLink(_ url: URL) { + let s = url.absoluteString + if s.starts(with: "https://simplex.chat/contact#") || (s.starts(with: "https://smp") && s.contains(".simplex.im/a#")) { + ChatModel.shared.appOpenUrl = url + } else { + showAlert( + title: NSLocalizedString("Open external link?", comment: "alert title"), + message: s, + buttonTitle: NSLocalizedString("Open", comment: "alert button"), + buttonAction: { UIApplication.shared.open(url) }, + cancelButton: true + ) + } +} + +struct ExternalLink: View { + let destination: URL + let label: Label + + init(destination: URL, @ViewBuilder label: () -> Label) { + self.destination = destination + self.label = label() + } + + init(_ titleKey: LocalizedStringKey, destination: URL) where Label == Text { + self.destination = destination + self.label = Text(titleKey) + } + + var body: some View { + Button { openExternalLink(destination) } label: { label } + } +} + let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default) let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel) @@ -100,23 +134,29 @@ class OpenChatAlertViewController: UIViewController { private let profileName: String private let profileFullName: String private let profileImage: UIView + private let subtitle: String? + private let information: String? private let cancelTitle: String - private let confirmTitle: String + private let confirmTitle: String? private let onCancel: () -> Void - private let onConfirm: () -> Void + private let onConfirm: (() -> Void)? init( profileName: String, profileFullName: String, profileImage: UIView, + subtitle: String? = nil, + information: String? = nil, cancelTitle: String = "Cancel", - confirmTitle: String = "Open", - onCancel: @escaping () -> Void, - onConfirm: @escaping () -> Void + confirmTitle: String? = "Open", + onCancel: @escaping () -> Void = {}, + onConfirm: (() -> Void)? = nil ) { self.profileName = profileName self.profileFullName = profileFullName self.profileImage = profileImage + self.subtitle = subtitle + self.information = information self.cancelTitle = cancelTitle self.confirmTitle = confirmTitle self.onCancel = onCancel @@ -171,6 +211,30 @@ class OpenChatAlertViewController: UIViewController { profileViews.append(fullNameLabel) } + // Subtitle label (e.g. subscriber count) + if let subtitle { + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + subtitleLabel.textColor = .secondaryLabel + subtitleLabel.numberOfLines = 3 + subtitleLabel.textAlignment = .center + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + profileViews.append(subtitleLabel) + } + + // Information label (e.g. owner verification) + if let information { + let infoLabel = UILabel() + infoLabel.text = information + infoLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + infoLabel.textColor = .label + infoLabel.numberOfLines = 3 + infoLabel.textAlignment = .center + infoLabel.translatesAutoresizingMaskIntoConstraints = false + profileViews.append(infoLabel) + } + // Horizontal stack for image + name let stack = UIStackView(arrangedSubviews: profileViews) stack.axis = .vertical @@ -196,20 +260,54 @@ class OpenChatAlertViewController: UIViewController { cancelButton.titleLabel?.font = UIFont(descriptor: bodyDescr.withSymbolicTraits(.traitBold) ?? bodyDescr, size: 0) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) - let confirmButton = UIButton(type: .system) - confirmButton.setTitle(confirmTitle, for: .normal) - confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) - confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + let buttonStack: UIStackView + var buttonDividerConstraints: [NSLayoutConstraint] = [] - let verticalButtons = cancelButton.intrinsicContentSize.width + 20 >= alertWidth / 2 || confirmButton.intrinsicContentSize.width + 20 >= alertWidth / 2 + if let confirmTitle { + let confirmButton = UIButton(type: .system) + confirmButton.setTitle(confirmTitle, for: .normal) + confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) - // Button stack with equal width buttons - let buttonStack = UIStackView(arrangedSubviews: verticalButtons ? [confirmButton, cancelButton] : [cancelButton, confirmButton]) - buttonStack.axis = verticalButtons ? .vertical : .horizontal - buttonStack.distribution = .fillEqually - buttonStack.spacing = 0 // no spacing, use divider instead - buttonStack.translatesAutoresizingMaskIntoConstraints = false - buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * (verticalButtons ? 2 : 1)).isActive = true + let verticalButtons = cancelButton.intrinsicContentSize.width + 20 >= alertWidth / 2 || confirmButton.intrinsicContentSize.width + 20 >= alertWidth / 2 + + // Button stack with equal width buttons + buttonStack = UIStackView(arrangedSubviews: verticalButtons ? [confirmButton, cancelButton] : [cancelButton, confirmButton]) + buttonStack.axis = verticalButtons ? .vertical : .horizontal + buttonStack.distribution = .fillEqually + buttonStack.spacing = 0 // no spacing, use divider instead + buttonStack.translatesAutoresizingMaskIntoConstraints = false + buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * (verticalButtons ? 2 : 1)).isActive = true + + // Add divider between buttons + let buttonDivider = UIView() + buttonDivider.backgroundColor = UIColor.separator + buttonDivider.translatesAutoresizingMaskIntoConstraints = false + buttonStack.addSubview(buttonDivider) + + buttonDividerConstraints = if verticalButtons { + [ + buttonDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + buttonDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + buttonDivider.centerYAnchor.constraint(equalTo: buttonStack.centerYAnchor), + buttonDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale) + ] + } else { + [ + buttonDivider.topAnchor.constraint(equalTo: buttonStack.topAnchor), + buttonDivider.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + buttonDivider.centerXAnchor.constraint(equalTo: buttonStack.centerXAnchor), + buttonDivider.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale) + ] + } + } else { + // Single button + buttonStack = UIStackView(arrangedSubviews: [cancelButton]) + buttonStack.axis = .horizontal + buttonStack.distribution = .fillEqually + buttonStack.translatesAutoresizingMaskIntoConstraints = false + buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight).isActive = true + } // Vertical stack containing hStack and buttonStack let vStack = UIStackView(arrangedSubviews: [topRowContainer, buttonStack]) @@ -226,29 +324,6 @@ class OpenChatAlertViewController: UIViewController { horizontalDivider.translatesAutoresizingMaskIntoConstraints = false containerView.addSubview(horizontalDivider) - // Add divider between buttons - let buttonDivider = UIView() - buttonDivider.backgroundColor = UIColor.separator - buttonDivider.translatesAutoresizingMaskIntoConstraints = false - buttonStack.addSubview(buttonDivider) - - // Constraints - let buttonDividerConstraints = if verticalButtons { - [ - buttonDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - buttonDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - buttonDivider.centerYAnchor.constraint(equalTo: buttonStack.centerYAnchor), - buttonDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale) - ] - } else { - [ - buttonDivider.topAnchor.constraint(equalTo: buttonStack.topAnchor), - buttonDivider.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - buttonDivider.centerXAnchor.constraint(equalTo: buttonStack.centerXAnchor), - buttonDivider.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale) - ] - } - NSLayoutConstraint.activate([ // Container view centering and fixed width containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor), @@ -280,7 +355,7 @@ class OpenChatAlertViewController: UIViewController { @objc private func confirmTapped() { dismiss(animated: true) { - self.onConfirm() + self.onConfirm?() } } } @@ -291,10 +366,12 @@ func showOpenChatAlert( profileFullName: String, profileImage: Content, theme: AppTheme, + subtitle: String? = nil, + information: String? = nil, cancelTitle: String = "Cancel", - confirmTitle: String = "Open", + confirmTitle: String? = "Open", onCancel: @escaping () -> Void = {}, - onConfirm: @escaping () -> Void + onConfirm: (() -> Void)? = nil ) { let themedView = profileImage.environmentObject(theme) let hostingController = UIHostingController(rootView: themedView) @@ -306,6 +383,8 @@ func showOpenChatAlert( profileName: profileName, profileFullName: profileFullName, profileImage: hostedView, + subtitle: subtitle, + information: information, cancelTitle: cancelTitle, confirmTitle: confirmTitle, onCancel: onCancel, diff --git a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift index 33acf22ebe..71316cc5aa 100644 --- a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift +++ b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift @@ -29,6 +29,7 @@ struct VideoPlayerView: UIViewRepresentable { func makeUIView(context: UIViewRepresentableContext) -> UIView { let controller = AVPlayerViewController() controller.showsPlaybackControls = showControls + controller.videoGravity = .resizeAspectFill if #available(iOS 16.0, *) { controller.speeds = [] } diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift index 85ef85c611..902a3f95d7 100644 --- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift +++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift @@ -17,6 +17,15 @@ extension View { self } } + + @inline(__always) + @ViewBuilder func compactSectionSpacing() -> some View { + if #available(iOS 17, *) { + self.listSectionSpacing(.compact) + } else { + self + } + } } extension Notification.Name { diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index c21ff9be8b..36608c58d6 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift index 4a6f8e7549..6df31b4d59 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift index ca30fa5ce8..046a3fd1fc 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 11/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI diff --git a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift index 7ec3ee1a42..995b9f5b0d 100644 --- a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift +++ b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 0af8fa7ad8..2ff376701c 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -5,6 +5,7 @@ // Created by Avently on 14.02.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 93fe19cf33..cb3832b727 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -5,6 +5,7 @@ // Created by Avently on 23.02.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -373,10 +374,12 @@ struct MigrateToDevice: View { "Upgrade and open chat", "", .yesUp) - case .downgrade: + case let .downgrade(downMigrations): ("Database downgrade", "Downgrade and open chat", - NSLocalizedString("Warning: you may lose some data!", comment: ""), + ([NSLocalizedString("Warning: you may lose some data!", comment: "")] + + downMigrationWarnings(downMigrations).reversed()) + .joined(separator: "\n"), .yesUpDown) case let .migrationError(mtrError): ("Incompatible database version", diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift new file mode 100644 index 0000000000..7d1e5ce827 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -0,0 +1,536 @@ +// +// AddChannelView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 23.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct AddChannelView: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + @StateObject private var channelRelaysModel = ChannelRelaysModel.shared + @StateObject private var ss = SaveableSettings() + @State private var profile = GroupProfile(displayName: "", fullName: "") + @FocusState private var focusDisplayName: Bool + @State private var showChooseSource = false + @State private var showImagePicker = false + @State private var showTakePhoto = false + @State private var chosenImage: UIImage? = nil + @State private var hasRelays = true + @State private var groupInfo: GroupInfo? = nil + @State private var groupLink: GroupLink? = nil + @State private var groupRelays: [GroupRelay] = [] + @State private var creationInProgress = false + @State private var showLinkStep = false + @State private var relayListExpanded = false + + var body: some View { + Group { + if showLinkStep, let gInfo = groupInfo { + linkStepView(gInfo) + } else if let gInfo = groupInfo { + progressStepView(gInfo) + } else { + profileStepView() + } + } + } + + // MARK: - Step 1: Profile + + private func profileStepView() -> some View { + List { + Group { + HStack(spacing: 0) { + Spacer(minLength: 0) + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } + } + } + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) + } + .padding(.horizontal, 10) // Offsets transparent space built into 3D asset + #if SIMPLEX_ASSETS + Spacer(minLength: 0) + Image(colorScheme == .light ? "create-channel" : "create-channel-light") + .resizable() + .scaledToFit() + .frame(height: 140) + #endif + Spacer(minLength: 0) + } + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) + + Section { + channelNameTextField() + NavigationLink { + NetworkAndServers() + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) + .environmentObject(ss) + } label: { + let color: Color = hasRelays ? .accentColor : .orange + settingsRow("externaldrive.connected.to.line.below", color: color) { + Text("Configure relays").foregroundColor(color) + } + } + let canCreate = canCreateProfile() && hasRelays && !creationInProgress + Button(action: createChannel) { + settingsRow("checkmark", color: canCreate ? theme.colors.primary : theme.colors.secondary) { Text("Create public channel") } + } + .disabled(!canCreate) + } footer: { + if !hasRelays { + ServersWarningView(warnStr: NSLocalizedString("Enable at least one chat relay in Network & Servers.", comment: "channel creation warning")) + } else { + let name = ChatModel.shared.currentUser?.displayName ?? "" + Text("Your profile **\(name)** will be shared with channel relays and subscribers.\nRelays can access channel messages.") + .foregroundColor(theme.colors.secondary) + } + } + .compactSectionSpacing() + } + .onAppear { + Task { hasRelays = await checkHasRelays() } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + focusDisplayName = true + } + } + .confirmationDialog("Channel image", isPresented: $showChooseSource, titleVisibility: .visible) { + Button("Take picture") { showTakePhoto = true } + Button("Choose from library") { showImagePicker = true } + } + .fullScreenCover(isPresented: $showTakePhoto) { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + CameraImagePicker(image: $chosenImage) + } + } + .sheet(isPresented: $showImagePicker) { + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { showImagePicker = false } + } + } + .onChange(of: chosenImage) { image in + Task { + let resized: String? = if let image { + await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) + } else { + nil + } + await MainActor.run { profile.image = resized } + } + } + .modifier(ThemedBackground(grouped: true)) + } + + private func channelNameTextField() -> some View { + ZStack(alignment: .leading) { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidChannelNameAlert() + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(theme.colors.secondary) + } + TextField("Enter channel name…", text: $profile.displayName) + .padding(.leading, 36) + .focused($focusDisplayName) + .submitLabel(.continue) + .onSubmit { + if canCreateProfile() && hasRelays { createChannel() } + } + } + } + + private func canCreateProfile() -> Bool { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + return name != "" && validDisplayName(name) + } + + private func createChannel() { + focusDisplayName = false + profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) + profile.groupPreferences = GroupPreferences( + history: GroupPreference(enable: .on), + support: GroupPreference(enable: .off) + ) + creationInProgress = true + Task { + do { + let enabledRelays = try await chooseRandomRelays() + let relayIds = enabledRelays.compactMap { $0.chatRelayId } + guard !relayIds.isEmpty else { + await MainActor.run { + creationInProgress = false + hasRelays = false + } + return + } + guard let result = try await apiNewPublicGroup( + incognito: false, relayIds: relayIds, groupProfile: profile + ) else { + await MainActor.run { creationInProgress = false } + return + } + switch result { + case let .created(gInfo, gLink, gRelays): + await MainActor.run { + m.updateGroup(gInfo) + m.creatingChannelId = gInfo.id + groupInfo = gInfo + groupLink = gLink + groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) } + channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays) + creationInProgress = false + } + case let .creationFailed(relayResults): + await MainActor.run { + creationInProgress = false + showAlert( + NSLocalizedString("Error creating channel", comment: "alert title"), + message: NSLocalizedString("Relay results:", comment: "alert message") + "\n" + + relayResults.map { "\(chatRelayDisplayName($0.relay)): \($0.relayError.map { connErrorText($0) } ?? "ok")" }.joined(separator: "\n") + ) + } + } + } catch { + await MainActor.run { + creationInProgress = false + showAlert( + NSLocalizedString("Error creating channel", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private let maxRelays = 3 + + private func chooseRandomRelays() async throws -> [UserChatRelay] { + let servers = try await getUserServers() + // Operator relays are grouped per operator; custom relays (nil operator) + // are treated independently to maximize trust distribution. + var operatorGroups: [[UserChatRelay]] = [] + var customRelays: [UserChatRelay] = [] + for op in servers { + guard op.operator?.enabled ?? true else { continue } + let relays = op.chatRelays.filter { $0.enabled && !$0.deleted && $0.chatRelayId != nil } + guard !relays.isEmpty else { continue } + if op.operator != nil { + operatorGroups.append(relays.shuffled()) + } else { + customRelays = relays.shuffled() + } + } + var selected: [UserChatRelay] = [] + // Prefer at least one custom relay when available - + // user's own infrastructure for trust distribution. + if let relay = customRelays.first { + selected.append(relay) + customRelays.removeFirst() + if selected.count >= maxRelays { return selected } + } + // Round-robin across shuffled groups to distribute relays across operators. + var groups = operatorGroups + customRelays.map { [$0] } + groups.shuffle() + let maxDepth = groups.map(\.count).max() ?? 0 + for depth in 0..= maxRelays { return selected } + } + } + } + return selected + } + + private func checkHasRelays() async -> Bool { + guard let servers = try? await getUserServers() else { return false } + return servers.contains { op in + (op.operator?.enabled ?? true) && + op.chatRelays.contains { $0.enabled && !$0.deleted && $0.chatRelayId != nil } + } + } + + // MARK: - Step 2: Progress + + private func progressStepView(_ gInfo: GroupInfo) -> some View { + let failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .active && relayMemberConnFailed($0) == nil }.count + let total = groupRelays.count + return List { + Group { + ProfileImage(imageStr: gInfo.groupProfile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128) + .frame(maxWidth: .infinity, alignment: .center) + + Text(gInfo.groupProfile.displayName) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .center) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) + + Section { + Button { + withAnimation { relayListExpanded.toggle() } + } label: { + HStack(spacing: 8) { + if activeCount + failedCount < total { + RelayProgressIndicator(active: activeCount, total: total) + } + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel creation progress with errors"), activeCount, total, failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total)) + } + Spacer() + Image(systemName: relayListExpanded ? "chevron.up" : "chevron.down") + .foregroundColor(theme.colors.secondary) + } + } + .foregroundColor(theme.colors.onBackground) + + if relayListExpanded { + ForEach(groupRelays) { relay in + let failed = relayMemberConnFailed(relay) + if let err = failed { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + relayRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + relayRow(relay, connFailed: false) + } + } + } + } + .compactSectionSpacing() + + Section { + Button("Cancel and delete channel", role: .destructive) { + showCancelChannelAlert(gInfo) + } + Button("Continue") { + if activeCount >= total { + showLinkStep = true + } else if activeCount > 0 { + let actions: [UIAlertAction] = if activeCount + failedCount < total { + [ + UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in } + ] + } else { + [ + UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + cancelAlertAction + ] + } + showAlert( + NSLocalizedString("Not all relays connected", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Continue?", comment: "alert message"), activeCount, total), + actions: { actions } + ) + } + } + .disabled(activeCount == 0) + } + } + .navigationTitle("Creating channel") + .navigationBarBackButtonHidden(true) + .onDisappear { + if !showLinkStep && m.creatingChannelId == gInfo.id { + showCancelChannelAlert(gInfo) + } + } + .onChange(of: channelRelaysModel.groupRelays) { relays in + guard channelRelaysModel.groupId == gInfo.groupId else { return } + groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } + if relays.allSatisfy({ $0.relayStatus == .active && relayMemberConnFailed($0) == nil }) { + showLinkStep = true + channelRelaysModel.reset() + } + } + } + + private func relayMemberConnFailed(_ relay: GroupRelay) -> String? { + m.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + + private func relayRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + HStack { + Text(relayDisplayName(relay)) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + + // MARK: - Step 3: Link + + private func linkStepView(_ gInfo: GroupInfo) -> some View { + GroupLinkView( + groupId: gInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: Binding.constant(.observer), // TODO [relays] starting role should be communicated in protocol from owner to relays + showTitle: false, + creatingGroup: true, + isChannel: true, + groupInfo: gInfo + ) { + m.creatingChannelId = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(gInfo.id) + } + } + } + .navigationBarTitle("Channel link") + } + + private func cancelChannelCreation(_ gInfo: GroupInfo) { + m.creatingChannelId = nil + channelRelaysModel.reset() + dismissAllSheets(animated: true) + Task { + do { + try await apiDeleteChat(type: .group, id: gInfo.apiId) + await MainActor.run { m.removeChat(gInfo.id) } + } catch { + logger.error("cancelChannelCreation error: \(responseError(error))") + } + } + } + + private func showCancelChannelAlert(_ gInfo: GroupInfo) { + let activeCount = groupRelays.filter { $0.relayStatus == .active && relayMemberConnFailed($0) == nil }.count + let total = groupRelays.count + showAlert( + NSLocalizedString("Cancel creating channel?", comment: "alert title"), + message: String.localizedStringWithFormat( + NSLocalizedString("Your new channel %@ is connected to %d of %d relays.\nIf you cancel, the channel will be deleted - you can create it again.", comment: "alert message"), + gInfo.groupProfile.displayName, activeCount, total + ), + actions: {[ + UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in }, + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert action"), style: .destructive) { _ in + cancelChannelCreation(gInfo) + } + ]} + ) + } + + // MARK: - Helpers + + private func showInvalidChannelNameAlert() { + let validName = mkValidName(profile.displayName) + if validName == "" { + showAlert(NSLocalizedString("Invalid name!", comment: "alert title")) + } else { + showAlert( + NSLocalizedString("Invalid name!", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName), + actions: {[ + UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in + profile.displayName = validName + }, + cancelAlertAction + ]} + ) + } + } + +} + +func relayDisplayName(_ relay: GroupRelay) -> String { + if !relay.userChatRelay.displayName.isEmpty { return relay.userChatRelay.displayName } + if let domain = relay.userChatRelay.domains.first { return domain } + if let link = relay.relayLink { return hostFromRelayLink(link) } + return "relay \(relay.groupRelayId)" +} + +func chatRelayDisplayName(_ relay: UserChatRelay) -> String { + if !relay.displayName.isEmpty { return relay.displayName } + return relay.address +} + +func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View { + let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false + let isRejected = status == .rejected + let color: Color = connFailed || removed || isRejected ? .red : (status == .active ? .green : .yellow) + let text: LocalizedStringKey = + connFailed ? "failed" + : isRejected ? "rejected" + : memberStatus == .memLeft ? "removed by operator" + : removed ? "removed" + : status.text + return HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + .font(.caption) + } + } +} + +struct RelayProgressIndicator: View { + var active: Int + var total: Int + + var body: some View { + if active == 0 { + ProgressView() + .frame(width: 20, height: 20) + } else { + ZStack { + Circle() + .stroke(Color(uiColor: .tertiaryLabel), style: StrokeStyle(lineWidth: 2.5)) + Circle() + .trim(from: 0, to: Double(active) / Double(max(total, 1))) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 20, height: 20) + } + } +} + +#Preview { + AddChannelView() +} diff --git a/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift b/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift index 3a64a955c5..6add190b88 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift @@ -26,7 +26,7 @@ struct AddContactLearnMore: View { VStack(alignment: .leading, spacing: 18) { Text("To connect, your contact can scan QR code or use the link in the app.") Text("If you can't meet in person, show QR code in a video call, or share the link.") - Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).") + ExternalLink("Read more in User Guide.", destination: URL(string: "https://simplex.chat/docs/guide/readme.html#connect-to-friends")!) } .frame(maxWidth: .infinity, alignment: .leading) .listRowBackground(Color.clear) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 901b2deeab..47afee5f06 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct AddGroupView: View { + @Environment(\.colorScheme) var colorScheme @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction @@ -66,29 +67,40 @@ struct AddGroupView: View { func createGroupView() -> some View { List { Group { - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, size: 128) - if profile.image != nil { - Button { - profile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) + HStack(spacing: 0) { + Spacer(minLength: 0) + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } } } - } - editImageButton { showChooseSource = true } - .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable + } + .padding(.horizontal, 10) // Offsets transparent space built into 3D asset + #if SIMPLEX_ASSETS + Spacer(minLength: 0) + Image(colorScheme == .light ? "create-group" : "create-group-light") + .resizable() + .scaledToFit() + .frame(height: 140) + #endif + Spacer(minLength: 0) } - .frame(maxWidth: .infinity, alignment: .center) } .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) Section { groupNameTextField() @@ -108,6 +120,7 @@ struct AddGroupView: View { focusDisplayName = false } } + .compactSectionSpacing() } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 7adb04cb7e..177f8761f4 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -55,7 +55,7 @@ struct NewChatSheet: View { let showArchive = chatModel.chats.contains { $0.chatInfo.contact?.chatDeleted == true } let v = NavigationView { viewBody(showArchive) - .navigationTitle("New message") + .navigationTitle("New chat") .navigationBarTitleDisplayMode(.large) .navigationBarHidden(searchMode) .modifier(ThemedBackground(grouped: true)) @@ -99,9 +99,8 @@ struct NewChatSheet: View { Section { NavigationLink(isActive: $isAddContactActive) { NewChatView(selection: .invite) - .navigationTitle("New chat") .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) + .navigationBarTitleDisplayMode(.inline) } label: { navigateOnTap(Label("Create 1-time link", systemImage: "link.badge.plus")) { isAddContactActive = true @@ -109,9 +108,8 @@ struct NewChatSheet: View { } NavigationLink(isActive: $isScanPasteLinkActive) { NewChatView(selection: .connect, showQRCodeScanner: true) - .navigationTitle("New chat") .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) + .navigationBarTitleDisplayMode(.inline) } label: { navigateOnTap(Label("Scan / Paste link", systemImage: "qrcode")) { isScanPasteLinkActive = true @@ -125,6 +123,14 @@ struct NewChatSheet: View { } label: { Label("Create group", systemImage: "person.2.circle.fill") } + NavigationLink { + AddChannelView() + .navigationTitle("Create public channel") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right") + } } if (showArchive) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 3de1fdb972..9bcc326a66 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.11.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat @@ -73,11 +74,13 @@ func showKeepInvitationAlert() { ChatModel.shared.showingInvitation = nil } +// Spec: spec/client/navigation.md#NewChatView struct NewChatView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @State var selection: NewChatOption @State var showQRCodeScanner = false + var onboarding: Bool = false @State private var invitationUsed: Bool = false @State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil) @State private var showShortLink = true @@ -89,17 +92,19 @@ struct NewChatView: View { var body: some View { VStack(alignment: .leading) { - Picker("New chat", selection: $selection) { - Label("1-time link", systemImage: "link") - .tag(NewChatOption.invite) - Label("Connect via link", systemImage: "qrcode") - .tag(NewChatOption.connect) - } - .pickerStyle(.segmented) - .padding() - .onChange(of: $selection.wrappedValue) { opt in - if opt == NewChatOption.connect { - showQRCodeScanner = true + if !onboarding { + Picker("New chat", selection: $selection) { + Label("1-time link", systemImage: "link") + .tag(NewChatOption.invite) + Label("Connect via link", systemImage: "qrcode") + .tag(NewChatOption.connect) + } + .pickerStyle(.segmented) + .padding() + .onChange(of: $selection.wrappedValue) { opt in + if opt == NewChatOption.connect { + showQRCodeScanner = true + } } } @@ -114,7 +119,7 @@ struct NewChatView: View { } } if case .connect = selection { - ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert) + ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert, onboarding: onboarding) .transition(.move(edge: .trailing)) } } @@ -139,16 +144,22 @@ struct NewChatView: View { } default: () } - } + }, + including: onboarding ? .subviews : .all ) } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - InfoSheetButton { - AddContactLearnMore(showTitle: true) + if !onboarding { + InfoSheetButton { + AddContactLearnMore(showTitle: true) + } + } else { + Image(systemName: "info.circle").opacity(0) } } } + .if(onboarding) { $0.navigationBarTitleDisplayMode(.inline) } .modifier(ThemedBackground(grouped: true)) .onChange(of: invitationUsed) { used in if used && !(m.showingInvitation?.connChatUsed ?? true) { @@ -177,7 +188,8 @@ struct NewChatView: View { contactConnection: $contactConnection, connLinkInvitation: $connLinkInvitation, showShortLink: $showShortLink, - choosingProfile: $choosingProfile + choosingProfile: $choosingProfile, + onboarding: onboarding ) } else if creatingConnReq { creatingLinkProgressView() @@ -237,6 +249,7 @@ private func incognitoProfileImage() -> some View { } private struct InviteView: View { + @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var invitationUsed: Bool @@ -244,18 +257,19 @@ private struct InviteView: View { @Binding var connLinkInvitation: CreatedConnLink @Binding var showShortLink: Bool @Binding var choosingProfile: Bool + var onboarding: Bool = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false var body: some View { List { - Section(header: Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary)) { + Section(header: sectionHeader) { shareLinkView() } .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) qrCodeView() - if let selectedProfile = chatModel.currentUser { + if !onboarding, let selectedProfile = chatModel.currentUser { Section { NavigationLink { ActiveProfilePicker( @@ -279,9 +293,9 @@ private struct InviteView: View { } header: { Text("Share profile").foregroundColor(theme.colors.secondary) } footer: { - if incognitoDefault { - Text("A new random profile will be shared.") - } + if incognitoDefault { + Text("A new random profile will be shared.") + } } } } @@ -293,8 +307,35 @@ private struct InviteView: View { } } + private var sectionHeader: some View { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light + ? (onboarding ? "one-time-link" : "one-time-link-small") + : (onboarding ? "one-time-link-light" : "one-time-link-small-light")) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + sectionHeaderText + } + .padding(.bottom, 6) + #else + sectionHeaderText + .if(onboarding) { $0.padding(.bottom, 6) } + #endif + } + + @ViewBuilder private var sectionHeaderText: some View { + if onboarding { + Text("Send the link via any messenger - it's secure. Ask to paste into SimpleX.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } else { + Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary) + } + } + private func shareLinkView() -> some View { - HStack { + HStack(spacing: 8) { let link = connLinkInvitation.simplexChatUri(short: showShortLink) linkTextView(link) Button { @@ -303,6 +344,7 @@ private struct InviteView: View { } label: { Image(systemName: "square.and.arrow.up") .padding(.top, -7) + .padding(.horizontal, 8) } } .frame(maxWidth: .infinity) @@ -322,7 +364,11 @@ private struct InviteView: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } header: { - ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink) + if onboarding { + Text("Or show QR in person or via video call.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } else { + ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink) + } } } @@ -585,20 +631,24 @@ private struct ActiveProfilePicker: View { } private struct ConnectView: View { + @Environment(\.colorScheme) var colorScheme @StateObject private var connectProgressManager = ConnectProgressManager.shared @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @Binding var showQRCodeScanner: Bool @Binding var pastedLink: String @Binding var alert: NewChatViewAlert? + var onboarding: Bool = false @State var scannerPaused: Bool = false @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings var body: some View { List { - Section(header: Text("Paste the link you received").foregroundColor(theme.colors.secondary)) { + Section(header: connectSectionHeader) { pasteLinkView() } + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) { ScannerInView(showQRCodeScanner: $showQRCodeScanner, scannerPaused: $scannerPaused, processQRCode: processQRCode) } @@ -628,7 +678,7 @@ private struct ConnectView: View { } } } label: { - Text("Tap to paste link") + Text("Tap to paste link").foregroundColor(theme.colors.primary) } .disabled(!pasteboardHasStrings) .frame(maxWidth: .infinity, alignment: .center) @@ -667,6 +717,23 @@ private struct ConnectView: View { } } + private var connectSectionHeader: some View { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light + ? (onboarding ? "connect-via-link" : "connect-via-link-small") + : (onboarding ? "connect-via-link-light" : "connect-via-link-small-light")) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + Text("Paste the link you received").foregroundColor(theme.colors.secondary) + } + .padding(.bottom, 4) + #else + Text("Paste the link you received").foregroundColor(theme.colors.secondary) + #endif + } + private func connect(_ link: String) { scannerPaused = true planAndConnect( @@ -763,7 +830,7 @@ struct ScannerInView: View { } -private func linkTextView(_ link: String) -> some View { +func linkTextView(_ link: String) -> some View { Text(link) .lineLimit(1) .font(.caption) @@ -914,11 +981,13 @@ private func showAskCurrentOrIncognitoProfileSheet( actionStyle: UIAlertAction.Style = .default, connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, + ownerVerification: OwnerVerification? = nil, dismiss: Bool, cleanup: (() -> Void)? ) { showSheet( title, + message: ownerVerificationMessage(ownerVerification), actions: {[ UIAlertAction( title: NSLocalizedString("Use current profile", comment: "new chat action"), @@ -988,47 +1057,73 @@ private func showOwnGroupLinkConfirmConnectSheet( dismiss: Bool, cleanup: (() -> Void)? ) { - showSheet( - String.localizedStringWithFormat( - NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"), - groupInfo.displayName - ), - actions: {[ - UIAlertAction( - title: NSLocalizedString("Open group", comment: "new chat action"), - style: .default, - handler: { _ in - openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) - } + if groupInfo.useRelays { + showSheet( + String.localizedStringWithFormat( + NSLocalizedString("This is your link for channel %@!", comment: "new chat action"), + groupInfo.displayName ), - UIAlertAction( - title: NSLocalizedString("Use current profile", comment: "new chat action"), - style: .destructive, - handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) - } + actions: {[ + UIAlertAction( + title: NSLocalizedString("Open channel", comment: "new chat action"), + style: .default, + handler: { _ in + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "new chat action"), + style: .default, + handler: { _ in + cleanup?() + } + ) + ]} + ) + } else { + showSheet( + String.localizedStringWithFormat( + NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"), + groupInfo.displayName ), - UIAlertAction( - title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), - style: .destructive, - handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) - } - ), - UIAlertAction( - title: NSLocalizedString("Cancel", comment: "new chat action"), - style: .default, - handler: { _ in - cleanup?() - } - ) - ]} - ) + actions: {[ + UIAlertAction( + title: NSLocalizedString("Open group", comment: "new chat action"), + style: .default, + handler: { _ in + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Use current profile", comment: "new chat action"), + style: .destructive, + handler: { _ in + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), + style: .destructive, + handler: { _ in + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "new chat action"), + style: .default, + handler: { _ in + cleanup?() + } + ) + ]} + ) + } } private func showPrepareContactAlert( connectionLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, + ownerVerification: OwnerVerification? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? @@ -1047,6 +1142,7 @@ private func showPrepareContactAlert( size: alertProfileImageSize ), theme: theme, + information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"), onCancel: { cleanup?() }, @@ -1072,30 +1168,49 @@ private func showPrepareContactAlert( private func showPrepareGroupAlert( connectionLink: CreatedConnLink, + groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, + ownerVerification: OwnerVerification? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? ) { + let isChannel = !(groupShortLinkInfo?.direct ?? true) + let subscriberCount = groupShortLinkData.publicGroupData.map { "\($0.publicMemberCount) subscribers" } showOpenChatAlert( profileName: groupShortLinkData.groupProfile.displayName, profileFullName: groupShortLinkData.groupProfile.fullName, - profileImage: ProfileImage(imageStr: groupShortLinkData.groupProfile.image, iconName: "person.2.circle.fill", size: alertProfileImageSize), + profileImage: + ProfileImage( + imageStr: groupShortLinkData.groupProfile.image, + iconName: isChannel + ? "antenna.radiowaves.left.and.right.circle.fill" + : "person.2.circle.fill", + size: alertProfileImageSize + ), theme: theme, + subtitle: isChannel ? subscriberCount : nil, + information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), - confirmTitle: NSLocalizedString("Open new group", comment: "new chat action"), + confirmTitle: isChannel + ? NSLocalizedString("Open new channel", comment: "new chat action") + : NSLocalizedString("Open new group", comment: "new chat action"), onCancel: { cleanup?() }, onConfirm: { Task { do { - let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData) + let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData) await MainActor.run { + if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty, + case let .group(gInfo, _) = chat.chatInfo { + ChatModel.shared.channelRelayHostnames[gInfo.groupId] = relays + } ChatModel.shared.addChat(Chat(chat)) openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup) } } catch let error { logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)") - showAlert(NSLocalizedString("Error opening group", comment: ""), message: responseError(error)) + showAlert(NSLocalizedString(isChannel ? "Error opening channel" : "Error opening group", comment: "alert title"), message: responseError(error)) await MainActor.run { cleanup?() } @@ -1136,6 +1251,7 @@ private func showOpenKnownGroupAlert( theme: AppTheme, dismiss: Bool ) { + let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" } showOpenChatAlert( profileName: groupInfo.groupProfile.displayName, profileFullName: groupInfo.groupProfile.fullName, @@ -1146,9 +1262,15 @@ private func showOpenKnownGroupAlert( size: alertProfileImageSize ), theme: theme, + subtitle: groupInfo.useRelays ? subscriberCount : nil, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: - groupInfo.businessChat == nil + groupInfo.useRelays + ? ( groupInfo.nextConnectPrepared + ? NSLocalizedString("Open new channel", comment: "new chat action") + : NSLocalizedString("Open channel", comment: "new chat action") + ) + : groupInfo.businessChat == nil ? ( groupInfo.nextConnectPrepared ? NSLocalizedString("Open new group", comment: "new chat action") : NSLocalizedString("Open group", comment: "new chat action") @@ -1163,14 +1285,24 @@ private func showOpenKnownGroupAlert( ) } +// Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, + linkOwnerSig: LinkOwnerSig? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? = nil, filterKnownContact: ((Contact) -> Void)? = nil, filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { + if case .simplexLink(_, .relay, _, _) = strHasSingleSimplexLink(shortOrFullLink)?.format { + showAlert( + NSLocalizedString("Relay address", comment: "alert title"), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") + ) + cleanup?() + return + } ConnectProgressManager.shared.cancelConnectProgress() let inProgress = BoxedValue(true) connectTask(inProgress) @@ -1181,7 +1313,7 @@ func planAndConnect( func connectTask(_ inProgress: BoxedValue) { Task { - let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, inProgress: inProgress) + let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, linkOwnerSig: linkOwnerSig, inProgress: inProgress) await MainActor.run { ConnectProgressManager.shared.stopConnectProgress() } @@ -1190,13 +1322,14 @@ func planAndConnect( switch connectionPlan { case let .invitationLink(ilp): switch ilp { - case let .ok(contactSLinkData_): + case let .ok(contactSLinkData_, ownerVerification): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .invitationLink, .ok, short link data present") await MainActor.run { showPrepareContactAlert( connectionLink: connectionLink, contactShortLinkData: contactSLinkData, + ownerVerification: ownerVerification, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1209,6 +1342,7 @@ func planAndConnect( title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"), connectionLink: connectionLink, connectionPlan: connectionPlan, + ownerVerification: ownerVerification, dismiss: dismiss, cleanup: cleanup ) @@ -1251,13 +1385,14 @@ func planAndConnect( } case let .contactAddress(cap): switch cap { - case let .ok(contactSLinkData_): + case let .ok(contactSLinkData_, ownerVerification): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .contactAddress, .ok, short link data present") await MainActor.run { showPrepareContactAlert( connectionLink: connectionLink, contactShortLinkData: contactSLinkData, + ownerVerification: ownerVerification, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1270,6 +1405,7 @@ func planAndConnect( title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"), connectionLink: connectionLink, connectionPlan: connectionPlan, + ownerVerification: ownerVerification, dismiss: dismiss, cleanup: cleanup ) @@ -1329,13 +1465,15 @@ func planAndConnect( } case let .groupLink(glp): switch glp { - case let .ok(groupSLinkData_): + case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") await MainActor.run { showPrepareGroupAlert( connectionLink: connectionLink, + groupShortLinkInfo: groupShortLinkInfo_, groupShortLinkData: groupSLinkData, + ownerVerification: ownerVerification, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1348,6 +1486,7 @@ func planAndConnect( title: NSLocalizedString("Join group", comment: "new chat sheet title"), connectionLink: connectionLink, connectionPlan: connectionPlan, + ownerVerification: ownerVerification, dismiss: dismiss, cleanup: cleanup ) @@ -1393,6 +1532,33 @@ func planAndConnect( showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss) } } + case let .noRelays(groupSLinkData_): + logger.debug("planAndConnect, .groupLink, .noRelays") + await MainActor.run { + if let groupSLinkData = groupSLinkData_ { + showOpenChatAlert( + profileName: groupSLinkData.groupProfile.displayName, + profileFullName: groupSLinkData.groupProfile.fullName, + profileImage: + ProfileImage( + imageStr: groupSLinkData.groupProfile.image, + iconName: "antenna.radiowaves.left.and.right.circle.fill", + size: alertProfileImageSize + ), + theme: theme, + subtitle: NSLocalizedString("Channel has no active relays. Please try to join later.", comment: "alert subtitle"), + cancelTitle: NSLocalizedString("OK", comment: "alert button"), + confirmTitle: nil, + onCancel: { cleanup?() } + ) + } else { + showAlert( + NSLocalizedString("Channel temporarily unavailable", comment: "alert title"), + message: NSLocalizedString("Channel has no active relays. Please try to join later.", comment: "alert message") + ) + cleanup?() + } + } } case let .error(chatError): logger.debug("planAndConnect, .error \(chatErrorString(chatError))") @@ -1541,6 +1707,14 @@ private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? } } +private func ownerVerificationMessage(_ ov: OwnerVerification?) -> String? { + switch ov { + case .verified: NSLocalizedString("Link signature verified.", comment: "owner verification") + case let .failed(reason): String.localizedStringWithFormat(NSLocalizedString("⚠️ Signature verification failed: %@.", comment: "owner verification"), reason) + case .none: nil + } +} + func connReqSentAlert(_ type: ConnReqType) -> Alert { return mkAlert( title: "Connection request sent!", diff --git a/apps/ios/Shared/Views/NewChat/OnboardingCards.swift b/apps/ios/Shared/Views/NewChat/OnboardingCards.swift new file mode 100644 index 0000000000..0a0b3c143d --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/OnboardingCards.swift @@ -0,0 +1,311 @@ +// +// OnboardingCards.swift +// SimpleX (iOS) +// +// Created by simplex-chat on 06.04.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// MARK: - Card component + +struct OnboardingCardView: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + let imageName: String + let icon: String + let title: LocalizedStringKey + var subtitle: LocalizedStringKey? = nil + let labelHeightRatio: CGFloat + let action: () -> Void + + static let lightStops: [Gradient.Stop] = [ + .init(color: oklch(0.9219, 0.0431, 249.4), location: 0.0), + .init(color: oklch(0.9198, 0.0471, 240.7), location: 0.5), + .init(color: oklch(0.9772, 0.0358, 196.6), location: 0.9), + .init(color: oklch(0.9829, 0.0104, 70.0), location: 0.95), + .init(color: oklch(0.9886, 0.0272, 99.1), location: 1.0) + ] + + static let darkStops: [Gradient.Stop] = [ + .init(color: oklch(0.1578, 0.0609, 267.3), location: 0.4), + .init(color: oklch(0.4729, 0.1574, 267.3), location: 0.72), + .init(color: oklch(0.9024, 0.0760, 202.8), location: 0.9), + .init(color: oklch(0.9384, 0.0354, 65.0), location: 0.95), + .init(color: oklch(0.9744, 0.0370, 88.4), location: 1.0) + ] + + static let gradientAngle: Double = 80.0 * .pi / 180.0 + + static func gradientPoints(aspectRatio: CGFloat, scale: CGFloat) -> (start: UnitPoint, end: UnitPoint) { + let r = Double(aspectRatio) + let s = Double(scale) + let dx = cos(gradientAngle) + let dy = -sin(gradientAngle) / r + let dLenSq = dx * dx + dy * dy + let projections = [ + -0.5 * dx + (-0.5) * dy, + 0.5 * dx + (-0.5) * dy, + -0.5 * dx + 0.5 * dy, + 0.5 * dx + 0.5 * dy + ] + let tMin = projections.min()! + let tMax = projections.max()! + let startX = 0.5 + tMin * dx / dLenSq + let startY = 0.5 + tMin * dy / dLenSq + let endX = 0.5 + tMax * dx / dLenSq + let endY = 0.5 + tMax * dy / dLenSq + return ( + start: .init(x: 0.5 + (startX - 0.5) * s, y: 0.5 + (startY - 0.5) * s), + end: .init(x: 0.5 + (endX - 0.5) * s, y: 0.5 + (endY - 0.5) * s) + ) + } + + var body: some View { + Button(action: action) { + GeometryReader { geo in + let labelHeight = geo.size.width * labelHeightRatio + let imageHeight = max(geo.size.height - labelHeight, 1) + let imageAspect = imageHeight / geo.size.width + let gp = Self.gradientPoints(aspectRatio: imageAspect, scale: colorScheme == .light ? 1.2 : 1.5) + VStack(spacing: 0) { + ZStack { + LinearGradient( + stops: colorScheme == .light ? Self.lightStops : Self.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? imageName : "\(imageName)-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + #else + Image(systemName: icon) + .font(.system(size: imageHeight * 0.25)) + .foregroundColor(theme.colors.primary) + #endif + } + .frame(height: imageHeight) + + labelRow(height: labelHeight) + } + } + .clipShape(RoundedRectangle(cornerRadius: 24)) + } + .buttonStyle(.plain) + } + + private func labelRow(height: CGFloat) -> some View { + VStack { + HStack { + #if SIMPLEX_ASSETS + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(theme.colors.primary) + #endif + Text(title) + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + if let subtitle { + Text(subtitle) + .font(.footnote) + .foregroundColor(theme.colors.onBackground.opacity(0.7)) + } + } + .frame(height: height) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 16) + .background(ToolbarMaterial.material(toolbarMaterial)) + } +} + +// MARK: - Onboarding pager + +private let backButtonHeight: CGFloat = 44 + +struct ConnectOnboardingView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + @Environment(\.verticalSizeClass) private var verticalSizeClass + @State private var currentPage = 0 + @State private var showConnectViaLink = false + @State private var showInviteSomeone = false + @State private var showCreateAddress = false + + var body: some View { + TabView(selection: $currentPage) { + talkToSomeonePage.tag(0) + connectWithSomeonePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .sheet(isPresented: $showConnectViaLink) { + NavigationView { + NewChatView(selection: .connect, showQRCodeScanner: true, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + .sheet(isPresented: $showInviteSomeone) { + NavigationView { + NewChatView(selection: .invite, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + .sheet(isPresented: $showCreateAddress) { + NavigationView { + UserAddressView(autoCreate: true, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + } + + @ViewBuilder + private func cardPair( + _ geo: GeometryProxy, + @ViewBuilder card1: () -> C1, + @ViewBuilder card2: () -> C2 + ) -> some View { + let padding: CGFloat = 20 + let spacing: CGFloat = 20 + let isLandscape = verticalSizeClass == .compact + let cardWidth = isLandscape + ? (geo.size.width - padding * 2 - spacing) / 2 + : geo.size.width - padding * 2 + let maxCardHeight = cardWidth * 0.75 + + if isLandscape { + HStack(spacing: spacing) { + card1().frame(maxHeight: maxCardHeight) + card2().frame(maxHeight: maxCardHeight) + } + .padding(.horizontal, padding) + } else { + VStack(spacing: spacing) { + card1().frame(maxHeight: maxCardHeight) + card2().frame(maxHeight: maxCardHeight) + } + .padding(.horizontal, padding) + } + } + + // MARK: Screen 1 + + @ViewBuilder + private func pageHeader(_ title: LocalizedStringKey, showBack: Bool) -> some View { + let isLandscape = verticalSizeClass == .compact + let titleView = Text(title) + .font(.largeTitle) + .bold() + .lineLimit(1) + .minimumScaleFactor(0.67) + .frame(maxWidth: .infinity, alignment: .center) + if isLandscape { + ZStack(alignment: .leading) { + if showBack { backButton } + titleView + } + .padding(.horizontal, 16) + } else { + VStack(spacing: 0) { + if showBack { + backButton.frame(maxWidth: .infinity, alignment: .leading) + } else { + Color.clear.frame(height: backButtonHeight) + } + titleView + } + .padding(.horizontal, 16) + } + } + + private var backButton: some View { + Button { + withAnimation { currentPage = 0 } + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Back") + } + } + .frame(height: backButtonHeight) + } + + // MARK: Screen 1 + + private var talkToSomeonePage: some View { + GeometryReader { geo in + VStack(spacing: 0) { + pageHeader("Talk to someone", showBack: false) + + Spacer(minLength: 16) + + cardPair(geo) { + OnboardingCardView( + imageName: "card-let-someone-connect-to-you-alpha", + icon: "link.badge.plus", + title: "Let someone connect to you", + labelHeightRatio: 0.132, + action: { withAnimation { currentPage = 1 } } + ) + } card2: { + OnboardingCardView( + imageName: "card-connect-via-link-alpha", + icon: "qrcode.viewfinder", + title: "Connect via link or QR code", + labelHeightRatio: 0.132, + action: { showConnectViaLink = true } + ) + } + + Spacer(minLength: 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: Screen 2 + + private var connectWithSomeonePage: some View { + GeometryReader { geo in + VStack(spacing: 0) { + pageHeader("Create your link", showBack: true) + + Spacer(minLength: 16) + + cardPair(geo) { + OnboardingCardView( + imageName: "card-invite-someone-privately-alpha", + icon: "link.badge.plus", + title: "Invite someone privately", + subtitle: "A link for one person to connect", + labelHeightRatio: 0.195, + action: { showInviteSomeone = true } + ) + } card2: { + OnboardingCardView( + imageName: "card-create-your-public-address-alpha", + icon: "qrcode", + title: m.userAddress != nil ? "Your public address" : "Create your public address", + subtitle: "For anyone to reach you", + labelHeightRatio: 0.195, + action: { showCreateAddress = true } + ) + } + + Spacer(minLength: 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index c9054f30da..2b38065bd9 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 30/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import CoreImage.CIFilterBuiltins diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift deleted file mode 100644 index c8d0faafa7..0000000000 --- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// AddressCreationCard.swift -// SimpleX (iOS) -// -// Created by Diogo Cunha on 13/11/2024. -// Copyright © 2024 SimpleX Chat. All rights reserved. -// - -import SwiftUI -import SimpleXChat - -struct AddressCreationCard: View { - @EnvironmentObject var theme: AppTheme - @EnvironmentObject private var chatModel: ChatModel - @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize - @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false - @State private var showAddressCreationAlert = false - @State private var showAddressSheet = false - @State private var showAddressInfoSheet = false - - var body: some View { - let addressExists = chatModel.userAddress != nil - let chats = chatModel.chats.filter { chat in - !chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard - } - ZStack(alignment: .topTrailing) { - HStack(alignment: .top, spacing: 16) { - let envelopeSize = dynamicSize(userFont).profileImageSize - Image(systemName: "envelope.circle.fill") - .resizable() - .frame(width: envelopeSize, height: envelopeSize) - .foregroundColor(.accentColor) - VStack(alignment: .leading) { - Text("Your SimpleX address") - .font(.title3) - Spacer() - Text("How to use it") + textSpace + Text(Image(systemName: "info.circle")).foregroundColor(theme.colors.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - VStack(alignment: .trailing) { - Image(systemName: "multiply") - .foregroundColor(theme.colors.secondary) - .onTapGesture { - showAddressCreationAlert = true - } - Spacer() - Text("Create") - .foregroundColor(.accentColor) - .onTapGesture { - showAddressSheet = true - } - } - } - .onTapGesture { - showAddressInfoSheet = true - } - .padding() - .background(theme.appColors.sentMessage) - .cornerRadius(12) - .frame(height: dynamicSize(userFont).rowHeight) - .alert(isPresented: $showAddressCreationAlert) { - Alert( - title: Text("SimpleX address"), - message: Text("Tap Create SimpleX address in the menu to create it later."), - dismissButton: .default(Text("Ok")) { - withAnimation { - addressCreationCardShown = true - } - } - ) - } - .sheet(isPresented: $showAddressSheet) { - NavigationView { - UserAddressView(autoCreate: true) - .navigationTitle("SimpleX address") - .navigationBarTitleDisplayMode(.large) - .modifier(ThemedBackground(grouped: true)) - } - } - .sheet(isPresented: $showAddressInfoSheet) { - NavigationView { - UserAddressLearnMore(showCreateAddressButton: true) - .navigationTitle("Address or 1-time link?") - .navigationBarTitleDisplayMode(.inline) - .modifier(ThemedBackground(grouped: true)) - } - } - .onChange(of: addressExists) { exists in - if exists, !addressCreationCardShown { - addressCreationCardShown = true - } - } - .onChange(of: chats.count) { size in - if size >= 3, !addressCreationCardShown { - addressCreationCardShown = true - } - } - .onAppear { - if addressExists, !addressCreationCardShown { - addressCreationCardShown = true - } - } - } -} - -#Preview { - AddressCreationCard() -} diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 33ffa04a50..b61b81a46b 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 31.10.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat @@ -43,160 +44,147 @@ struct OnboardingButtonStyle: ButtonStyle { } } -private enum OnboardingConditionsViewSheet: Identifiable { - case showConditions - case configureOperators - - var id: String { - switch self { - case .showConditions: return "showConditions" - case .configureOperators: return "configureOperators" - } - } -} - struct OnboardingConditionsView: View { @EnvironmentObject var theme: AppTheme - @State private var serverOperators: [ServerOperator] = [] - @State private var selectedOperatorIds = Set() - @State private var sheetItem: OnboardingConditionsViewSheet? = nil - @State private var notificationsModeNavLinkActive = false - @State private var justOpened = true + @Environment(\.colorScheme) var colorScheme: ColorScheme + @State private var showConditionsSheet = false + var selectedOperatorIds: Set var body: some View { GeometryReader { g in - let v = ScrollView { - VStack(alignment: .leading, spacing: 20) { - Text("Conditions of use") - .font(.largeTitle) - .bold() - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 25) + VStack(alignment: .leading, spacing: 10) { + Spacer(minLength: 0) - Spacer() + heroImage().frame(maxWidth: .infinity, minHeight: 80) - VStack(alignment: .leading, spacing: 20) { - Text("Private chats, groups and your contacts are not accessible to server operators.") - .lineSpacing(2) - .frame(maxWidth: .infinity, alignment: .leading) - Text(""" - By using SimpleX Chat you agree to: - - send only legal content in public groups. - - respect other users – no spam. - """) - .lineSpacing(2) - .frame(maxWidth: .infinity, alignment: .leading) + Text("Network commitments") + .font(.largeTitle) + .bold() + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .fixedSize(horizontal: false, vertical: true) - Button("Privacy policy and conditions of use.") { - sheetItem = .showConditions - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 4) + Text("Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code") + .font(.callout) + .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + .padding(.top, 10) + .fixedSize(horizontal: false, vertical: true) - Spacer() + Text("You commit to:\n- Only legal content in public groups\n- Respect other users - no spam") + .font(.callout) + .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + .padding(.top, 10) + .fixedSize(horizontal: false, vertical: true) - VStack(spacing: 12) { - acceptConditionsButton() - - Button("Configure server operators") { - sheetItem = .configureOperators - } - .frame(minHeight: 40) - } + Button { + showConditionsSheet = true + } label: { + Text("Privacy policy and conditions of use.") + .fontWeight(.medium) + .fixedSize(horizontal: false, vertical: true) } - .padding(25) - .frame(minHeight: g.size.height) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + .padding(.top, 10) + .padding(.bottom, 15) + + Spacer(minLength: 0) + + acceptButton() + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } - .onAppear { - if justOpened { - serverOperators = ChatModel.shared.conditions.serverOperators - selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) - justOpened = false + .padding(.horizontal, 25) + .padding(.top, 25) + .padding(.bottom, 25) + .frame(minHeight: g.size.height) + } + .frame(maxHeight: .infinity) + .navigationBarHidden(true) + .sheet(isPresented: $showConditionsSheet) { + NavigationView { + VStack { + ConditionsTextView() + .padding() + acceptButton() + .padding(.horizontal, 25) + .padding(.bottom, 20) } - } - .sheet(item: $sheetItem) { item in - switch item { - case .showConditions: - SimpleConditionsView() - .modifier(ThemedBackground(grouped: true)) - case .configureOperators: - ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds) - .modifier(ThemedBackground()) - } - } - .frame(maxHeight: .infinity, alignment: .top) - if #available(iOS 16.4, *) { - v.scrollBounceBehavior(.basedOnSize) - } else { - v + .navigationTitle("Conditions of use") + .navigationBarTitleDisplayMode(.large) + .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) } + .modifier(ThemedBackground(grouped: true)) } } - .frame(maxHeight: .infinity, alignment: .top) - .navigationBarHidden(true) // necessary on iOS 15 } - private func continueToNextStep() { - onboardingStageDefault.set(.step4_SetNotificationsMode) - notificationsModeNavLinkActive = true - } - - func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View { + @ViewBuilder + private func heroImage() -> some View { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "network-commitments" : "network-commitments-light") + .resizable() + .scaledToFit() + #else ZStack { - button() - - NavigationLink(isActive: $notificationsModeNavLinkActive) { - notificationsModeDestinationView() - } label: { - EmptyView() - } - .frame(width: 1, height: 1) - .hidden() + let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.5, scale: colorScheme == .light ? 1.2 : 1.5) + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image(systemName: "checkmark.shield") + .font(.system(size: 72)) + .foregroundColor(theme.colors.primary) } + .aspectRatio(1.5, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .padding(.horizontal, 25) + #endif } - private func notificationsModeDestinationView() -> some View { - SetNotificationsMode() - .navigationBarBackButtonHidden(true) - .modifier(ThemedBackground()) - } - - private func acceptConditionsButton() -> some View { - notificationsModeNavLinkButton { - Button { - Task { - do { - let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId - let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: Array(selectedOperatorIds)) + private func acceptButton() -> some View { + Button { + Task { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: Array(selectedOperatorIds)) + await MainActor.run { + ChatModel.shared.conditions = r + } + if let enabledOps = enabledOperators(r.serverOperators) { + let r2 = try await setServerOperators(operators: enabledOps) await MainActor.run { - ChatModel.shared.conditions = r + ChatModel.shared.conditions = r2 + completeOnboarding() } - if let enabledOperators = enabledOperators(r.serverOperators) { - let r2 = try await setServerOperators(operators: enabledOperators) - await MainActor.run { - ChatModel.shared.conditions = r2 - continueToNextStep() - } - } else { - await MainActor.run { - continueToNextStep() - } - } - } catch let error { + } else { await MainActor.run { - showAlert( - NSLocalizedString("Error accepting conditions", comment: "alert title"), - message: responseError(error) - ) + completeOnboarding() } } + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) + } } - } label: { - Text("Accept") } - .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) - .disabled(selectedOperatorIds.isEmpty) + } label: { + Text("Accept") } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + } + + private func completeOnboarding() { + let m = ChatModel.shared + onboardingStageDefault.set(.onboardingComplete) + m.onboardingStage = .onboardingComplete } private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? { @@ -221,7 +209,7 @@ struct OnboardingConditionsView: View { if !haveXFTPProxy { op.xftpRoles.proxy = true } ops[firstEnabledIndex] = op return ops - } else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled + } else { return nil } } else { @@ -404,5 +392,5 @@ struct ChooseServerOperatorsInfoView: View { } #Preview { - OnboardingConditionsView() + OnboardingConditionsView(selectedOperatorIds: []) } diff --git a/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift b/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift new file mode 100644 index 0000000000..87f66a72bb --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift @@ -0,0 +1,114 @@ +// +// ConnectBannerCard.swift +// SimpleX (iOS) +// +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let bannerImageRatio: CGFloat = 800 / 505 + +struct ConnectBannerCard: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false + @State private var showNewLink = false + @State private var showPasteLink = false + + var body: some View { + VStack(alignment: .trailing, spacing: 3) { + Button { + withAnimation { addressCreationCardShown = true } + } label: { + Image(systemName: "multiply") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(theme.colors.secondary) + .frame(width: 30, height: 30) + .background(theme.colors.onBackground.opacity(0.08), in: Circle()) + } + HStack(spacing: 2) { + bannerHalf( + imageName: "banner-create-link", + icon: "link.badge.plus", + title: "New 1-time link", + action: { showNewLink = true } + ) + bannerHalf( + imageName: "banner-paste-link", + icon: "qrcode.viewfinder", + title: "Paste link / Scan", + action: { showPasteLink = true } + ) + } + .clipShape(RoundedRectangle(cornerRadius: 18)) + } + .sheet(isPresented: $showNewLink) { + NavigationView { + NewChatView(selection: .invite) + .modifier(ThemedBackground(grouped: true)) + } + } + .sheet(isPresented: $showPasteLink) { + NavigationView { + NewChatView(selection: .connect, showQRCodeScanner: true) + .modifier(ThemedBackground(grouped: true)) + } + } + } + + @ViewBuilder + private func bannerHalf(imageName: String, icon: String, title: LocalizedStringKey, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 0) { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? imageName : "\(imageName)-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + gradientFallback(icon: icon) + #endif + HStack(spacing: 8) { + #if SIMPLEX_ASSETS + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundColor(theme.colors.primary) + #endif + Text(title) + .font(.footnote) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(height: 20) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(ToolbarMaterial.material(toolbarMaterial)) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func gradientFallback(icon: String) -> some View { + let gp = OnboardingCardView.gradientPoints( + aspectRatio: 1 / bannerImageRatio, + scale: colorScheme == .light ? 1.2 : 1.5 + ) + ZStack { + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image(systemName: icon) + .font(.system(size: 40)) + .foregroundColor(theme.colors.primary) + } + .aspectRatio(bannerImageRatio, contentMode: .fit) + .frame(maxWidth: .infinity) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index f119beec50..3c33546436 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat @@ -28,45 +29,82 @@ enum UserProfileAlert: Identifiable { let MAX_BIO_LENGTH_BYTES = 160 struct CreateProfile: View { + @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme @State private var displayName: String = "" @State private var profileBio: String = "" @FocusState private var focusDisplayName @State private var alert: UserProfileAlert? + @State private var showChooseSource = false + @State private var showImagePicker = false + @State private var showTakePhoto = false + @State private var chosenImage: UIImage? = nil + @State private var profileImage: String? = nil var body: some View { List { + Group { + HStack(spacing: 0) { + Spacer(minLength: 0) + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profileImage, size: 128) + if profileImage != nil { + Button { + profileImage = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } + } + } + + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) + } + .padding(.horizontal, 10) // Offsets transparent space built into 3D asset + Spacer(minLength: 0) + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "create-profile" : "create-profile-light") + .resizable() + .scaledToFit() + .frame(height: 140) + // No trailing spacer — asset image has empty space on the right + #endif + } + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) + Section { - TextField("Enter your name…", text: $displayName) - .focused($focusDisplayName) - TextField("Bio", text: $profileBio) - Button { - createProfile() - } label: { - Label("Create profile", systemImage: "checkmark") + ZStack(alignment: .leading) { + let name = displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + alert = .invalidNameError(validName: mkValidName(name)) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(theme.colors.secondary) + } + TextField("Enter your name…", text: $displayName) + .padding(.leading, 36) + .focused($focusDisplayName) + } + ZStack(alignment: .leading) { + Image(systemName: "pencil").foregroundColor(theme.colors.secondary) + TextField("Bio", text: $profileBio) + .padding(.leading, 36) + } + Button(action: createProfile) { + settingsRow("checkmark", color: theme.colors.primary) { Text("Create profile") } } .disabled(!canCreateProfile(displayName) || !bioFitsLimit()) - } header: { - HStack { - Text("Your profile") - .foregroundColor(theme.colors.secondary) - - let name = displayName.trimmingCharacters(in: .whitespaces) - let validName = mkValidName(name) - if name != validName { - Spacer() - validationErrorIndicator { - alert = .invalidNameError(validName: validName) - } - } else if !bioFitsLimit() { - Spacer() - validationErrorIndicator { - showAlert(NSLocalizedString("Bio too large", comment: "alert title")) - } - } - } - .frame(height: 20) } footer: { VStack(alignment: .leading, spacing: 8) { Text("Your profile is stored on your device and only shared with your contacts.") @@ -74,10 +112,42 @@ struct CreateProfile: View { .foregroundColor(theme.colors.secondary) .frame(maxWidth: .infinity, alignment: .leading) } + .compactSectionSpacing() } .navigationTitle("Create your profile") .modifier(ThemedBackground(grouped: true)) .alert(item: $alert) { a in userProfileAlert(a, $displayName) } + .confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) { + Button("Take picture") { + showTakePhoto = true + } + Button("Choose from library") { + showImagePicker = true + } + } + .fullScreenCover(isPresented: $showTakePhoto) { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + CameraImagePicker(image: $chosenImage) + } + } + .sheet(isPresented: $showImagePicker) { + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { + showImagePicker = false + } + } + } + .onChange(of: chosenImage) { image in + Task { + let resized: String? = if let image { + await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) + } else { + nil + } + await MainActor.run { profileImage = resized } + } + } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { focusDisplayName = true @@ -85,14 +155,6 @@ struct CreateProfile: View { } } - private func validationErrorIndicator(_ onTap: @escaping () -> Void) -> some View { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .onTapGesture { - onTap() - } - } - private func bioFitsLimit() -> Bool { chatJsonLength(profileBio) <= MAX_BIO_LENGTH_BYTES } @@ -103,7 +165,8 @@ struct CreateProfile: View { let profile = Profile( displayName: displayName.trimmingCharacters(in: .whitespaces), fullName: "", - shortDescr: shortDescr + shortDescr: shortDescr, + image: profileImage ) let m = ChatModel.shared do { @@ -132,61 +195,103 @@ struct CreateProfile: View { struct CreateFirstProfile: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme - @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var displayName: String = "" @FocusState private var focusDisplayName @State private var nextStepNavLinkActive = false - + @State private var showMigrateSheet = false var body: some View { - let v = VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .center, spacing: 16) { - Text("Create profile") - .font(.largeTitle) - .bold() - .multilineTextAlignment(.center) - - Text("Your profile is stored on your device and only shared with your contacts.") - .font(.callout) - .foregroundColor(theme.colors.secondary) - .multilineTextAlignment(.center) - } - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity) // Ensures it takes up the full width - .padding(.horizontal, 10) - .onTapGesture { focusDisplayName = false } - - HStack { - let name = displayName.trimmingCharacters(in: .whitespaces) - let validName = mkValidName(name) - ZStack(alignment: .trailing) { - TextField("Enter your name…", text: $displayName) - .focused($focusDisplayName) - .padding(.horizontal) - .padding(.trailing, 20) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(uiColor: .tertiarySystemFill)) + let spacing: CGFloat = 10 + let topPadding: CGFloat = 8 + let padding: CGFloat = 25 + GeometryReader { g in + let v = ScrollView { + VStack(alignment: .center, spacing: spacing) { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "your-profile" : "your-profile-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + ZStack { + let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5) + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end ) - if name != validName { - Button { - showAlert(.invalidNameError(validName: validName)) - } label: { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.horizontal, 10) - } + Image(systemName: "person.crop.rectangle") + .font(.system(size: 72)) + .foregroundColor(theme.colors.primary) } + .aspectRatio(1.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .padding(.horizontal, 25) + .frame(maxWidth: .infinity) + #endif + + Text("Your profile") + .font(.largeTitle) + .bold() + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text("On your phone, not on servers.") + .font(.title3) + .fontWeight(.medium) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text("No account. No phone. No email. No ID.\nThe most secure encryption.") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + profileNameField() + .padding(.top) + .padding(.bottom, 5) + + Spacer(minLength: 0) + + createProfileButton() + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, padding) + .padding(.top, topPadding) + .padding(.bottom, padding) + .frame(minHeight: g.size.height) + } + .onTapGesture { focusDisplayName = false } + .sheet(isPresented: $showMigrateSheet, onDismiss: { m.migrationState = nil }) { + NavigationView { + MigrateToDevice(migrationState: $m.migrationState) + .navigationTitle("Migrate here") + .modifier(ThemedBackground(grouped: true)) } } - .padding(.top) - - Spacer() - - VStack(spacing: 10) { - createProfileButton() - if !focusDisplayName { - onboardingButtonPlaceholder() + if #available(iOS 17, *) { + v.scrollBounceBehavior(.basedOnSize).defaultScrollAnchor(.bottom) + } else if #available(iOS 16.4, *) { + v.scrollBounceBehavior(.basedOnSize) + } else { + v + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + if m.migrationState == nil { + m.migrationState = .pasteOrScanLink + } + showMigrateSheet = true + } label: { + HStack(spacing: 4) { + Image(systemName: "tray.and.arrow.down") + Text("Migrate") + .fontWeight(.medium) + } } } } @@ -194,23 +299,40 @@ struct CreateFirstProfile: View { if #available(iOS 16, *) { focusDisplayName = true } else { - // it does not work before animation completes on iOS 15 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { focusDisplayName = true } } } - .padding(.horizontal, 25) - .padding(.bottom, 25) - .frame(maxWidth: .infinity, alignment: .leading) - if #available(iOS 16, *) { - return v.padding(.top, 10) - } else { - return v.padding(.top, 75).ignoresSafeArea(.all, edges: .top) + .frame(maxHeight: .infinity) + } + + private func profileNameField() -> some View { + let name = displayName.trimmingCharacters(in: .whitespaces) + let validName = mkValidName(name) + return ZStack(alignment: .trailing) { + TextField("Enter profile name...", text: $displayName) + .focused($focusDisplayName) + .padding(.horizontal) + .padding(.trailing, name != validName ? 20 : 0) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(uiColor: .tertiarySystemFill)) + ) + if name != validName { + Button { + showAlert(.invalidNameError(validName: validName)) + } label: { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + .padding(.horizontal, 10) + } + } } } - func createProfileButton() -> some View { + private func createProfileButton() -> some View { ZStack { Button { createProfile() @@ -235,7 +357,7 @@ struct CreateFirstProfile: View { } private func nextStepDestinationView() -> some View { - OnboardingConditionsView() + YourNetworkView() .navigationBarBackButtonHidden(true) .modifier(ThemedBackground()) } diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 03b0fcba1a..ab84bed7df 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.04.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import Contacts diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index 7452d74e91..e9b9c6b970 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -5,10 +5,11 @@ // Created by Evgeny on 08/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI -struct HowItWorks: View { +struct OldHowItWorks: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var m: ChatModel var onboarding: Bool @@ -27,7 +28,7 @@ struct HowItWorks: View { Text("Only client devices store user profiles, contacts, groups, and messages.") Text("All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages.") if !onboarding { - Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).") + ExternalLink("Read more in our GitHub repository.", destination: URL(string: "https://github.com/simplex-chat/simplex-chat#readme")!) } } .padding(.bottom) @@ -60,9 +61,57 @@ struct HowItWorks: View { } } -struct HowItWorks_Previews: PreviewProvider { +struct WhySimpleX: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var m: ChatModel + var onboarding: Bool + @Binding var createProfileNavLinkActive: Bool + + var body: some View { + VStack(alignment: .leading) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("You were born without an account") + .font(.title) + .bold() + .padding(.top) + Text("Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life.") + Text("Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible.") + Text("There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected.") + Text("Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign.") + Text("Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public.") + Text("The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it.") + Text("Because we destroyed the power to know who you are. So that your power can never be taken.") + Text("Be free in your network.") + } + } + .padding(.bottom, 16) + + Spacer() + + if onboarding { + createFirstProfileButton() + } + } + .padding(onboarding ? 25 : 16) + .frame(maxHeight: .infinity, alignment: .top) + .modifier(ThemedBackground()) + } + + private func createFirstProfileButton() -> some View { + Button { + dismiss() + createProfileNavLinkActive = true + } label: { + Text("Get started") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + } +} + +struct WhySimpleX_Previews: PreviewProvider { static var previews: some View { - HowItWorks( + WhySimpleX( onboarding: true, createProfileNavLinkActive: Binding.constant(false) ) diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 8f448dc508..39ccabce04 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -5,9 +5,11 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI +// Spec: spec/client/navigation.md#OnboardingView struct OnboardingView: View { var onboarding: OnboardingStage @@ -17,17 +19,18 @@ struct OnboardingView: View { case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) .modifier(ThemedBackground()) - case .step2_CreateProfile: // deprecated + case .step2_CreateProfile: CreateFirstProfile() .modifier(ThemedBackground()) case .step3_CreateSimpleXAddress: // deprecated CreateSimpleXAddress() - case .step3_ChooseServerOperators: - OnboardingConditionsView() + case .step3_ChooseServerOperators, + .step4_SetNotificationsMode: // deprecated + YourNetworkView() .navigationBarBackButtonHidden(true) .modifier(ThemedBackground()) - case .step4_SetNotificationsMode: - SetNotificationsMode() + case .step4_NetworkCommitments: + OnboardingConditionsView(selectedOperatorIds: Set(ChatModel.shared.conditions.serverOperators.filter { $0.enabled }.map { $0.operatorId })) .navigationBarBackButtonHidden(true) .modifier(ThemedBackground()) case .onboardingComplete: EmptyView() @@ -40,12 +43,14 @@ func onboardingButtonPlaceholder() -> some View { Spacer().frame(height: 40) } +// Spec: spec/client/navigation.md#onboardingStage enum OnboardingStage: String, Identifiable { case step1_SimpleXInfo - case step2_CreateProfile // deprecated + case step2_CreateProfile case step3_CreateSimpleXAddress // deprecated - case step3_ChooseServerOperators // changed to simplified conditions - case step4_SetNotificationsMode + case step3_ChooseServerOperators + case step4_SetNotificationsMode // deprecated + case step4_NetworkCommitments case onboardingComplete public var id: Self { self } diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 31865e7af9..1a1f1bb68c 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -5,50 +5,45 @@ // Created by Evgeny on 03/07/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat struct SetNotificationsMode: View { - @EnvironmentObject var m: ChatModel - @State private var notificationMode = NotificationsMode.instant - @State private var showAlert: NotificationAlert? - @State private var showInfo: Bool = false + @Environment(\.dismiss) var dismiss + @Binding var notificationMode: NotificationsMode + @State private var showInfo = false var body: some View { GeometryReader { g in - let v = ScrollView { + ScrollView { VStack(alignment: .center, spacing: 20) { Text("Push notifications") .font(.largeTitle) .bold() .padding(.top, 25) - - infoText() - + + Button { + showInfo = true + } label: { + Label("How it affects privacy", systemImage: "info.circle") + .font(.headline) + } + Spacer() ForEach(NotificationsMode.values) { mode in NtfModeSelector(mode: mode, selection: $notificationMode) } - + Spacer() - + VStack(spacing: 10) { Button { - if let token = m.deviceToken { - setNotificationsMode(token, notificationMode) - } else { - AlertManager.shared.showAlertMsg(title: "No device token!") - } - onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete + dismiss() } label: { - if case .off = notificationMode { - Text("Use chat") - } else { - Text("Enable notifications") - } + Text("OK") } .buttonStyle(OnboardingButtonStyle()) onboardingButtonPlaceholder() @@ -57,50 +52,11 @@ struct SetNotificationsMode: View { .padding(25) .frame(minHeight: g.size.height) } - if #available(iOS 16.4, *) { - v.scrollBounceBehavior(.basedOnSize) - } else { - v - } } .frame(maxHeight: .infinity) .sheet(isPresented: $showInfo) { NotificationsInfoView() } - .navigationBarHidden(true) // necessary on iOS 15 - } - - private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { - switch mode { - case .off: - m.tokenStatus = .new - m.notificationMode = .off - default: - Task { - do { - let status = try await apiRegisterToken(token: token, notificationMode: mode) - await MainActor.run { - m.tokenStatus = status - m.notificationMode = mode - } - } catch let error { - let a = getErrorAlert(error, "Error enabling notifications") - AlertManager.shared.showAlertMsg( - title: a.title, - message: a.message - ) - } - } - } - } - - private func infoText() -> some View { - Button { - showInfo = true - } label: { - Label("How it affects privacy", systemImage: "info.circle") - .font(.headline) - } } } @@ -179,6 +135,6 @@ struct NotificationsInfoView: View { struct NotificationsModeView_Previews: PreviewProvider { static var previews: some View { - SetNotificationsMode() + SetNotificationsMode(notificationMode: .constant(.instant)) } } diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 9f41a37b1d..15b8e05b5e 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -5,74 +5,91 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat struct SimpleXInfo: View { @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme: ColorScheme - @State private var showHowItWorks = false + @State private var showWhyBuilt = false @State private var createProfileNavLinkActive = false var onboarding: Bool var body: some View { GeometryReader { g in - let v = ScrollView { - VStack(alignment: .leading) { - VStack(alignment: .center, spacing: 10) { - Image(colorScheme == .light ? "logo" : "logo-light") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: g.size.width * 0.67) - .padding(.bottom, 8) - .padding(.leading, 4) - .frame(maxWidth: .infinity, minHeight: 48, alignment: .top) - - Button { - showHowItWorks = true - } label: { - Label("The future of messaging", systemImage: "info.circle") - .font(.headline) - } - } + VStack(alignment: .center, spacing: 10) { + Image(colorScheme == .light ? "logo" : "logo-light") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: (g.size.width - 50) * 0.55) + .padding(.leading, 4) + .frame(maxWidth: .infinity, minHeight: 48, alignment: .top) - Spacer() + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "intro" : "intro-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + ZStack { + let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5) + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 72)) + .foregroundColor(theme.colors.primary) + } + .aspectRatio(1.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .padding(.horizontal, 25) + .frame(maxWidth: .infinity) + #endif - VStack(alignment: .leading) { - onboardingInfoRow("privacy", "Privacy redefined", - "No user identifiers.", width: 48) - onboardingInfoRow("shield", "Immune to spam", - "You decide who can connect.", width: 46) - onboardingInfoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized", - "Anybody can host servers.", width: 46) - } - .padding(.leading, 16) + Text("Be free\nin your network") + .font(.largeTitle) + .bold() + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) - Spacer() + Text("Private and secure messaging.") + .font(.title3) + .fontWeight(.medium) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) - if onboarding { - VStack(spacing: 10) { - createFirstProfileButton() + Text("The first network where you own\nyour contacts and groups.") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) - Button { - m.migrationState = .pasteOrScanLink - } label: { - Label("Migrate from another device", systemImage: "tray.and.arrow.down") - .font(.system(size: 17, weight: .semibold)) - .frame(minHeight: 40) - } - .frame(maxWidth: .infinity) - } + if onboarding { + Spacer(minLength: 0) + + createFirstProfileButton() + .padding(.vertical, 10) + + Button { + showWhyBuilt = true + } label: { + Label("Why SimpleX is built.", systemImage: "info.circle") + .font(.headline) } } - .padding(.horizontal, 25) - .padding(.top, 75) - .padding(.bottom, 25) - .frame(minHeight: g.size.height) } + .padding(.horizontal, 25) + .padding(.top, 28) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) .sheet(isPresented: Binding( - get: { m.migrationState != nil }, + get: { m.migrationState != nil && !createProfileNavLinkActive }, set: { _ in m.migrationState = nil MigrationToDeviceState.save(nil) } @@ -85,17 +102,12 @@ struct SimpleXInfo: View { .modifier(ThemedBackground(grouped: true)) } } - .sheet(isPresented: $showHowItWorks) { - HowItWorks( + .sheet(isPresented: $showWhyBuilt) { + WhySimpleX( onboarding: onboarding, createProfileNavLinkActive: $createProfileNavLinkActive ) } - if #available(iOS 16.4, *) { - v.scrollBounceBehavior(.basedOnSize) - } else { - v - } } .onAppear() { setLastVersionDefault() @@ -104,32 +116,12 @@ struct SimpleXInfo: View { .navigationBarHidden(true) // necessary on iOS 15 } - private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View { - HStack(alignment: .top) { - Image(image) - .resizable() - .scaledToFit() - .frame(width: width, height: 54) - .frame(width: 54) - .padding(.trailing, 10) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(text).frame(minHeight: 40, alignment: .top) - .font(.callout) - .lineLimit(3) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.top, 4) - } - .padding(.bottom, 12) - } - private func createFirstProfileButton() -> some View { ZStack { Button { createProfileNavLinkActive = true } label: { - Text("Create your profile") + Text("Get started") } .buttonStyle(OnboardingButtonStyle(isDisabled: false)) diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 916e3f9e78..41a342d7c8 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 24/12/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat @@ -631,6 +632,38 @@ private let versionDescriptions: [VersionDescription] = [ )) ] ), + VersionDescription( + version: "v6.5", + post: URL(string: "https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html"), + features: [ + .feature(Description( + icon: nil, + title: "Public channels - speak freely 🚀", + description: nil, + subfeatures: [ + ("antenna.radiowaves.left.and.right", "Reliability: many relays per channel."), + ("server.rack", "Ownership: you can run your own relays."), + ("key.2.on.ring", "Security: owners hold channel keys."), + ("person.badge.shield.checkmark", "Privacy: for owners and subscribers."), + ] + )), + .feature(Description( + icon: "link.badge.plus", + title: "Easier to invite your friends 👋", + description: "We made connecting simpler for new users." + )), + .feature(Description( + icon: "network.badge.shield.half.filled", + title: "Safe web links", + description: "- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking." + )), + .feature(Description( + icon: "network", + title: "Non-profit governance", + description: "To make SimpleX Network last." + )) + ] + ), ] private let lastVersion = versionDescriptions.last!.version @@ -758,7 +791,7 @@ struct WhatsNewView: View { } } if let post = v.post { - Link(destination: post) { + ExternalLink(destination: post) { HStack { Text("Read more") Image(systemName: "arrow.up.right.circle") diff --git a/apps/ios/Shared/Views/Onboarding/YourNetwork.swift b/apps/ios/Shared/Views/Onboarding/YourNetwork.swift new file mode 100644 index 0000000000..d3727e196e --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/YourNetwork.swift @@ -0,0 +1,193 @@ +// +// YourNetwork.swift +// SimpleX (iOS) +// +// Created by Evgeny on 22/04/2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private enum YourNetworkSheet: Identifiable { + case configureOperators + case configureNotifications + + var id: String { + switch self { + case .configureOperators: return "configureOperators" + case .configureNotifications: return "configureNotifications" + } + } +} + +struct YourNetworkView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + @State private var serverOperators: [ServerOperator] = [] + @State private var selectedOperatorIds = Set() + @State private var notificationMode: NotificationsMode = .instant + @State private var sheetItem: YourNetworkSheet? = nil + @State private var nextStepNavLinkActive = false + @State private var justOpened = true + + var body: some View { + GeometryReader { g in + VStack(alignment: .center, spacing: 10) { + Spacer(minLength: 0) + + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "your-network" : "your-network-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + ZStack { + let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5) + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image(systemName: "network") + .font(.system(size: 72)) + .foregroundColor(theme.colors.primary) + } + .aspectRatio(1.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .padding(.horizontal, 25) + .frame(maxWidth: .infinity) + #endif + + Text("Your network") + .font(.largeTitle) + .bold() + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 15) + + Text("Network routers cannot know\nwho talks to whom") + .font(.title3) + .fontWeight(.medium) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 20) { + configureRoutersButton() + configureNotificationsButton() + } + .padding(.top, 15) + .padding(.bottom, 15) + + Spacer(minLength: 0) + + continueButton() + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 8) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + .onAppear { + if justOpened { + serverOperators = ChatModel.shared.conditions.serverOperators + selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) + justOpened = false + } + } + .sheet(item: $sheetItem) { item in + switch item { + case .configureOperators: + ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds) + .modifier(ThemedBackground()) + case .configureNotifications: + SetNotificationsMode(notificationMode: $notificationMode) + .modifier(ThemedBackground()) + } + } + .frame(maxHeight: .infinity) + .navigationBarHidden(true) + } + + private func configureRoutersButton() -> some View { + Button { + sheetItem = .configureOperators + } label: { + HStack(spacing: 6) { + Text("Setup routers") + .fontWeight(.medium) + ForEach(serverOperators.reversed()) { op in + Image(op.logo(colorScheme)) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .grayscale(selectedOperatorIds.contains(op.operatorId) ? 0.0 : 1.0) + } + } + } + } + + private func configureNotificationsButton() -> some View { + Button { + sheetItem = .configureNotifications + } label: { + HStack(spacing: 4) { + Text("Setup notifications") + .fontWeight(.medium) + Image(systemName: notificationMode.icon) + } + } + } + + private func continueButton() -> some View { + ZStack { + Button { + applyNotificationMode() + onboardingStageDefault.set(.step4_NetworkCommitments) + nextStepNavLinkActive = true + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle()) + + NavigationLink(isActive: $nextStepNavLinkActive) { + OnboardingConditionsView(selectedOperatorIds: selectedOperatorIds) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func applyNotificationMode() { + let m = ChatModel.shared + if let token = m.deviceToken { + switch notificationMode { + case .off: + m.tokenStatus = .new + m.notificationMode = .off + default: + Task { + do { + let status = try await apiRegisterToken(token: token, notificationMode: notificationMode) + await MainActor.run { + m.tokenStatus = status + m.notificationMode = notificationMode + } + } catch let error { + let a = getErrorAlert(error, "Error enabling notifications") + AlertManager.shared.showAlertMsg( + title: a.title, + message: a.message + ) + } + } + } + } + } +} diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 44e0b20958..3554ce720f 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -38,6 +38,7 @@ extension AppSettings { privacyLinkPreviewsGroupDefault.set(val) def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } + if let val = privacySanitizeLinks { privacySanitizeLinksGroupDefault.set(val) } 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) } @@ -76,7 +77,8 @@ extension AppSettings { c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get() c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() - c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + c.privacyLinkPreviews = privacyLinkPreviewsGroupDefault.get() + c.privacySanitizeLinks = privacySanitizeLinksGroupDefault.get() 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) diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 02dec5a618..54a60eed19 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 03/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/theme.md import SwiftUI import SimpleXChat @@ -21,6 +22,7 @@ let darkThemesWithoutBlackNames: [String] = [DefaultTheme.DARK.themeName, Defaul let appSettingsURL = URL(string: UIApplication.openSettingsURLString)! +// Spec: spec/services/theme.md#AppearanceSettings struct AppearanceSettings: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @@ -313,6 +315,7 @@ struct AppearanceSettings: View { } } +// Spec: spec/services/theme.md#ToolbarMaterial enum ToolbarMaterial: String, CaseIterable { case bar case ultraThin @@ -596,6 +599,7 @@ struct CustomizeThemeView: View { } } +// Spec: spec/services/theme.md#ImportExportThemeSection struct ImportExportThemeSection: View { @EnvironmentObject var theme: AppTheme @Binding var showFileImporter: Bool @@ -632,6 +636,7 @@ struct ImportExportThemeSection: View { } } +// Spec: spec/services/theme.md#ThemeImporter struct ThemeImporter: ViewModifier { @Binding var isPresented: Bool var save: (ThemeOverrides) -> Void @@ -1141,6 +1146,7 @@ private func removeUserThemeModeOverrides(_ themeUserDestination: Binding<(Int64 wallpaperFilesToDelete.forEach(removeWallpaperFile) } +// Spec: spec/services/theme.md#decodeYAML private func decodeYAML(_ string: String) -> T? { do { return try YAMLDecoder().decode(T.self, from: string) @@ -1150,6 +1156,7 @@ private func decodeYAML(_ string: String) -> T? { } } +// Spec: spec/services/theme.md#encodeThemeOverrides private func encodeThemeOverrides(_ value: ThemeOverrides) throws -> String { let encoder = YAMLEncoder() encoder.options = YAMLEncoder.Options(sequenceStyle: .block, mappingStyle: .block, newLineScalarStyle: .doubleQuoted) diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 6df2d5422e..a504b00116 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -22,14 +22,16 @@ struct DeveloperView: View { VStack { List { Section { - ZStack(alignment: .leading) { - Image(colorScheme == .dark ? "github_light" : "github") - .resizable() - .frame(width: 24, height: 24) - .opacity(0.5) - .colorMultiply(theme.colors.secondary) - Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") - .padding(.leading, 36) + ExternalLink(destination: URL(string: "https://github.com/simplex-chat/simplex-chat")!) { + ZStack(alignment: .leading) { + Image(colorScheme == .dark ? "github_light" : "github") + .resizable() + .frame(width: 24, height: 24) + .opacity(0.5) + .colorMultiply(theme.colors.secondary) + Text("Install SimpleX Chat for terminal") + .padding(.leading, 36) + } } NavigationLink { TerminalView() @@ -91,6 +93,11 @@ struct DeveloperView: View { UserDefaults.standard.set(val, forKey: def) } } + for def in hintGroupDefaults { + if let val = groupAppDefaults[def] as? Bool { + groupDefaults.set(val, forKey: def) + } + } hintsUnchanged = true } } @@ -98,6 +105,8 @@ struct DeveloperView: View { private func hintDefaultsUnchanged() -> Bool { hintDefaults.allSatisfy { def in appDefaults[def] as? Bool == UserDefaults.standard.bool(forKey: def) + } && hintGroupDefaults.allSatisfy { def in + groupAppDefaults[def] as? Bool == groupDefaults.bool(forKey: def) } } diff --git a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift index d9862aaac8..f74516c2c8 100644 --- a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift +++ b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift @@ -23,7 +23,7 @@ struct IncognitoHelp: View { Text("Incognito mode protects your privacy by using a new random profile for each contact.") Text("It allows having many anonymous connections without any shared data between them in a single chat profile.") Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.") - Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).") + ExternalLink("Read more in User Guide.", destination: URL(string: "https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode")!) } .listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 3a536c7b17..74d38b050b 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 02/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift new file mode 100644 index 0000000000..4a5cbab184 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift @@ -0,0 +1,403 @@ +// +// ChatRelayView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 23.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// +// Spec: spec/architecture.md + +import SwiftUI +import SimpleXChat + +@ViewBuilder func showRelayTestStatus(relay: UserChatRelay) -> some View { + switch relay.tested { + case .some(true): Image(systemName: "checkmark").foregroundColor(.green) + case .some(false): Image(systemName: "multiply").foregroundColor(.red) + case .none: Color.clear + } +} + +func validRelayName(_ name: String) -> Bool { + name != "" && validDisplayName(name) +} + +func showInvalidRelayNameAlert(_ name: Binding) { + let validName = mkValidName(name.wrappedValue) + if validName == "" { + showAlert(NSLocalizedString("Invalid name!", comment: "alert title")) + } else { + showAlert( + NSLocalizedString("Invalid name!", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName), + actions: {[ + UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in + name.wrappedValue = validName + }, + cancelAlertAction + ]} + ) + } +} + +func validRelayAddress(_ address: String) -> Bool { + if let parsedMd = parseSimpleXMarkdown(address), + parsedMd.count == 1, + case .simplexLink(_, .relay, _, _) = parsedMd.first?.format { + true + } else { + false + } +} + +func addChatRelay( + _ relay: UserChatRelay, + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil, + _ dismiss: DismissAction +) { + let nameEmpty = relay.displayName.trimmingCharacters(in: .whitespaces).isEmpty + let addressEmpty = relay.address.trimmingCharacters(in: .whitespaces).isEmpty + if nameEmpty && addressEmpty { + dismiss() + } else if !validRelayName(relay.displayName) { + dismiss() + showAlert( + NSLocalizedString("Invalid relay name!", comment: "alert title"), + message: NSLocalizedString("Check relay name and try again.", comment: "alert message") + ) + } else if !validRelayAddress(relay.address) { + dismiss() + showAlert( + NSLocalizedString("Invalid relay address!", comment: "alert title"), + message: NSLocalizedString("Check relay address and try again.", comment: "alert message") + ) + } else if let i = userServers.wrappedValue.firstIndex(where: { $0.operator == nil }) { + userServers[i].wrappedValue.chatRelays.append(relay) + validateServers_(userServers, serverErrors, serverWarnings) + dismiss() + } else { // Shouldn't happen + dismiss() + showAlert(NSLocalizedString("Error adding relay", comment: "alert title")) + } +} + +struct ChatRelayView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @Binding var relay: UserChatRelay + @State var relayToEdit: UserChatRelay + var backLabel: LocalizedStringKey + @State private var showTestFailure = false + @State private var testing = false + @State private var testFailure: RelayTestFailure? + + var body: some View { + let validName = validRelayName(relayToEdit.displayName) + let validAddress = validRelayAddress(relayToEdit.address) + ZStack { + if relay.preset { + presetRelay() + } else { + customRelay(validName: validName, validAddress: validAddress) + } + if testing { + ProgressView().scaleEffect(2) + } + } + .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) { + if validName && validAddress { + relay = relayToEdit + validateServers_($userServers, $serverErrors, $serverWarnings) + dismiss() + } else if !validName { + dismiss() + showAlert( + NSLocalizedString("Invalid relay name!", comment: "alert title"), + message: NSLocalizedString("Check relay name and try again.", comment: "alert message") + ) + } else { + dismiss() + showAlert( + NSLocalizedString("Invalid relay address!", comment: "alert title"), + message: NSLocalizedString("Check relay address and try again.", comment: "alert message") + ) + } + }) + .alert(isPresented: $showTestFailure) { + Alert( + title: Text("Relay test failed!"), + message: Text(testFailure?.localizedDescription ?? "") + ) + } + .onChange(of: relayToEdit.address) { _ in + if relayToEdit.address == relay.address { + relayToEdit.tested = relay.tested + relayToEdit.displayName = relay.displayName + } else { + relayToEdit.tested = nil + } + } + } + + private func relayNameHeader(validName: Bool) -> some View { + HStack { + Text("Your relay name").foregroundColor(theme.colors.secondary) + if !validName { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + .onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) } + } + } + } + + private func presetRelay() -> some View { + List { + Section(header: Text("Preset relay address").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.address) + .textSelection(.enabled) + } + Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.displayName) + } + useRelaySection() + } + } + + private func customRelay(validName: Bool, validAddress: Bool) -> some View { + List { + Section { + TextEditor(text: $relayToEdit.address) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .allowsTightening(true) + .lineLimit(10) + .frame(height: 144) + .padding(-6) + } header: { + HStack { + Text("Your relay address") + .foregroundColor(theme.colors.secondary) + if !validAddress { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } + Section { + TextField("Enter relay name…", text: $relayToEdit.displayName) + .autocorrectionDisabled(true) + .disabled(relayToEdit.tested == true) + } header: { + relayNameHeader(validName: validName) + } footer: { + if relayToEdit.tested != true { + Text("**Test relay** to retrieve its name.") + } + } + useRelaySection(valid: validAddress) + Section { + Button(role: .destructive) { + relay.deleted = true + validateServers_($userServers, $serverErrors, $serverWarnings) + dismiss() + } label: { + Label("Delete relay", systemImage: "trash") + .foregroundColor(.red) + } + } + } + } + + private func useRelaySection(valid: Bool = true) -> some View { + Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test relay") { + testing = true + relayToEdit.tested = nil + Task { + if let f = await testRelayConnection(relay: $relayToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } + } + .disabled(!valid || testing) + Spacer() + showRelayTestStatus(relay: relayToEdit) + } + Toggle("Use for new channels", isOn: $relayToEdit.enabled) + } + } +} + +struct ChatRelayViewLink: View { + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @Binding var relay: UserChatRelay + var duplicateRelayAddresses: Set + var backLabel: LocalizedStringKey + @Binding var selectedServer: String? + + var body: some View { + NavigationLink(tag: relay.id, selection: $selectedServer) { + ChatRelayView( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: $relay, + relayToEdit: relay, + backLabel: backLabel + ) + .navigationBarTitle("Chat relay") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Group { + if duplicateRelayAddresses.contains(relay.address) { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } else if !relay.enabled { + Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) + } else { + showRelayTestStatus(relay: relay) + } + } + .frame(width: 16, alignment: .center) + .padding(.trailing, 4) + + let displayName = !relay.displayName.isEmpty ? relay.displayName : relay.domains.first ?? relay.address + let v = Text(displayName).lineLimit(1) + if relay.enabled { + v + } else { + v.foregroundColor(theme.colors.secondary) + } + } + } + } +} + +struct NewChatRelayView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @State private var relayToEdit = UserChatRelay( + chatRelayId: nil, address: "", name: "", domains: [], + preset: false, tested: nil, enabled: true, deleted: false + ) + @State private var showTestFailure = false + @State private var testing = false + @State private var testFailure: RelayTestFailure? + + var body: some View { + let validName = validRelayName(relayToEdit.displayName) + let validAddress = validRelayAddress(relayToEdit.address) + ZStack { + List { + Section { + TextEditor(text: $relayToEdit.address) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .allowsTightening(true) + .lineLimit(10) + .frame(height: 144) + .padding(-6) + } header: { + HStack { + Text("Your relay address") + .foregroundColor(theme.colors.secondary) + if !validAddress { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } + Section { + TextField("Enter relay name…", text: $relayToEdit.displayName) + .autocorrectionDisabled(true) + .disabled(relayToEdit.tested == true) + } header: { + HStack { + Text("Your relay name").foregroundColor(theme.colors.secondary) + if !validName { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + .onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) } + } + } + } footer: { + if relayToEdit.tested != true { + Text("**Test relay** to retrieve its name.") + } + } + Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test relay") { + testing = true + relayToEdit.tested = nil + Task { + if let f = await testRelayConnection(relay: $relayToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } + } + .disabled(!validAddress || testing) + Spacer() + showRelayTestStatus(relay: relayToEdit) + } + Toggle("Use for new channels", isOn: $relayToEdit.enabled) + } + } + if testing { + ProgressView().scaleEffect(2) + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + addChatRelay(relayToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) + }) + .alert(isPresented: $showTestFailure) { + Alert( + title: Text("Relay test failed!"), + message: Text(testFailure?.localizedDescription ?? "") + ) + } + .onChange(of: relayToEdit.address) { _ in + relayToEdit.tested = nil + } + } +} + +func testRelayConnection(relay: Binding) async -> RelayTestFailure? { + do { + let (relayProfile, testFailure) = try await testChatRelay(address: relay.wrappedValue.address) + if let f = testFailure { + await MainActor.run { relay.wrappedValue.tested = false } + return f + } + await MainActor.run { + relay.wrappedValue.tested = true + if let relayProfile { + relay.wrappedValue.displayName = relayProfile.displayName + } + } + return nil + } catch { + logger.error("testRelayConnection \(responseError(error))") + await MainActor.run { relay.wrappedValue.tested = false } + return nil + } +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift index 1e38b7d5ec..5abbbf8d2e 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift @@ -5,6 +5,7 @@ // Created by Stanislav Dmitrenko on 26.11.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import WebKit @@ -70,11 +71,7 @@ struct ConditionsWebView: UIViewRepresentable { switch navigationAction.navigationType { case .linkActivated: decisionHandler(.cancel) - if url.absoluteString.starts(with: "https://simplex.chat/contact#") { - ChatModel.shared.appOpenUrl = url - } else { - UIApplication.shared.open(url) - } + openExternalLink(url) default: decisionHandler(.allow) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 6f4710396a..f10b945dc0 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 02/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -77,6 +78,7 @@ struct NetworkAndServers: View { YourServersView( userServers: $ss.servers.userServers, serverErrors: $ss.servers.serverErrors, + serverWarnings: $ss.servers.serverWarnings, operatorIndex: idx ) .navigationTitle("Your servers") @@ -114,6 +116,9 @@ struct NetworkAndServers: View { } else if !ss.servers.serverErrors.isEmpty { ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error")) } + if let warnStr = globalServersWarning(ss.servers.serverWarnings) { + ServersWarningView(warnStr: warnStr) + } } Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) { @@ -142,6 +147,8 @@ struct NetworkAndServers: View { ss.servers.currUserServers = try await getUserServers() ss.servers.userServers = ss.servers.currUserServers ss.servers.serverErrors = [] + ss.servers.serverWarnings = [] + validateServers_($ss.servers.userServers, $ss.servers.serverErrors, $ss.servers.serverWarnings) } catch let error { await MainActor.run { showAlert( @@ -185,6 +192,7 @@ struct NetworkAndServers: View { currUserServers: $ss.servers.currUserServers, userServers: $ss.servers.userServers, serverErrors: $ss.servers.serverErrors, + serverWarnings: $ss.servers.serverWarnings, operatorIndex: operatorIndex, useOperator: serverOperator.enabled ) @@ -324,7 +332,7 @@ struct UsageConditionsView: View { @ViewBuilder private func conditionsDiffButton(_ font: Font? = nil) -> some View { let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") { - Link(destination: commitUrl) { + ExternalLink(destination: commitUrl) { HStack { Text("Open changes") Image(systemName: "arrow.up.right.circle") @@ -343,29 +351,19 @@ private func regularConditionsHeader() -> some View { } } -struct SimpleConditionsView: View { - var body: some View { - VStack(alignment: .leading, spacing: 20) { - regularConditionsHeader() - .padding(.top) - .padding(.top) - ConditionsTextView() - .padding(.bottom) - .padding(.bottom) - } - .padding(.horizontal, 25) - .frame(maxHeight: .infinity) - } -} - -func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { +func validateServers_( + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil +) { let userServersToValidate = userServers.wrappedValue Task { do { - let errs = try await validateServers(userServers: userServersToValidate) + let (errs, warns) = try await validateServers(userServers: userServersToValidate) await MainActor.run { serverErrors.wrappedValue = errs + serverWarnings?.wrappedValue = warns } } catch let error { logger.error("validateServers error: \(responseError(error))") @@ -395,6 +393,20 @@ struct ServersErrorView: View { } } +struct ServersWarningView: View { + @EnvironmentObject var theme: AppTheme + var warnStr: String + + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text(warnStr) + .foregroundColor(theme.colors.secondary) + } + } +} + func globalServersError(_ serverErrors: [UserServersError]) -> String? { for err in serverErrors { if let errStr = err.globalError { @@ -404,6 +416,29 @@ func globalServersError(_ serverErrors: [UserServersError]) -> String? { return nil } +func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? { + for warn in serverWarnings { + switch warn { + case let .noChatRelays(user): + let text = NSLocalizedString("No chat relays enabled.", comment: "servers warning") + if let user = user { + return String.localizedStringWithFormat( + NSLocalizedString("For chat profile %@:", comment: "servers warning"), + user.localDisplayName + ) + " " + text + } else { return text } + } + } + return nil +} + +func bindingForChatRelays(_ userServers: Binding<[UserOperatorServers]>, _ opIndex: Int) -> Binding<[UserChatRelay]> { + Binding( + get: { userServers[opIndex].wrappedValue.chatRelays }, + set: { userServers[opIndex].wrappedValue.chatRelays = $0 } + ) +} + func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? { for err in serverErrors { if let errStr = err.globalSMPError { @@ -433,6 +468,14 @@ func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set { return Set(duplicateHostsList) } +func findDuplicateRelayAddresses(_ serverErrors: [UserServersError]) -> Set { + Set(serverErrors.compactMap { err in + if case let .duplicateChatRelayAddress(_, duplicateAddress) = err { return duplicateAddress } + else { return nil } + }) +} + + func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) { let userServersToSave = userServers.wrappedValue Task { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index c8cb2349e7..0a3c82b4dd 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 13.11.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -14,6 +15,7 @@ struct NewServerView: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] @State private var serverToEdit: UserServer = .empty @State private var showTestFailure = false @State private var testing = false @@ -27,7 +29,7 @@ struct NewServerView: View { } } .modifier(BackButton(disabled: Binding.constant(false)) { - addServer(serverToEdit, $userServers, $serverErrors, dismiss) + addServer(serverToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) }) .alert(isPresented: $showTestFailure) { Alert( @@ -117,6 +119,7 @@ func addServer( _ server: UserServer, _ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil, _ dismiss: DismissAction ) { if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) { @@ -125,7 +128,7 @@ func addServer( case .smp: userServers[i].wrappedValue.smpServers.append(server) case .xftp: userServers[i].wrappedValue.xftpServers.append(server) } - validateServers_(userServers, serverErrors) + validateServers_(userServers, serverErrors, serverWarnings) dismiss() if let op = matchingOperator { showAlert( @@ -151,6 +154,7 @@ func addServer( #Preview { NewServerView( userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), - serverErrors: Binding.constant([]) + serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]) ) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index afbccc109c..26f24f2f0f 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.10.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -18,6 +19,7 @@ struct OperatorView: View { @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int @State var useOperator: Bool @State private var useOperatorToggleReset: Bool = false @@ -40,6 +42,7 @@ struct OperatorView: View { private func operatorView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) + let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors) return VStack { List { Section { @@ -51,6 +54,8 @@ struct OperatorView: View { } footer: { if let errStr = globalServersError(serverErrors) { ServersErrorView(errStr: errStr) + } else if let warnStr = globalServersWarning(serverWarnings) { + ServersWarningView(warnStr: warnStr) } else { switch (userServers[operatorIndex].operator_.conditionsAcceptance) { case let .accepted(acceptedAt, _): @@ -68,15 +73,37 @@ struct OperatorView: View { } if userServers[operatorIndex].operator_.enabled { + if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty { + Section { + ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in + if !relay.wrappedValue.deleted { + ChatRelayViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: relay, + duplicateRelayAddresses: duplicateRelayAddresses, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { EmptyView() } + } + } header: { + Text("Chat relays").foregroundColor(theme.colors.secondary) + } footer: { + Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary) + } + } + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage) .onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy) .onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Use for messages") @@ -96,6 +123,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -127,6 +155,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -139,7 +168,7 @@ struct OperatorView: View { } .onDelete { indexSet in deleteSMPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Added message servers") @@ -151,7 +180,7 @@ struct OperatorView: View { Section { Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage) .onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Use for files") @@ -171,6 +200,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -202,6 +232,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -214,7 +245,7 @@ struct OperatorView: View { } .onDelete { indexSet in deleteXFTPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Added media & file servers") @@ -226,6 +257,7 @@ struct OperatorView: View { TestServersButton( smpServers: $userServers[operatorIndex].smpServers, xftpServers: $userServers[operatorIndex].xftpServers, + chatRelays: $userServers[operatorIndex].chatRelays, testing: $testing ) } @@ -245,6 +277,7 @@ struct OperatorView: View { currUserServers: $currUserServers, userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, operatorIndex: operatorIndex ) .modifier(ThemedBackground(grouped: true)) @@ -275,18 +308,18 @@ struct OperatorView: View { switch userServers[operatorIndex].operator_.conditionsAcceptance { case .accepted: userServers[operatorIndex].operator_.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) case let .required(deadline): if deadline == nil { showConditionsSheet = true } else { userServers[operatorIndex].operator_.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } } else { userServers[operatorIndex].operator_.enabled = false - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } } @@ -331,11 +364,15 @@ struct OperatorInfoView: View { Text(d) } } - Link(serverOperator.info.website.absoluteString, destination: serverOperator.info.website) + ExternalLink(destination: serverOperator.info.website) { + Text(serverOperator.info.website.absoluteString) + } } if let selfhost = serverOperator.info.selfhost { Section { - Link(selfhost.text, destination: selfhost.link) + ExternalLink(destination: selfhost.link) { + Text(selfhost.text) + } } } } @@ -399,7 +436,7 @@ struct ConditionsTextView: View { private func conditionsLinkView(_ conditionsLink: String) -> some View { VStack(alignment: .leading, spacing: 20) { Text("Current conditions text couldn't be loaded, you can review conditions via this link:") - Link(destination: URL(string: conditionsLink)!) { + ExternalLink(destination: URL(string: conditionsLink)!) { Text(conditionsLink) .multilineTextAlignment(.leading) } @@ -423,6 +460,7 @@ struct SingleOperatorUsageConditionsView: View { @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int var body: some View { @@ -525,7 +563,7 @@ struct SingleOperatorUsageConditionsView: View { updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators) updateOperatorsConditionsAcceptance($userServers, r.serverOperators) userServers[operatorIndexToEnable].operator?.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) dismiss() } } catch let error { @@ -557,11 +595,11 @@ func conditionsLinkButton() -> some View { let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit let mdUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/\(commit)/PRIVACY.md") ?? conditionsURL return Menu { - Link(destination: mdUrl) { + ExternalLink(destination: mdUrl) { Label("Open conditions", systemImage: "doc") } if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") { - Link(destination: commitUrl) { + ExternalLink(destination: commitUrl) { Label("Open changes", systemImage: "ellipsis") } } @@ -580,6 +618,7 @@ func conditionsLinkButton() -> some View { currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]), operatorIndex: 1, useOperator: ServerOperator.sampleData1.enabled ) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 97bfd360cb..5299b7d415 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 15/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -14,6 +15,7 @@ struct ProtocolServerView: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] @Binding var server: UserServer @State var serverToEdit: UserServer var backLabel: LocalizedStringKey @@ -49,7 +51,7 @@ struct ProtocolServerView: View { ) } else { server = serverToEdit - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) dismiss() } } else { @@ -201,6 +203,7 @@ struct ProtocolServerView_Previews: PreviewProvider { ProtocolServerView( userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]), server: Binding.constant(UserServer.sampleData.custom), serverToEdit: UserServer.sampleData.custom, backLabel: "Your SMP servers" diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index b9737914ec..b059be7cb0 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 15/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -18,10 +19,12 @@ struct YourServersView: View { @Environment(\.editMode) private var editMode @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int @State private var selectedServer: String? = nil @State private var showAddServer = false @State private var newServerNavLinkActive = false + @State private var newChatRelayNavLinkActive = false @State private var showScanProtoServer = false @State private var testing = false @@ -40,7 +43,34 @@ struct YourServersView: View { private func yourServersView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) + let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors) return List { + if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty { + Section { + ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in + if !relay.wrappedValue.deleted { + ChatRelayViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: relay, + duplicateRelayAddresses: duplicateRelayAddresses, + backLabel: "Your servers", + selectedServer: $selectedServer + ) + } else { EmptyView() } + } + .onDelete { indexSet in + deleteChatRelay($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors, $serverWarnings) + } + } header: { + Text("Chat relays").foregroundColor(theme.colors.secondary) + } footer: { + Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary) + } + } + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { ForEach($userServers[operatorIndex].smpServers) { srv in @@ -48,6 +78,7 @@ struct YourServersView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -60,7 +91,7 @@ struct YourServersView: View { } .onDelete { indexSet in deleteSMPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Message servers") @@ -83,6 +114,7 @@ struct YourServersView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -95,7 +127,7 @@ struct YourServersView: View { } .onDelete { indexSet in deleteXFTPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Media & file servers") @@ -124,10 +156,23 @@ struct YourServersView: View { } .frame(width: 1, height: 1) .hidden() + + NavigationLink(isActive: $newChatRelayNavLinkActive) { + NewChatRelayView(userServers: $userServers, serverErrors: $serverErrors, serverWarnings: $serverWarnings) + .navigationTitle("New chat relay") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } footer: { if let errStr = globalServersError(serverErrors) { ServersErrorView(errStr: errStr) + } else if let warnStr = globalServersWarning(serverWarnings) { + ServersWarningView(warnStr: warnStr) } } @@ -135,6 +180,7 @@ struct YourServersView: View { TestServersButton( smpServers: $userServers[operatorIndex].smpServers, xftpServers: $userServers[operatorIndex].xftpServers, + chatRelays: $userServers[operatorIndex].chatRelays, testing: $testing ) howToButton() @@ -143,7 +189,8 @@ struct YourServersView: View { .toolbar { if ( !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty || - !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty + !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty || + !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty ) { EditButton() } @@ -151,11 +198,13 @@ struct YourServersView: View { .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) { Button("Enter server manually") { newServerNavLinkActive = true } Button("Scan server QR code") { showScanProtoServer = true } + Button("Chat relay") { newChatRelayNavLinkActive = true } } .sheet(isPresented: $showScanProtoServer) { ScanProtocolServer( userServers: $userServers, - serverErrors: $serverErrors + serverErrors: $serverErrors, + serverWarnings: $serverWarnings ) .modifier(ThemedBackground(grouped: true)) } @@ -164,7 +213,8 @@ struct YourServersView: View { private func newServerDestinationView() -> some View { NewServerView( userServers: $userServers, - serverErrors: $serverErrors + serverErrors: $serverErrors, + serverWarnings: $serverWarnings ) .navigationTitle("New server") .navigationBarTitleDisplayMode(.large) @@ -173,9 +223,7 @@ struct YourServersView: View { func howToButton() -> some View { Button { - DispatchQueue.main.async { - UIApplication.shared.open(howToUrl) - } + openExternalLink(howToUrl) } label: { HStack { Text("How to use your servers") @@ -189,6 +237,7 @@ struct ProtocolServerViewLink: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var duplicateHosts: Set @Binding var server: UserServer var serverProtocol: ServerProtocol @@ -202,6 +251,7 @@ struct ProtocolServerViewLink: View { ProtocolServerView( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, server: $server, serverToEdit: server, backLabel: backLabel @@ -279,9 +329,27 @@ func deleteXFTPServer( } } +func deleteChatRelay( + _ userServers: Binding<[UserOperatorServers]>, + _ operatorServersIndex: Int, + _ serverIndexSet: IndexSet +) { + if let idx = serverIndexSet.first { + let relay = userServers[operatorServersIndex].wrappedValue.chatRelays[idx] + if relay.chatRelayId == nil { + userServers[operatorServersIndex].wrappedValue.chatRelays.remove(at: idx) + } else { + var updatedRelay = relay + updatedRelay.deleted = true + userServers[operatorServersIndex].wrappedValue.chatRelays[idx] = updatedRelay + } + } +} + struct TestServersButton: View { @Binding var smpServers: [UserServer] @Binding var xftpServers: [UserServer] + @Binding var chatRelays: [UserChatRelay] @Binding var testing: Bool var body: some View { @@ -290,20 +358,24 @@ struct TestServersButton: View { } private var allServersDisabled: Bool { - smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled } + smpServers.allSatisfy { !$0.enabled } && + xftpServers.allSatisfy { !$0.enabled } && + chatRelays.filter({ !$0.deleted }).allSatisfy { !$0.enabled } } private func testServers() { resetTestStatus() testing = true Task { - let fs = await runServersTest() + let rfs = await runRelaysTest() + let sfs = await runServersTest() await MainActor.run { testing = false - if !fs.isEmpty { - let msg = fs.map { (srv, f) in - "\(srv): \(f.localizedDescription)" - }.joined(separator: "\n") + var failures: [String] = [] + failures += rfs.map { (name, f) in "\(name): \(f.localizedDescription)" } + failures += sfs.map { (srv, f) in "\(srv): \(f.localizedDescription)" } + if !failures.isEmpty { + let msg = failures.joined(separator: "\n") showAlert( NSLocalizedString("Tests failed!", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg) @@ -314,6 +386,12 @@ struct TestServersButton: View { } private func resetTestStatus() { + for i in 0.. [String: RelayTestFailure] { + var fs: [String: RelayTestFailure] = [:] + for i in 0.. some View { Button { - DispatchQueue.main.async { - UIApplication.shared.open(howToUrl) - } + openExternalLink(howToUrl) } label: { HStack{ Text("How to") diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index cb6fdf8597..a903329454 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -5,12 +5,13 @@ // Created by Evgeny Poberezkin on 31/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import StoreKit import SimpleXChat -let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")! +let simplexTeamURL = URL(string: "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im")! let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String @@ -149,6 +150,10 @@ let hintDefaults = [ DEFAULT_SHOW_DELETE_CONTACT_NOTICE ] +let hintGroupDefaults = [ + GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT +] + // not used anymore enum ConnectViaLinkTab: String { case scan @@ -394,7 +399,9 @@ struct SettingsView: View { } Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) { - settingsRow("keyboard", color: theme.colors.secondary) { Text("[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)") } + settingsRow("keyboard", color: theme.colors.secondary) { + ExternalLink("Contribute", destination: URL(string: "https://github.com/simplex-chat/simplex-chat#contribute")!) + } settingsRow("star", color: theme.colors.secondary) { Button("Rate the app") { if let scene = sceneDelegate.windowScene { @@ -402,14 +409,16 @@ struct SettingsView: View { } } } - ZStack(alignment: .leading) { - Image(colorScheme == .dark ? "github_light" : "github") - .resizable() - .frame(width: 24, height: 24) - .opacity(0.5) - .colorMultiply(theme.colors.secondary) - Text("[Star on GitHub](https://github.com/simplex-chat/simplex-chat)") - .padding(.leading, indent) + ExternalLink(destination: URL(string: "https://github.com/simplex-chat/simplex-chat")!) { + ZStack(alignment: .leading) { + Image(colorScheme == .dark ? "github_light" : "github") + .resizable() + .frame(width: 24, height: 24) + .opacity(0.5) + .colorMultiply(theme.colors.secondary) + Text("Star on GitHub") + .padding(.leading, indent) + } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift index 6c1ea8deb2..ac6ae05984 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift @@ -31,7 +31,7 @@ struct UserAddressLearnMore: View { .padding(.top) Text("SimpleX address and 1-time links are safe to share via any messenger.") Text("To protect against your link being replaced, you can compare contact security codes.") - Text("Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).") + ExternalLink("Read more in User Guide.", destination: URL(string: "https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses")!) .padding(.top) } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 1e5b4bff16..e22042fa24 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -11,11 +11,13 @@ import MessageUI @preconcurrency import SimpleXChat struct UserAddressView: View { + @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject private var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @State var shareViaProfile = false @State var autoCreate = false + var onboarding: Bool = false @State private var showShortLink = true @State private var settings = AddressSettingsState() @State private var savedSettings = AddressSettingsState() @@ -54,6 +56,14 @@ struct UserAddressView: View { } } } + .if(onboarding) { v in + v.toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Image(systemName: "info.circle").opacity(0) + } + } + .navigationBarTitleDisplayMode(.inline) + } .onAppear { if chatModel.userAddress == nil, autoCreate { createAddress() @@ -64,12 +74,16 @@ struct UserAddressView: View { private func userAddressView() -> some View { List { if let userAddress = chatModel.userAddress { - existingAddressView(userAddress) - .onAppear { - settings = AddressSettingsState(settings: userAddress.addressSettings) - savedSettings = AddressSettingsState(settings: userAddress.addressSettings) - } - } else { + if onboarding { + onboardingAddressView(userAddress) + } else { + existingAddressView(userAddress) + .onAppear { + settings = AddressSettingsState(settings: userAddress.addressSettings) + savedSettings = AddressSettingsState(settings: userAddress.addressSettings) + } + } + } else if !onboarding { Section { createAddressButton() } header: { @@ -121,8 +135,8 @@ struct UserAddressView: View { ) case .shareOnCreate: return Alert( - title: Text("Share address with contacts?"), - message: Text("Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts."), + title: Text("Share address with SimpleX contacts?"), + message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."), primaryButton: .default(Text("Share")) { setProfileAddress($progressIndicator, true) shareViaProfile = true @@ -157,7 +171,19 @@ struct UserAddressView: View { } addressSettingsButton(userAddress) } header: { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light ? "simplex-address-small" : "simplex-address-small-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .padding(.top, -20) + ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) + } + .padding(.bottom, 4) + #else ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) + #endif } footer: { if settings.businessAddress { Text("Add your team members to the conversations.") @@ -184,6 +210,54 @@ struct UserAddressView: View { } } + @ViewBuilder private func onboardingAddressView(_ userAddress: UserContactLink) -> some View { + Section { + HStack(spacing: 8) { + let link = userAddress.connLinkContact.simplexChatUri(short: showShortLink) + linkTextView(link) + Button { showShareSheet(items: [link]) } label: { + Image(systemName: "square.and.arrow.up") + .padding(.top, -7) + .padding(.horizontal, 8) + } + } + .frame(maxWidth: .infinity) + } header: { + #if SIMPLEX_ASSETS + VStack(alignment: .leading) { + Image(colorScheme == .light ? "simplex-address" : "simplex-address-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + Text("Use this address in your social media profile, website, or email signature.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } + .padding(.bottom, 4) + #else + Text("Use this address in your social media profile, website, or email signature.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + .padding(.bottom, 6) + #endif + } + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) + + Section { + SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink) + .id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))") + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } header: { + Text("Or use this QR - print or show online.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } + } + private func createAddressButton() -> some View { Button { createAddress() @@ -196,10 +270,24 @@ struct UserAddressView: View { progressIndicator = true Task { do { - if let connLinkContact = try await apiCreateUserAddress() { - DispatchQueue.main.async { + let connLinkContact = try await apiCreateUserAddress() + DispatchQueue.main.async { + if let connLinkContact { chatModel.userAddress = UserContactLink(connLinkContact) - alert = .shareOnCreate + let hasRelevantContacts = chatModel.chats.contains { chat in + if case let .direct(contact) = chat.chatInfo { + return contact.active && !contact.isContactCard && !contact.contactConnIncognito + } + return false + } + if hasRelevantContacts { + alert = .shareOnCreate + progressIndicator = false + } else { + setProfileAddress($progressIndicator, true) + shareViaProfile = true + } + } else { progressIndicator = false } } @@ -485,15 +573,15 @@ struct UserAddressSettingsView: View { private func shareWithContactsButton() -> some View { settingsRow("person", color: theme.colors.secondary) { - Toggle("Share with contacts", isOn: $shareViaProfile) + Toggle("Share with SimpleX contacts", isOn: $shareViaProfile) .onChange(of: shareViaProfile) { on in if ignoreShareViaProfileChange { ignoreShareViaProfileChange = false } else { if on { showAlert( - NSLocalizedString("Share address with contacts?", comment: "alert title"), - message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"), + NSLocalizedString("Share address with SimpleX contacts?", comment: "alert title"), + message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"), actions: {[ UIAlertAction( title: NSLocalizedString("Cancel", comment: "alert action"), @@ -515,7 +603,7 @@ struct UserAddressSettingsView: View { } else { showAlert( NSLocalizedString("Stop sharing address?", comment: "alert title"), - message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"), + message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"), actions: {[ UIAlertAction( title: NSLocalizedString("Cancel", comment: "alert action"), diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 569b5caf13..2e609c3f7d 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -26,7 +26,7 @@ struct UserProfile: View { var body: some View { List { - EditProfileImage(profileImage: $profile.image, showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $profile.image, iconName: "person.crop.circle.fill", showChooseSource: $showChooseSource) .padding(.top) Section { @@ -178,6 +178,7 @@ struct EditProfileImage: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @Binding var profileImage: String? + var iconName: String @Binding var showChooseSource: Bool var body: some View { @@ -193,7 +194,7 @@ struct EditProfileImage: View { } } else { ZStack(alignment: .center) { - ProfileImage(imageStr: profileImage, size: 160) + ProfileImage(imageStr: profileImage, iconName: iconName, size: 160) editImageButton { showChooseSource = true } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index ddfe59e719..ad3b5cdf95 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -2,6 +2,7 @@ // Created by Avently on 17.01.2023. // Copyright (c) 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index e3ca0e6a43..71a7a427be 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -185,6 +185,21 @@ %d месеца time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d сек. @@ -200,11 +215,53 @@ %d пропуснато(и) съобщение(я) integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d седмици time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +272,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld избран(и) контакт(а) @@ -315,11 +376,19 @@ %u пропуснати съобщения. No comment provided by engineer. + + (from owner) + chat link info line + (new) (ново) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (това устройство v%@) @@ -365,6 +434,10 @@ **Сканирай / Постави линк**: за свързване чрез получения линк. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Внимание**: Незабавните push известия изискват парола, запазена в Keychain. @@ -408,6 +481,12 @@ - и още! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +585,10 @@ time interval Още няколко неща No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact Нов контакт @@ -632,9 +715,8 @@ swipe action Активни връзки No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Добавете адрес към вашия профил, така че вашите контакти да могат да го споделят с други хора. Актуализацията на профила ще бъде изпратена до вашите контакти. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -702,6 +784,10 @@ swipe action Добавени сървъри за съобщения No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent Допълнителен акцент @@ -792,6 +878,10 @@ swipe action Всички членове на групата ще останат свързани. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Всички съобщения и файлове се изпращат с **криптиране от край до край**, с постквантова сигурност в директните съобщения. @@ -817,6 +907,14 @@ swipe action Всички профили profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. Всички доклади за нарушения ще бъдат архивирани за вас. @@ -877,6 +975,10 @@ swipe action Позволи необратимо изтриване на съобщение само ако вашият контакт го рарешава. (24 часа) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Позволи реакции на съобщения само ако вашият контакт ги разрешава. @@ -892,6 +994,10 @@ swipe action Позволи изпращането на лични съобщения до членовете. No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Разреши изпращането на изчезващи съобщения. @@ -902,6 +1008,10 @@ swipe action Позволи споделяне No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Позволи необратимо изтриване на изпратените съобщения. (24 часа) @@ -1007,11 +1117,6 @@ swipe action Отговор на повикване No comment provided by engineer. - - Anybody can host servers. - Протокол и код с отворен код – всеки може да оперира собствени сървъри. - No comment provided by engineer. - App build: %@ Компилация на приложението: %@ @@ -1142,6 +1247,10 @@ swipe action Аудио и видео разговори No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Аудио/видео разговори @@ -1212,6 +1321,19 @@ swipe action Лош хеш на съобщението No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls По-добри обаждания @@ -1307,6 +1429,10 @@ swipe action Блокирай члена? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin Блокиран от админ @@ -1357,6 +1483,14 @@ swipe action И вие, и вашият контакт можете да изпращате гласови съобщения. No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Български, финландски, тайландски и украински - благодарение на потребителите и [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1365,7 +1499,7 @@ swipe action Business address Бизнес адрес - No comment provided by engineer. + chat link info line Business chats @@ -1387,15 +1521,6 @@ swipe action Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - С използването на SimpleX Chat вие се съгласявате със: -- изпращане само на легално съдържание в публични групи. -- уважение към другите потребители – без спам. - No comment provided by engineer. - Call already ended! Разговорът вече приключи! @@ -1544,6 +1669,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat Чат @@ -1629,6 +1815,22 @@ set passcode view Потребителски профил No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme Тема на чата @@ -1647,7 +1849,8 @@ set passcode view Chat with admins Чат с администраторите - chat toolbar + chat feature +chat toolbar Chat with member @@ -1664,11 +1867,23 @@ set passcode view Чатове No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members Чатове с членовете No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. Проверявай за съобщенията на всеки 20 минути. @@ -1679,6 +1894,14 @@ set passcode view Проверявай за съобщенията, когато е разрешено. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. Проверете адреса на сървъра и опитайте отново. @@ -1802,7 +2025,7 @@ set passcode view Conditions of use Условия за ползване - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1824,9 +2047,8 @@ set passcode view Конфигурирай ICE сървъри No comment provided by engineer. - - Configure server operators - Конфигуриране на сървърни оператори + + Configure relays No comment provided by engineer. @@ -1887,7 +2109,8 @@ set passcode view Connect Свързване - server test step + relay test step +server test step Connect automatically @@ -1933,6 +2156,10 @@ This is your own one-time link! Свърване чрез линк new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Свързване чрез еднократен линк за връзка @@ -2011,6 +2238,10 @@ This is your own one-time link! Connection error (AUTH) Грешка при свързване (AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -2062,6 +2293,10 @@ This is your own one-time link! Connections No comment provided by engineer. + + Contact address + chat link info line + Contact allows Контактът позволява @@ -2127,6 +2362,11 @@ This is your own one-time link! Продължи No comment provided by engineer. + + Contribute + Допринеси + No comment provided by engineer. + Conversation deleted! No comment provided by engineer. @@ -2152,12 +2392,7 @@ This is your own one-time link! Correct name to %@? Поправи име на %@? - No comment provided by engineer. - - - Create - Създаване - No comment provided by engineer. + alert message Create 1-time link @@ -2208,6 +2443,14 @@ This is your own one-time link! Създай профил No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue Създай опашка @@ -2217,11 +2460,19 @@ This is your own one-time link! Create your address No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile Създай своя профил No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created No comment provided by engineer. @@ -2241,6 +2492,10 @@ This is your own one-time link! Създаване на архивен линк No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… Линкът се създава… @@ -2393,10 +2648,9 @@ This is your own one-time link! Debug delivery No comment provided by engineer. - - Decentralized - Децентрализиран - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2443,6 +2697,14 @@ swipe action Изтрий и уведоми контакт No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat No comment provided by engineer. @@ -2552,6 +2814,14 @@ swipe action Изтрий съобщението на члена? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Изтрий съобщението? @@ -2560,7 +2830,8 @@ swipe action Delete messages Изтрий съобщенията - alert button + alert action +alert button Delete messages after @@ -2596,6 +2867,10 @@ swipe action Изтрий опашка server test step + + Delete relay + No comment provided by engineer. + Delete report No comment provided by engineer. @@ -2746,6 +3021,14 @@ swipe action Личните съобщения между членовете са забранени в тази група. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) Деактивиране (запазване на промените) @@ -2846,6 +3129,10 @@ swipe action Не изпращай история на нови членове. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. No comment provided by engineer. @@ -2938,11 +3225,19 @@ chat item action E2E encrypted notifications. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit Редактирай chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile Редактирай групов профил @@ -2955,7 +3250,7 @@ chat item action Enable Активирай - No comment provided by engineer. + alert button Enable (keep overrides) @@ -2976,6 +3271,10 @@ chat item action Активирай TCP keep-alive No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Активиране на автоматично изтриване на съобщения? @@ -2986,6 +3285,10 @@ chat item action Разреши достъпа до камерата No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. No comment provided by engineer. @@ -3005,16 +3308,15 @@ chat item action Активирай незабавни известия? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock Активирай заключване No comment provided by engineer. - - Enable notifications - Активирай известията - No comment provided by engineer. - Enable periodic notifications? Активирай периодични известия? @@ -3118,6 +3420,10 @@ chat item action Въведете kодa за достъп No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Въведи правилна парола. @@ -3143,6 +3449,14 @@ chat item action Въведете парола по-горе, за да се покаже! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually Въведи сървъра ръчно @@ -3171,7 +3485,7 @@ chat item action Error Грешка при свързване със сървъра - No comment provided by engineer. + conn error description Error aborting address change @@ -3196,6 +3510,10 @@ chat item action Грешка при добавяне на член(ове) No comment provided by engineer. + + Error adding relay + alert title + Error adding server alert title @@ -3239,11 +3557,19 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Грешка при създаване на адрес No comment provided by engineer. + + Error creating channel + alert title + Error creating group Грешка при създаване на група @@ -3373,10 +3699,6 @@ chat item action Грешка при отваряне на чата No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file Грешка при получаване на файл @@ -3416,6 +3738,10 @@ chat item action Грешка при запазване на ICE сървърите No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list alert title @@ -3478,6 +3804,10 @@ chat item action Грешка при настройването на потвърждениeто за доставка!! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat Грешка при стартиране на чата @@ -3554,7 +3884,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3737,6 +4068,10 @@ snd error text Файловете и медията са забранени! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Филтрирайте непрочетените и любимите чатове. @@ -3773,7 +4108,8 @@ snd error text Fingerprint in server address does not match certificate. Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3813,9 +4149,14 @@ snd error text For all moderators No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: - servers error + servers error +servers warning For console @@ -3940,10 +4281,18 @@ Error: %2$@ GIF файлове и стикери No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! message preview @@ -4000,7 +4349,7 @@ Error: %2$@ Group link Групов линк - No comment provided by engineer. + chat link info line Group links @@ -4109,6 +4458,10 @@ Error: %2$@ Историята не се изпраща на нови членове. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works Как работи SimpleX @@ -4170,6 +4523,10 @@ Error: %2$@ Ако въведете kодa за достъп за самоунищожение, докато отваряте приложението: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Ако трябва да използвате чата сега, докоснете **Отложи** отдолу (ще ви бъде предложено да мигрирате базата данни, когато рестартирате приложението). @@ -4190,16 +4547,15 @@ Error: %2$@ Изображението ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Веднага No comment provided by engineer. - - Immune to spam - Защитен от спам и злоупотреби - No comment provided by engineer. - Import Импортиране @@ -4337,9 +4693,9 @@ More improvements are coming soon! Първоначална роля No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Инсталирайте [SimpleX Chat за терминал](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Инсталирайте SimpleX Chat за терминал No comment provided by engineer. @@ -4391,7 +4747,7 @@ More improvements are coming soon! Invalid connection link Невалиден линк за връзка - No comment provided by engineer. + conn error description Invalid display name! @@ -4411,7 +4767,15 @@ More improvements are coming soon! Invalid name! Невалидно име! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4438,11 +4802,19 @@ More improvements are coming soon! Покани приятели No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Покани членове No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat No comment provided by engineer. @@ -4517,6 +4889,10 @@ More improvements are coming soon! присъединяване като %@ No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group Влез в групата @@ -4602,6 +4978,14 @@ This is your link for group %@! Напусни swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat No comment provided by engineer. @@ -4624,6 +5008,10 @@ This is your link for group %@! Less traffic on mobile networks. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat Нека да поговорим в SimpleX Chat @@ -4644,6 +5032,10 @@ This is your link for group %@! Свържете мобилни и настолни приложения! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options Настройки на запомнени настолни устройства @@ -4654,6 +5046,10 @@ This is your link for group %@! Запомнени настолни устройства No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4769,6 +5165,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4789,12 +5189,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Членът ще бъде премахнат от групата - това не може да бъде отменено! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4805,6 +5205,10 @@ This is your link for group %@! Членовете на групата могат да добавят реакции към съобщенията. No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа) @@ -4866,6 +5270,10 @@ This is your link for group %@! Чернова на съобщение No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded item status text @@ -4951,6 +5359,14 @@ This is your link for group %@! Съобщенията от %@ ще бъдат показани! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. alert message @@ -4977,16 +5393,15 @@ This is your link for group %@! Съобщенията, файловете и разговорите са защитени чрез **квантово устойчиво e2e криптиране** с перфектна секретност при препращане, правдоподобно опровержение и възстановяване при взлом. No comment provided by engineer. + + Migrate + No comment provided by engineer. + Migrate device Мигрирай устройството No comment provided by engineer. - - Migrate from another device - Мигриране от друго устройство - No comment provided by engineer. - Migrate here Мигрирай тук @@ -5104,6 +5519,10 @@ This is your link for group %@! Мрежа и сървъри No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection Мрежова връзка @@ -5113,6 +5532,10 @@ This is your link for group %@! Network decentralization No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. snd error text @@ -5126,6 +5549,11 @@ This is your link for group %@! Network operator No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings Мрежови настройки @@ -5134,12 +5562,16 @@ This is your link for group %@! Network status Състояние на мрежата - No comment provided by engineer. + alert title New token status text + + New 1-time link + No comment provided by engineer. + New Passcode Нов kод за достъп @@ -5162,6 +5594,10 @@ This is your link for group %@! New chat experience 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request Нова заявка за контакт @@ -5227,11 +5663,28 @@ This is your link for group %@! Не No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password Приложението няма kод за достъп Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats No comment provided by engineer. @@ -5359,11 +5812,22 @@ This is your link for group %@! No unread chats No comment provided by engineer. - - No user identifiers. - Първата платформа без никакви потребителски идентификатори – поверителна по дизайн. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! Несъвместим! @@ -5415,7 +5879,7 @@ This is your link for group %@! OK ОК - No comment provided by engineer. + alert button Off @@ -5434,11 +5898,19 @@ new chat action Стара база данни No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link Линк за еднократна покана No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5458,6 +5930,10 @@ Requires compatible VPN. Няма се използват Onion хостове. No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. No comment provided by engineer. @@ -5555,7 +6031,8 @@ Requires compatible VPN. Open Отвори - alert action + alert action +alert button Open Settings @@ -5566,6 +6043,10 @@ Requires compatible VPN. Open changes No comment provided by engineer. + + Open channel + new chat action + Open chat Отвори чат @@ -5584,6 +6065,10 @@ Requires compatible VPN. Open conditions No comment provided by engineer. + + Open external link? + alert title + Open full link alert action @@ -5602,6 +6087,10 @@ Requires compatible VPN. Отвори миграцията към друго устройство authentication reason + + Open new channel + new chat action + Open new chat new chat action @@ -5639,6 +6128,13 @@ Requires compatible VPN. Operator server alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file No comment provided by engineer. @@ -5658,6 +6154,10 @@ Requires compatible VPN. Или сигурно споделете този линк към файла No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code Или покажи този код @@ -5667,6 +6167,10 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists No comment provided by engineer. @@ -5681,6 +6185,18 @@ Requires compatible VPN. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count PING бройка @@ -5735,6 +6251,10 @@ Requires compatible VPN. Постави изображение No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! Поставете линк, за да се свържете! @@ -5879,6 +6399,14 @@ Error: %@ Запазете последната чернова на съобщението с прикачени файлове. No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address Предварително зададен адрес на сървъра @@ -5910,13 +6438,12 @@ Error: %@ Privacy policy and conditions of use. No comment provided by engineer. - - Privacy redefined - Поверителността преосмислена + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. + + Private and secure messaging. No comment provided by engineer. @@ -5953,6 +6480,10 @@ Error: %@ Private routing timeout alert title + + Proceed + alert action + Profile and server connections Профилни и сървърни връзки @@ -5977,9 +6508,8 @@ Error: %@ Profile theme No comment provided by engineer. - - Profile update will be sent to your contacts. - Актуализацията на профила ще бъде изпратена до вашите контакти. + + Profile update will be sent to your SimpleX contacts. alert message @@ -5987,6 +6517,10 @@ Error: %@ Забрани аудио/видео разговорите. No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Забрани необратимото изтриване на съобщения. @@ -6016,6 +6550,10 @@ Error: %@ Забрани изпращането на лични съобщения до членовете. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Забрани изпращането на изчезващи съобщения. @@ -6076,6 +6614,10 @@ Enable in *Network & servers* settings. Proxy requires password No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Push известия @@ -6115,24 +6657,14 @@ Enable in *Network & servers* settings. Прочетете още No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Прочетете повече в Ръководство за потребителя. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Прочетете повече в [Ръководство на потребителя](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Прочетете повече в нашето [GitHub хранилище](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Прочетете повече в нашето GitHub хранилище. No comment provided by engineer. @@ -6154,11 +6686,6 @@ Enable in *Network & servers* settings. Получено в: %@ copied message info - - Received file event - Събитие за получен файл - notification - Received message Получено съобщение @@ -6284,6 +6811,26 @@ swipe action Reject member? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Реле сървър се използва само ако е необходимо. Друга страна може да наблюдава вашия IP адрес. @@ -6294,10 +6841,22 @@ swipe action Relay сървърът защитава вашия IP адрес, но може да наблюдава продължителността на разговора. No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove Премахване - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6319,13 +6878,21 @@ swipe action Remove member? Острани член? - No comment provided by engineer. + alert title Remove passphrase from keychain? Премахване на паролата от keychain? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. No comment provided by engineer. @@ -6538,6 +7105,10 @@ swipe action SOCKS proxy No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files No comment provided by engineer. @@ -6562,6 +7133,10 @@ chat item action Save (and notify members) alert button + + Save (and notify subscribers) + alert button + Save admission settings? alert title @@ -6576,6 +7151,10 @@ chat item action Запази и уведоми членовете на групата No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect No comment provided by engineer. @@ -6585,6 +7164,14 @@ chat item action Запази и актуализирай профила на групата No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile Запази профила на групата @@ -6704,11 +7291,31 @@ chat item action Лентата за търсене приема линк за връзка. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Търсене или поставяне на SimpleX линк No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -6732,6 +7339,10 @@ chat item action Код за сигурност No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select Избери @@ -6851,6 +7462,10 @@ chat item action Send request without message No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Изпрати от галерия или персонализирани клавиатури. @@ -6861,6 +7476,10 @@ chat item action Изпращане до последните 100 съобщения на нови членове. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. No comment provided by engineer. @@ -6875,6 +7494,10 @@ chat item action Подателят може да е изтрил заявката за връзка. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Изпращането на потвърждениe за доставка ще бъде активирано за всички контакти във всички видими чат профили. @@ -6929,11 +7552,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Събитие за изпратен файл - notification - Sent message Изпратено съобщение @@ -6992,6 +7610,10 @@ chat item action Server protocol changed. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. Сървърът изисква оторизация за създаване на опашки, проверете паролата @@ -7111,6 +7733,14 @@ chat item action Settings were changed. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images Променете формата на профилните изображения @@ -7144,11 +7774,14 @@ chat item action Share address publicly No comment provided by engineer. - - Share address with contacts? - Сподели адреса с контактите? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. No comment provided by engineer. @@ -7170,6 +7803,10 @@ chat item action Share profile No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link Сподели този еднократен линк за връзка @@ -7179,9 +7816,12 @@ chat item action Share to SimpleX No comment provided by engineer. - - Share with contacts - Сподели с контактите + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -7341,8 +7981,8 @@ chat item action SimpleX protocols reviewed by Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7410,6 +8050,11 @@ report reason Квадрат, кръг или нещо между тях. No comment provided by engineer. + + Star on GitHub + Звезда в GitHub + No comment provided by engineer. + Start chat Започни чат @@ -7505,6 +8150,63 @@ report reason Subscribed No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors No comment provided by engineer. @@ -7577,6 +8279,10 @@ report reason Направи снимка No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat No comment provided by engineer. @@ -7589,8 +8295,8 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. + + Tap Join channel No comment provided by engineer. @@ -7622,6 +8328,10 @@ report reason Докосни за инкогнито вход No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link Докосни за поставяне на линк за връзка @@ -7639,12 +8349,17 @@ report reason Test failed at step %@. Тестът е неуспешен на стъпка %@. - server test failure + relay test failure +server test failure Test notifications No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server Тествай сървър @@ -7695,6 +8410,10 @@ It can happen because of some bug or when the connection is compromised.The app protects your privacy by using different operators in each conversation. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). No comment provided by engineer. @@ -7709,6 +8428,10 @@ It can happen because of some bug or when the connection is compromised.QR кодът, който сканирахте, не е SimpleX линк за връзка. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. No comment provided by engineer. @@ -7733,9 +8456,9 @@ It can happen because of some bug or when the connection is compromised.Криптирането работи и новото споразумение за криптиране не е необходимо. Това може да доведе до грешки при свързване! No comment provided by engineer. - - The future of messaging - Ново поколение поверителни съобщения + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -7770,6 +8493,10 @@ It can happen because of some bug or when the connection is compromised.Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7810,6 +8537,14 @@ It can happen because of some bug or when the connection is compromised.Themes No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. No comment provided by engineer. @@ -7873,6 +8608,14 @@ It can happen because of some bug or when the connection is compromised.Тази група вече не съществува. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. No comment provided by engineer. @@ -7917,6 +8660,10 @@ It can happen because of some bug or when the connection is compromised.Скриване на нежелани съобщения. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection За да направите нова връзка @@ -7995,10 +8742,6 @@ You will be prompted to complete authentication before this feature is enabled.< За да проверите криптирането от край до край с вашия контакт, сравнете (или сканирайте) кода на вашите устройства. No comment provided by engineer. - - Toggle chat list: - No comment provided by engineer. - Toggle incognito when connecting. Избор на инкогнито при свързване. @@ -8012,6 +8755,10 @@ You will be prompted to complete authentication before this feature is enabled.< Toolbar opacity No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total No comment provided by engineer. @@ -8025,15 +8772,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8080,6 +8821,10 @@ You will be prompted to complete authentication before this feature is enabled.< Отблокирай член? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages No comment provided by engineer. @@ -8177,13 +8922,17 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. На новите членове се изпращат до последните 100 съобщения. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Актуализация @@ -8294,11 +9043,6 @@ To connect, please ask your contact to create another connection link and check Use TCP port 443 for preset servers only. No comment provided by engineer. - - Use chat - Използвай чата - No comment provided by engineer. - Use current profile Използвай текущия профил @@ -8312,6 +9056,10 @@ To connect, please ask your contact to create another connection link and check Use for messages No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections Използвай за нови връзки @@ -8349,6 +9097,10 @@ To connect, please ask your contact to create another connection link and check Use private routing with unknown servers. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server Използвай сървър @@ -8367,6 +9119,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port No comment provided by engineer. @@ -8384,6 +9140,10 @@ To connect, please ask your contact to create another connection link and check Използват се сървърите на SimpleX Chat. No comment provided by engineer. + + Verify + relay test step + Verify code with desktop Потвърди кода с настолното устройство @@ -8444,6 +9204,10 @@ To connect, please ask your contact to create another connection link and check Видеото ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Видео и файлове до 1gb @@ -8497,6 +9261,18 @@ To connect, please ask your contact to create another connection link and check Гласово съобщение… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... Изчакване на настолно устройство… @@ -8535,6 +9311,10 @@ To connect, please ask your contact to create another connection link and check Предупреждение: Може да загубите някои данни! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICE сървъри @@ -8583,6 +9363,10 @@ To connect, please ask your contact to create another connection link and check Когато споделяте инкогнито профил с някого, този профил ще се използва за групите, в които той ви кани. No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi WiFi @@ -8704,16 +9488,19 @@ Repeat join request? Изпрати отново заявката за присъединяване? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Вие сте свързани към сървъра, използван за получаване на съобщения от този контакт. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Поканени сте в групата No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -8779,6 +9566,10 @@ Repeat join request? Можете да зададете визуализация на известията на заключен екран през настройките. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Можете да споделите линк или QR код - всеки ще може да се присъедини към групата. Няма да загубите членовете на групата, ако по-късно я изтриете. @@ -8822,16 +9613,21 @@ Repeat join request? Не може да изпращате съобщения! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. Не можахте да бъдете потвърдени; Моля, опитайте отново. No comment provided by engineer. - - You decide who can connect. - Хората могат да се свържат с вас само чрез ликовете, които споделяте. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -8895,6 +9691,10 @@ Repeat connection request? You should receive notifications. token info + + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. No comment provided by engineer. @@ -8929,6 +9729,10 @@ Repeat connection request? Все още ще получавате обаждания и известия от заглушени профили, когато са активни. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. No comment provided by engineer. @@ -8972,6 +9776,10 @@ Repeat connection request? Вашите обаждания No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database Вашата база данни @@ -9018,6 +9826,10 @@ Repeat connection request? Вашите контакти ще останат свързани. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. No comment provided by engineer. @@ -9036,6 +9848,10 @@ Repeat connection request? Your group No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences Вашите настройки @@ -9051,6 +9867,11 @@ Repeat connection request? Вашият профил No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Вашият профил **%@** ще бъде споделен. @@ -9070,11 +9891,23 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message + + Your public address + No comment provided by engineer. + Your random profile Вашият автоматично генериран профил No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address Вашият адрес на сървъра @@ -9089,21 +9922,11 @@ Repeat connection request? Вашите настройки No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Допринеси](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Изпратете ни имейл](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Звезда в GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_курсив_ @@ -9119,6 +9942,10 @@ Repeat connection request? по-горе, след това избери: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ rcv group event chat item @@ -9136,6 +9963,10 @@ Repeat connection request? accepted you rcv group event chat item + + active + No comment provided by engineer. + admin админ @@ -9243,6 +10074,10 @@ marked deleted chat item preview text повикване… call status + + can't broadcast + No comment provided by engineer. + can't send messages No comment provided by engineer. @@ -9277,6 +10112,14 @@ marked deleted chat item preview text промяна на адреса… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored цветен @@ -9418,6 +10261,10 @@ pref value изтрит deleted chat item + + deleted channel + rcv group event chat item + deleted contact изтрит контакт @@ -9527,10 +10374,18 @@ pref value грешка No comment provided by engineer. + + error: %@ + receive error chat item + expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded препратено @@ -9647,6 +10502,10 @@ pref value напусна rcv group event chat item + + link + No comment provided by engineer. + marked deleted маркирано като изтрито @@ -9714,6 +10573,10 @@ pref value никога delete after time + + new + No comment provided by engineer. + new message ново съобщение @@ -9729,6 +10592,10 @@ pref value липсва e2e криптиране No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text няма текст @@ -9825,6 +10692,10 @@ time to disappear отхвърлено повикване call status + + relay + member role + removed отстранен @@ -9835,6 +10706,14 @@ time to disappear отстранен %@ rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address премахнат адрес за контакт @@ -9975,6 +10854,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile актуализиран профил на групата @@ -9995,6 +10878,10 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link чрез линк с адрес за контакт @@ -10067,6 +10954,10 @@ last received msg: %2$@ вие сте наблюдател No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ вие блокирахте %@ @@ -10127,6 +11018,10 @@ last received msg: %2$@ \~зачеркнат~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 244cbdf946..1cc44dd7cb 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -185,6 +185,21 @@ %d měsíce time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d sek @@ -200,11 +215,53 @@ %d přeskočené zprávy integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d týdnů time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +272,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld kontakt(y) vybrané @@ -227,6 +288,7 @@ %lld group events + %lld událostí skupiny No comment provided by engineer. @@ -314,11 +376,19 @@ %u zpráv přeskočeno. No comment provided by engineer. + + (from owner) + chat link info line + (new) (nový) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (toto zařízení v%@) @@ -363,6 +433,10 @@ **Skenovat / Vložit odkaz**: pro připojení pomocí odkazu který jste obdrželi. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Upozornění**: Okamžitě doručovaná oznámení vyžadují přístupové heslo uložené v Klíčence. @@ -406,6 +480,12 @@ - a více! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -503,6 +583,10 @@ time interval Ještě pár věcí No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact Nový kontakt @@ -515,7 +599,7 @@ time interval A separate TCP connection will be used **for each chat profile you have in the app**. - Samostatné připojení TCP bude použito **pro každý chat profil, který máte v aplikaci**. + Samostatné připojení TCP bude použito **pro každý profil chatu, který máte v aplikaci**. No comment provided by engineer. @@ -626,9 +710,8 @@ swipe action Aktivní spojení No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Přidejte adresu do svého profilu, aby ji vaše kontakty mohly sdílet s dalšími lidmi. Aktualizace profilu bude zaslána vašim kontaktům. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -693,6 +776,10 @@ swipe action Přidané servery zpráv No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent Další zbarvení @@ -764,7 +851,7 @@ swipe action All chats will be removed from the list %@, and the list deleted. - Všechny chaty budou odstraněny ze seznamu %@ a seznam bude odstraněn. + Všechny chaty budou odstraněny ze seznamu %@ a seznam bude smazán. alert message @@ -782,6 +869,10 @@ swipe action Všichni členové skupiny zůstanou připojeni. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -805,6 +896,14 @@ swipe action Všechny profily profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. No comment provided by engineer. @@ -861,6 +960,10 @@ swipe action Povolte nevratné smazání zprávy pouze v případě, že vám to váš kontakt dovolí. (24 hodin) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Povolit reakce na zprávy, pokud je váš kontakt povolí. @@ -876,6 +979,10 @@ swipe action Povolit odesílání přímých zpráv členům. No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Povolit odesílání mizících zpráv. @@ -886,6 +993,10 @@ swipe action Povolit sdílení No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Povolit nevratné smazání odeslaných zpráv. (24 hodin) @@ -978,7 +1089,7 @@ swipe action An empty chat profile with the provided name is created, and the app opens as usual. - Vytvořit prázdný chat profil se zadaným názvem a otevřít aplikaci jako obvykle. + Vytvořit prázdný profil chatu se zadaným názvem a otevřít aplikaci jako obvykle. No comment provided by engineer. @@ -991,11 +1102,6 @@ swipe action Přijmout hovor No comment provided by engineer. - - Anybody can host servers. - Servery může provozovat kdokoli. - No comment provided by engineer. - App build: %@ Sestavení aplikace: %@ @@ -1117,6 +1223,10 @@ swipe action Hlasové a video hovory No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Audio/video hovory @@ -1186,6 +1296,21 @@ swipe action Špatný hash zprávy No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + Buďte svobodní ve své síti. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Protože jsme zničili sílu vědět, kdo jste. Aby vám vaši moc nikdo nemohl vzít. + No comment provided by engineer. + Better calls Lepší volání @@ -1273,6 +1398,10 @@ swipe action Blokovat člena? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin Blokován správcem @@ -1321,6 +1450,14 @@ swipe action Hlasové zprávy můžete posílat vy i váš kontakt. No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bulharský, finský, thajský a ukrajinský - díky uživatelům a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1329,7 +1466,7 @@ swipe action Business address Obchodní adresa - No comment provided by engineer. + chat link info line Business chats @@ -1345,13 +1482,7 @@ swipe action By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). - Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). - No comment provided by engineer. - - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. + Podle profilu chatu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. @@ -1453,7 +1584,7 @@ new chat action Change chat profiles - Změnit chat profily + Změnit profily chatu authentication reason @@ -1502,6 +1633,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat No comment provided by engineer. @@ -1581,6 +1773,22 @@ set passcode view Profil uživatele No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme No comment provided by engineer. @@ -1595,7 +1803,8 @@ set passcode view Chat with admins - chat toolbar + chat feature +chat toolbar Chat with member @@ -1610,10 +1819,22 @@ set passcode view Chaty No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -1622,6 +1843,14 @@ set passcode view Check messages when allowed. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. Zkontrolujte adresu serveru a zkuste to znovu. @@ -1730,7 +1959,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1749,8 +1978,8 @@ set passcode view Konfigurace serverů ICE No comment provided by engineer. - - Configure server operators + + Configure relays No comment provided by engineer. @@ -1806,7 +2035,8 @@ set passcode view Connect Připojit - server test step + relay test step +server test step Connect automatically @@ -1846,6 +2076,10 @@ Toto je váš vlastní jednorázový odkaz! Připojte se prostřednictvím odkazu new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Připojit se jednorázovým odkazem @@ -1914,6 +2148,10 @@ Toto je váš vlastní jednorázový odkaz! Connection error (AUTH) Chyba spojení (AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -1959,6 +2197,10 @@ Toto je váš vlastní jednorázový odkaz! Connections No comment provided by engineer. + + Contact address + chat link info line + Contact allows Kontakt povolil @@ -2024,6 +2266,11 @@ Toto je váš vlastní jednorázový odkaz! Pokračovat No comment provided by engineer. + + Contribute + Přispějte + No comment provided by engineer. + Conversation deleted! No comment provided by engineer. @@ -2048,12 +2295,7 @@ Toto je váš vlastní jednorázový odkaz! Correct name to %@? - No comment provided by engineer. - - - Create - Vytvořit - No comment provided by engineer. + alert message Create 1-time link @@ -2101,6 +2343,14 @@ Toto je váš vlastní jednorázový odkaz! Vytvořte si profil No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue Vytvořit frontu @@ -2110,11 +2360,19 @@ Toto je váš vlastní jednorázový odkaz! Create your address No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile Vytvořte si profil No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created No comment provided by engineer. @@ -2131,6 +2389,10 @@ Toto je váš vlastní jednorázový odkaz! Creating archive link No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… No comment provided by engineer. @@ -2282,10 +2544,9 @@ Toto je váš vlastní jednorázový odkaz! Debug delivery No comment provided by engineer. - - Decentralized - Decentralizované - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2330,6 +2591,14 @@ swipe action Delete and notify contact No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat No comment provided by engineer. @@ -2340,12 +2609,12 @@ swipe action Delete chat profile - Smazat chat profil + Smazat profil chatu No comment provided by engineer. Delete chat profile? - Smazat chat profil? + Smazat profil chatu? No comment provided by engineer. @@ -2438,6 +2707,14 @@ swipe action Smazat zprávu člena? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Smazat zprávu? @@ -2446,7 +2723,8 @@ swipe action Delete messages Smazat zprávy - alert button + alert action +alert button Delete messages after @@ -2482,6 +2760,10 @@ swipe action Odstranit frontu server test step + + Delete relay + No comment provided by engineer. + Delete report No comment provided by engineer. @@ -2629,6 +2911,14 @@ swipe action Přímé zprávy mezi členy jsou v této skupině zakázány. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) Vypnout (zachovat přepsání) @@ -2726,6 +3016,10 @@ swipe action Do not send history to new members. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. No comment provided by engineer. @@ -2814,11 +3108,19 @@ chat item action E2E encrypted notifications. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit Upravit chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile Upravit profil skupiny @@ -2831,7 +3133,7 @@ chat item action Enable Zapnout - No comment provided by engineer. + alert button Enable (keep overrides) @@ -2852,6 +3154,10 @@ chat item action Povolit TCP keep-alive No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Povolit automatické mazání zpráv? @@ -2861,6 +3167,10 @@ chat item action Enable camera access No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. No comment provided by engineer. @@ -2879,16 +3189,15 @@ chat item action Povolit okamžitá oznámení? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock Povolit zámek No comment provided by engineer. - - Enable notifications - Povolit upozornění - No comment provided by engineer. - Enable periodic notifications? Povolit pravidelná oznámení? @@ -2988,6 +3297,10 @@ chat item action Zadat heslo No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Zadejte správnou přístupovou frázi. @@ -3011,6 +3324,14 @@ chat item action Zadejte heslo do hledání! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually Zadejte server ručně @@ -3037,7 +3358,7 @@ chat item action Error Chyba - No comment provided by engineer. + conn error description Error aborting address change @@ -3062,6 +3383,10 @@ chat item action Chyba přidávání člena(ů) No comment provided by engineer. + + Error adding relay + alert title + Error adding server alert title @@ -3105,11 +3430,19 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Chyba při vytváření adresy No comment provided by engineer. + + Error creating channel + alert title + Error creating group Chyba při vytváření skupiny @@ -3236,10 +3569,6 @@ chat item action Error opening chat No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file Chyba při příjmu souboru @@ -3279,6 +3608,10 @@ chat item action Chyba při ukládání serverů ICE No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list alert title @@ -3339,6 +3672,10 @@ chat item action Chyba nastavování potvrzení o doručení! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat Chyba při spuštění chatu @@ -3413,7 +3750,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3592,6 +3930,10 @@ snd error text Soubory a média jsou zakázány! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtrovat nepřečtené a oblíbené chaty. @@ -3625,8 +3967,9 @@ snd error text Fingerprint in server address does not match certificate. - Je možné, že otisk certifikátu v adrese serveru je nesprávný - server test error + Otisk certifikátu v adrese serveru neodpovídá. + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3666,9 +4009,14 @@ snd error text For all moderators No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: - servers error + servers error +servers warning For console @@ -3787,10 +4135,18 @@ Error: %2$@ GIFy a nálepky No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! message preview @@ -3845,7 +4201,7 @@ Error: %2$@ Group link Odkaz na skupinu - No comment provided by engineer. + chat link info line Group links @@ -3916,7 +4272,7 @@ Error: %2$@ Hidden chat profiles - Skryté chat profily + Skryté profily chatu No comment provided by engineer. @@ -3953,6 +4309,10 @@ Error: %2$@ History is not sent to new members. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works Jak SimpleX funguje @@ -4013,6 +4373,10 @@ Error: %2$@ Pokud při otevření aplikace zadáte sebedestrukční heslo: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Pokud potřebujete chat používat nyní, klepněte na **Udělat později** níže (migrace databáze vám bude nabídnuta po restartování aplikace). @@ -4033,16 +4397,15 @@ Error: %2$@ Obrázek bude přijat, až bude váš kontakt online, vyčkejte prosím nebo se podívejte později! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Ihned No comment provided by engineer. - - Immune to spam - Odolná vůči spamu a zneužití - No comment provided by engineer. - Import Import @@ -4173,9 +4536,9 @@ More improvements are coming soon! Počáteční role No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Nainstalujte [SimpleX Chat pro terminál](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Nainstalujte SimpleX Chat pro terminál No comment provided by engineer. @@ -4226,7 +4589,7 @@ More improvements are coming soon! Invalid connection link Neplatný odkaz na spojení - No comment provided by engineer. + conn error description Invalid display name! @@ -4242,7 +4605,15 @@ More improvements are coming soon! Invalid name! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4268,11 +4639,19 @@ More improvements are coming soon! Pozvat přátele No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Pozvat členy No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat No comment provided by engineer. @@ -4347,6 +4726,10 @@ More improvements are coming soon! připojit se jako %@ No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group Připojit ke skupině @@ -4426,6 +4809,14 @@ This is your link for group %@! Opustit swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat No comment provided by engineer. @@ -4448,6 +4839,10 @@ This is your link for group %@! Less traffic on mobile networks. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat Promluvme si v SimpleX Chatu @@ -4467,6 +4862,10 @@ This is your link for group %@! Link mobile and desktop apps! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options No comment provided by engineer. @@ -4475,6 +4874,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4590,6 +4993,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4610,12 +5017,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Člen bude odstraněn ze skupiny - toto nelze vzít zpět! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4626,6 +5033,10 @@ This is your link for group %@! Členové skupin mohou přidávat reakce na zprávy. No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Členové skupiny mohou nevratně mazat odeslané zprávy. (24 hodin) @@ -4686,6 +5097,10 @@ This is your link for group %@! Návrh zprávy No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded item status text @@ -4768,6 +5183,14 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. alert message @@ -4792,12 +5215,12 @@ This is your link for group %@! Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. No comment provided by engineer. - - Migrate device + + Migrate No comment provided by engineer. - - Migrate from another device + + Migrate device No comment provided by engineer. @@ -4884,7 +5307,7 @@ This is your link for group %@! Multiple chat profiles - Více chatovacích profilů + Více profilů chatu No comment provided by engineer. @@ -4911,6 +5334,10 @@ This is your link for group %@! Síť a servery No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection No comment provided by engineer. @@ -4919,6 +5346,10 @@ This is your link for group %@! Network decentralization No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. snd error text @@ -4931,6 +5362,11 @@ This is your link for group %@! Network operator No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings Nastavení sítě @@ -4939,12 +5375,16 @@ This is your link for group %@! Network status Stav sítě - No comment provided by engineer. + alert title New token status text + + New 1-time link + No comment provided by engineer. + New Passcode Nové heslo @@ -4966,6 +5406,10 @@ This is your link for group %@! New chat experience 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request Žádost o nový kontakt @@ -5031,11 +5475,28 @@ This is your link for group %@! Ne No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password Žádné heslo aplikace Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats No comment provided by engineer. @@ -5162,11 +5623,24 @@ This is your link for group %@! No unread chats No comment provided by engineer. - - No user identifiers. - Bez uživatelských identifikátorů + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + Nikdo nesledoval vaše konverzace. Nikdo nevytvořil mapu, kde jste byli. Soukromí nikdy nebylo funkcí - byl to způsob života. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + Nejde o to mít lepší zámek na dveřích někoho jiného. Ani o to mít nájemce, který respektuje vaše soukromí, ale vede evidenci všech vašich návštěvníků. Nejste host. Jste doma. Ani král k vám nemůže vstoupit - jste suverén. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! No comment provided by engineer. @@ -5216,7 +5690,7 @@ This is your link for group %@! OK - No comment provided by engineer. + alert button Off @@ -5235,11 +5709,19 @@ new chat action Stará databáze No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link Jednorázový zvací odkaz No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5259,6 +5741,10 @@ Vyžaduje povolení sítě VPN. Onion hostitelé nebudou použiti. No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. No comment provided by engineer. @@ -5356,7 +5842,8 @@ Vyžaduje povolení sítě VPN. Open Otevřít - alert action + alert action +alert button Open Settings @@ -5367,6 +5854,10 @@ Vyžaduje povolení sítě VPN. Open changes No comment provided by engineer. + + Open channel + new chat action + Open chat Otevřete chat @@ -5385,6 +5876,10 @@ Vyžaduje povolení sítě VPN. Open conditions No comment provided by engineer. + + Open external link? + alert title + Open full link alert action @@ -5401,6 +5896,10 @@ Vyžaduje povolení sítě VPN. Open migration to another device authentication reason + + Open new channel + new chat action + Open new chat new chat action @@ -5437,6 +5936,13 @@ Vyžaduje povolení sítě VPN. Operator server alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file No comment provided by engineer. @@ -5453,6 +5959,10 @@ Vyžaduje povolení sítě VPN. Or securely share this file link No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code No comment provided by engineer. @@ -5461,6 +5971,10 @@ Vyžaduje povolení sítě VPN. Or to share privately No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists No comment provided by engineer. @@ -5474,6 +5988,18 @@ Vyžaduje povolení sítě VPN. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count Počet PING @@ -5527,6 +6053,10 @@ Vyžaduje povolení sítě VPN. Vložit obrázek No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! No comment provided by engineer. @@ -5665,6 +6195,14 @@ Error: %@ Zachování posledního návrhu zprávy s přílohami. No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address Přednastavená adresa serveru @@ -5696,13 +6234,12 @@ Error: %@ Privacy policy and conditions of use. No comment provided by engineer. - - Privacy redefined - Nové vymezení soukromí + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. + + Private and secure messaging. No comment provided by engineer. @@ -5738,6 +6275,10 @@ Error: %@ Private routing timeout alert title + + Proceed + alert action + Profile and server connections Profil a připojení k serveru @@ -5761,9 +6302,8 @@ Error: %@ Profile theme No comment provided by engineer. - - Profile update will be sent to your contacts. - Aktualizace profilu bude zaslána vašim kontaktům. + + Profile update will be sent to your SimpleX contacts. alert message @@ -5771,6 +6311,10 @@ Error: %@ Zákaz audio/video hovorů. No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Zakázat nevratné mazání zpráv. @@ -5799,6 +6343,10 @@ Error: %@ Zakázat odesílání přímých zpráv členům. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Zakázat posílání mizících zpráv. @@ -5830,7 +6378,7 @@ Enable in *Network & servers* settings. Protect your chat profiles with a password! - Chraňte své chat profily heslem! + Chraňte své profily chatu pomocí hesla! No comment provided by engineer. @@ -5859,6 +6407,10 @@ Enable in *Network & servers* settings. Proxy requires password No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Nabízená oznámení @@ -5896,23 +6448,14 @@ Enable in *Network & servers* settings. Přečíst více No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Více informací v průvodci uživatele. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Další informace naleznete v [Uživatelské příručce](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Přečtěte si více v [Uživatelské příručce](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Přečtěte si více v našem [GitHub repozitáři](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Přečtěte si více v našem GitHub repozitáři. No comment provided by engineer. @@ -5934,11 +6477,6 @@ Enable in *Network & servers* settings. Přijato v: %@ copied message info - - Received file event - Událost přijatého souboru - notification - Received message Přijatá zpráva @@ -6062,6 +6600,26 @@ swipe action Reject member? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Přenosový server se používá pouze v případě potřeby. Jiná strana může sledovat vaši IP adresu. @@ -6072,10 +6630,22 @@ swipe action Přenosový server chrání vaši IP adresu, ale může sledovat dobu trvání hovoru. No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove Odstranit - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6097,13 +6667,21 @@ swipe action Remove member? Odebrat člena? - No comment provided by engineer. + alert title Remove passphrase from keychain? Odstranit přístupovou frázi z klíčenek? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. No comment provided by engineer. @@ -6226,7 +6804,7 @@ swipe action Restart the app to create a new chat profile - Restartujte aplikaci pro vytvoření nového chat profilu + Restartujte aplikaci pro vytvoření nového profilu chatu No comment provided by engineer. @@ -6312,6 +6890,10 @@ swipe action SOCKS proxy No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files No comment provided by engineer. @@ -6335,6 +6917,10 @@ chat item action Save (and notify members) alert button + + Save (and notify subscribers) + alert button + Save admission settings? alert title @@ -6349,6 +6935,10 @@ chat item action Uložit a upozornit členy skupiny No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect No comment provided by engineer. @@ -6358,6 +6948,14 @@ chat item action Uložit a aktualizovat profil skupiny No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile Uložení profilu skupiny @@ -6472,10 +7070,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -6499,6 +7117,10 @@ chat item action Bezpečnostní kód No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select Vybrat @@ -6618,6 +7240,10 @@ chat item action Send request without message No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Odeslat je z galerie nebo vlastní klávesnice. @@ -6627,6 +7253,10 @@ chat item action Send up to 100 last messages to new members. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. No comment provided by engineer. @@ -6641,9 +7271,13 @@ chat item action Odesílatel možná smazal požadavek připojení. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. - Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných chat profilech. + Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných profilech chatu. No comment provided by engineer. @@ -6695,11 +7329,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Odeslaná událost souboru - notification - Sent message Poslaná zpráva @@ -6758,14 +7387,18 @@ chat item action Server protocol changed. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. - Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo + Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo. server test error Server requires authorization to upload, check password. - Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo + Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo. server test error @@ -6875,6 +7508,14 @@ chat item action Settings were changed. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images No comment provided by engineer. @@ -6907,11 +7548,14 @@ chat item action Share address publicly No comment provided by engineer. - - Share address with contacts? - Sdílet adresu s kontakty? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. No comment provided by engineer. @@ -6933,6 +7577,10 @@ chat item action Share profile No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link No comment provided by engineer. @@ -6941,9 +7589,12 @@ chat item action Share to SimpleX No comment provided by engineer. - - Share with contacts - Sdílet s kontakty + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -7099,8 +7750,8 @@ chat item action SimpleX protocols reviewed by Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7167,6 +7818,11 @@ report reason Square, circle, or anything in between. No comment provided by engineer. + + Star on GitHub + Hvězda na GitHubu + No comment provided by engineer. + Start chat Začít chat @@ -7259,6 +7915,63 @@ report reason Subscribed No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors No comment provided by engineer. @@ -7331,6 +8044,10 @@ report reason Vyfotit No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat No comment provided by engineer. @@ -7343,8 +8060,8 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. + + Tap Join channel No comment provided by engineer. @@ -7375,6 +8092,10 @@ report reason Klepnutím se připojíte inkognito No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link No comment provided by engineer. @@ -7390,12 +8111,17 @@ report reason Test failed at step %@. Test selhal v kroku %@. - server test failure + relay test failure +server test failure Test notifications No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server Testovací server @@ -7446,6 +8172,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován The app protects your privacy by using different operators in each conversation. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). No comment provided by engineer. @@ -7459,6 +8189,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován The code you scanned is not a SimpleX link QR code. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. No comment provided by engineer. @@ -7483,9 +8217,9 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Šifrování funguje a nové povolení šifrování není vyžadováno. To může vyvolat chybu v připojení! No comment provided by engineer. - - The future of messaging - Nová generace soukromých zpráv + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -7520,6 +8254,11 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Stará databáze nebyla během přenášení odstraněna, lze ji smazat. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + Nejstarší lidská svoboda - mluvit s druhým člověkem, aniž by byl sledován - postavena na infrastruktuře, která ji nemůže zradit. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7540,7 +8279,7 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován The servers for new connections of your current chat profile **%@**. - Servery pro nová připojení vašeho aktuálního chat profilu **%@**. + Servery pro nová připojení vašeho aktuálního profilu chatu **%@**. No comment provided by engineer. @@ -7559,6 +8298,16 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Themes No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + Pak jsme se přesunuli na internet a každá platforma chtěla o vás něco vědět - vaše jméno, vaše číslo, vaše přátele. Smířili jsme se s tím, že cenou za komunikaci s ostatními je dát někomu vědět, s kým mluvíme. Každá generace, lidská i technická, to tak měla - telefon, e-mail, komunikátory, sociální sítě. Zdálo se, že je to jediný možný způsob. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + Existuje i jiný způsob. Síť bez telefonních čísel. Bez uživatelských jmen. Bez účtů. Bez jakékoli uživatelské identity. Síť, která spojuje lidi a přenáší šifrované zprávy, aniž by bylo známo, kdo je připojen. + No comment provided by engineer. + These conditions will also apply for: **%@**. No comment provided by engineer. @@ -7618,6 +8367,14 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Tato skupina již neexistuje. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. No comment provided by engineer. @@ -7632,7 +8389,7 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován This setting applies to messages in your current chat profile **%@**. - Toto nastavení platí pro zprávy ve vašem aktuálním chat profilu **%@**. + Toto nastavení platí pro zprávy ve vašem aktuálním profilu chatu **%@**. No comment provided by engineer. @@ -7661,6 +8418,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován To hide unwanted messages. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection Vytvoření nového připojení @@ -7710,7 +8471,7 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. - Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce **Chat profily**. + Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce **Profily chatu**. No comment provided by engineer. @@ -7739,10 +8500,6 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Chcete-li ověřit koncové šifrování u svého kontaktu, porovnejte (nebo naskenujte) kód na svých zařízeních. No comment provided by engineer. - - Toggle chat list: - No comment provided by engineer. - Toggle incognito when connecting. Změnit inkognito režim při připojení. @@ -7756,6 +8513,10 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Toolbar opacity No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total No comment provided by engineer. @@ -7769,15 +8530,9 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Pokus o připojení k serveru používanému k přijímání zpráv od tohoto kontaktu (chyba: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Pokus o připojení k serveru používanému pro příjem zpráv od tohoto kontaktu. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -7818,6 +8573,10 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Unblock member? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages No comment provided by engineer. @@ -7839,7 +8598,7 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Unhide chat profile - Odkrýt chat profil + Odkrýt profil chatu No comment provided by engineer. @@ -7913,12 +8672,16 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Unsupported connection link - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Aktualizovat @@ -8027,11 +8790,6 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use TCP port 443 for preset servers only. No comment provided by engineer. - - Use chat - Použijte chat - No comment provided by engineer. - Use current profile Použít aktuální profil @@ -8045,6 +8803,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use for messages No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections Použít pro nová připojení @@ -8080,6 +8842,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use private routing with unknown servers. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server Použít server @@ -8097,6 +8863,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use the app with one hand. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port No comment provided by engineer. @@ -8114,6 +8884,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Používat servery SimpleX Chat. No comment provided by engineer. + + Verify + relay test step + Verify code with desktop No comment provided by engineer. @@ -8168,6 +8942,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Video obdržíte, až bude váš kontakt online, vyčkejte prosím nebo zkontrolujte později! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Videa a soubory až do velikosti 1 gb @@ -8219,6 +8997,18 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Hlasová zpráva… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... No comment provided by engineer. @@ -8255,6 +9045,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Upozornění: můžete ztratit nějaká data! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers WebRTC servery ICE @@ -8301,6 +9095,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Pokud s někým sdílíte inkognito profil, bude tento profil použit pro skupiny, do kterých vás pozve. No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi No comment provided by engineer. @@ -8409,16 +9207,19 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Jste pozváni do skupiny No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -8482,9 +9283,13 @@ Repeat join request? Náhled oznámení na zamykací obrazovce můžete změnit v nastavení. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. - Můžete sdílet odkaz nebo QR kód - ke skupině se bude moci připojit kdokoli. O členy skupiny nepřijdete, pokud ji později odstraníte. + Můžete sdílet odkaz nebo QR kód - ke skupině se bude moci připojit kdokoli. O členy skupiny nepřijdete, pokud odkaz později smažete. No comment provided by engineer. @@ -8524,16 +9329,21 @@ Repeat join request? Nemůžete posílat zprávy! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. Nemohli jste být ověřeni; Zkuste to prosím znovu. No comment provided by engineer. - - You decide who can connect. - Lidé se s vámi mohou spojit pouze prostřednictvím odkazu, který sdílíte. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -8595,6 +9405,11 @@ Repeat connection request? You should receive notifications. token info + + You were born without an account + Narodili jste se bez účtu. + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. No comment provided by engineer. @@ -8628,6 +9443,10 @@ Repeat connection request? Stále budete přijímat volání a upozornění od umlčených profilů pokud budou aktivní. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. No comment provided by engineer. @@ -8671,6 +9490,10 @@ Repeat connection request? Vaše hovory No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database Vaše chatovací databáze @@ -8687,7 +9510,7 @@ Repeat connection request? Your chat profiles - Vaše chat profily + Vaše profily chatu No comment provided by engineer. @@ -8717,6 +9540,11 @@ Repeat connection request? Vaše kontakty zůstanou připojeny. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + Vaše konverzace patří vám, jako tomu bylo vždy před internetem. Síť není místo, které navštěvujete. Je to místo, které vytváříte a vlastníte. A nikdo vám ho nemůže vzít, ať už je soukromé, nebo veřejné. + No comment provided by engineer. + Your credentials may be sent unencrypted. No comment provided by engineer. @@ -8735,6 +9563,10 @@ Repeat connection request? Your group No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences Vaše preference @@ -8749,6 +9581,11 @@ Repeat connection request? Your profile No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Váš profil **%@** bude sdílen. @@ -8768,11 +9605,23 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message + + Your public address + No comment provided by engineer. + Your random profile Váš náhodný profil No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address Adresa vašeho serveru @@ -8787,21 +9636,11 @@ Repeat connection request? Vaše nastavení No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Přispějte](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Pošlete nám e-mail](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Hvězda na GitHubu](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_kurzíva_ @@ -8817,6 +9656,10 @@ Repeat connection request? výše, pak vyberte: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ rcv group event chat item @@ -8834,6 +9677,10 @@ Repeat connection request? accepted you rcv group event chat item + + active + No comment provided by engineer. + admin správce @@ -8934,6 +9781,10 @@ marked deleted chat item preview text volání… call status + + can't broadcast + No comment provided by engineer. + can't send messages No comment provided by engineer. @@ -8968,6 +9819,14 @@ marked deleted chat item preview text změna adresy… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored barevné @@ -9108,6 +9967,10 @@ pref value smazáno deleted chat item + + deleted channel + rcv group event chat item + deleted contact rcv direct event chat item @@ -9216,10 +10079,18 @@ pref value chyba No comment provided by engineer. + + error: %@ + receive error chat item + expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. @@ -9335,6 +10206,10 @@ pref value opustil rcv group event chat item + + link + No comment provided by engineer. + marked deleted označeno jako smazáno @@ -9401,6 +10276,10 @@ pref value nikdy delete after time + + new + No comment provided by engineer. + new message nová zpráva @@ -9416,6 +10295,10 @@ pref value bez šifrování e2e No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text žádný text @@ -9510,6 +10393,10 @@ time to disappear odmítnutý hovor call status + + relay + member role + removed odstraněno @@ -9520,6 +10407,14 @@ time to disappear odstraněno %@ rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address profile update event chat item @@ -9651,6 +10546,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile aktualizoval profil skupiny @@ -9669,6 +10568,10 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link prostřednictvím odkazu na kontaktní adresu @@ -9740,6 +10643,10 @@ last received msg: %2$@ jste pozorovatel No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ snd group event chat item @@ -9798,6 +10705,10 @@ last received msg: %2$@ \~stávka~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index e6131264af..872fafddd7 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -185,6 +185,24 @@ %d Monate time interval + + %d relays failed + %d Relais fehlgeschlagen + channel relay bar +channel subscriber relay bar + + + %d relays not active + %d Relais nicht aktiv + channel relay bar +channel subscriber relay bar + + + %d relays removed + %d Relais entfernt + channel relay bar +channel subscriber relay bar + %d sec %d s @@ -200,11 +218,63 @@ %d übersprungene Nachricht(en) integrity error chat item + + %d subscriber + %d Abonnent + channel subscriber count + + + %d subscribers + %d Abonnenten + channel subscriber count + %d weeks %d Wochen time interval + + %1$d/%2$d relays active + %1$d/%2$d Relais aktiv + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + %1$d/%2$d Relais aktiv, %3$d Fehler + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d Relais aktiv, %3$d fehlgeschlagen + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + %1$d/%2$d Relais aktiv, %3$d entfernt + channel relay bar + + + %1$d/%2$d relays connected + %1$d/%2$d Relais verbunden + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d Relais verbunden, %3$d Fehler + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d Relais verbunden, %3$d fehlgeschlagen + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d Relais verbunden, %3$d entfernt + channel subscriber relay bar + %lld %lld @@ -215,6 +285,11 @@ %lld %@ No comment provided by engineer. + + %lld channel events + %lld Kanalereignisse + No comment provided by engineer. + %lld contact(s) selected %lld Kontakt(e) ausgewählt @@ -315,11 +390,21 @@ %u übersprungene Nachrichten. No comment provided by engineer. + + (from owner) + (vom Eigentümer) + chat link info line + (new) (Neu) No comment provided by engineer. + + (signed) + (signiert) + chat link info line + (this device v%@) (Dieses Gerät hat v%@) @@ -365,6 +450,11 @@ **Link scannen / einfügen**: Um eine Verbindung über den Link herzustellen, den Sie erhalten haben. No comment provided by engineer. + + **Test relay** to retrieve its name. + **Relais testen** um seinen Namen abzurufen. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Warnung**: Sofortige Push-Benachrichtigungen erfordern die Eingabe eines Passworts, welches in Ihrem Schlüsselbund gespeichert ist. @@ -408,6 +498,15 @@ - und mehr! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + - Opt‑in zum Senden von Linkvorschauen. +- Hyperlink‑Phishing verhindern. +- Link‑Tracking entfernen. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +605,11 @@ time interval Ein paar weitere Dinge No comment provided by engineer. + + A link for one person to connect + Verbindungs-Link für eine Person + No comment provided by engineer. + A new contact Ein neuer Kontakt @@ -632,9 +736,9 @@ swipe action Aktive Verbindungen No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. + Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre SimpleX-Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre SimpleX-Kontakte gesendet. No comment provided by engineer. @@ -702,6 +806,11 @@ swipe action Nachrichtenserver hinzugefügt No comment provided by engineer. + + Adding relays will be supported later. + Das Hinzufügen von Relais wird zu einem späteren Zeitpunkt unterstützt. + No comment provided by engineer. + Additional accent Erste Akzentfarbe @@ -744,7 +853,7 @@ swipe action Admins can create the links to join groups. - Administratoren können Links für den Beitritt zu Gruppen erzeugen. + Administratoren können Links für den Beitritt zu Gruppen erstellen. No comment provided by engineer. @@ -792,6 +901,11 @@ swipe action Alle Gruppenmitglieder bleiben verbunden. No comment provided by engineer. + + All messages + Alle Nachrichten + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Alle Nachrichten und Dateien werden **Ende-zu-Ende verschlüsselt** versendet - in Direkt-Nachrichten mit Post-Quantum-Security. @@ -817,6 +931,16 @@ swipe action Alle Profile profile dropdown + + All relays failed + Alle Relais fehlgeschlagen + No comment provided by engineer. + + + All relays removed + Alle Relais entfernt + No comment provided by engineer. + All reports will be archived for you. Alle Meldungen werden für Sie archiviert. @@ -877,6 +1001,11 @@ swipe action Erlauben Sie das unwiederbringliche Löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt. (24 Stunden) No comment provided by engineer. + + Allow members to chat with admins. + Mitgliedern den Chat mit Administratoren erlauben. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Erlauben Sie Reaktionen auf Nachrichten nur dann, wenn es Ihr Kontakt ebenfalls erlaubt. @@ -892,6 +1021,11 @@ swipe action Das Senden von Direktnachrichten an Gruppenmitglieder erlauben. No comment provided by engineer. + + Allow sending direct messages to subscribers. + Das Senden von Direktnachrichten an Abonnenten erlauben. + No comment provided by engineer. + Allow sending disappearing messages. Das Senden von verschwindenden Nachrichten erlauben. @@ -902,6 +1036,11 @@ swipe action Teilen erlauben No comment provided by engineer. + + Allow subscribers to chat with admins. + Abonnenten den Chat mit Administratoren erlauben. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Unwiederbringliches löschen von gesendeten Nachrichten erlauben. (24 Stunden) @@ -1007,11 +1146,6 @@ swipe action Anruf annehmen No comment provided by engineer. - - Anybody can host servers. - Jeder kann seine eigenen Server aufsetzen. - No comment provided by engineer. - App build: %@ App Build: %@ @@ -1142,6 +1276,11 @@ swipe action Audio- und Videoanrufe No comment provided by engineer. + + Audio call + Audioanruf + No comment provided by engineer. + Audio/video calls Audio-/Video-Anrufe @@ -1212,6 +1351,23 @@ swipe action Ungültiger Nachrichten-Hash No comment provided by engineer. + + Be free +in your network + Seien Sie frei +in Ihrem Netzwerk + No comment provided by engineer. + + + Be free in your network. + Genießen Sie die Freiheit in Ihrem Netzwerk. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Weil wir die Macht zerstört haben, zu wissen, wer Sie sind. Damit Ihnen Ihre Macht niemals genommen werden kann. + No comment provided by engineer. + Better calls Verbesserte Anrufe @@ -1307,6 +1463,11 @@ swipe action Mitglied blockieren? No comment provided by engineer. + + Block subscriber for all? + Abonnent für alle blockieren? + No comment provided by engineer. + Blocked by admin wurde vom Administrator blockiert @@ -1357,6 +1518,16 @@ swipe action Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden. No comment provided by engineer. + + Bottom bar + Untere Leiste + No comment provided by engineer. + + + Broadcast + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bulgarisch, Finnisch, Thailändisch und Ukrainisch - Dank der Nutzer und [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1365,7 +1536,7 @@ swipe action Business address Geschäftliche Adresse - No comment provided by engineer. + chat link info line Business chats @@ -1387,15 +1558,6 @@ swipe action Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden: -- nur legale Inhalte in öffentlichen Gruppen zu versenden. -- andere Nutzer zu respektieren - kein Spam. - No comment provided by engineer. - Call already ended! Anruf ist bereits beendet! @@ -1544,6 +1706,82 @@ new chat action authentication reason set passcode view + + Channel + Kanal + No comment provided by engineer. + + + Channel display name + Anzeigename des Kanals + No comment provided by engineer. + + + Channel full name (optional) + Vollständiger Kanalname (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + Der Kanal hat keine aktiven Relais. Bitte später erneut versuchen. + alert message +alert subtitle + + + Channel image + Kanalbild + No comment provided by engineer. + + + Channel link + Kanallink + chat link info line + + + Channel preferences + Kanal-Präferenzen + No comment provided by engineer. + + + Channel profile + Kanalprofil + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + Das Kanalprofil wird auf den Geräten der Abonnenten und auf den Chat‑Relais gespeichert. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + Das Kanalprofil wurde geändert. Beim Speichern wird das aktualisierte Profil an die Abonnenten des Kanals gesendet. + alert message + + + Channel temporarily unavailable + Der Kanal ist vorübergehend nicht erreichbar + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + Der Kanal wird für alle Abonnenten gelöscht. Dies kann nicht rückgängig gemacht werden! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + Der Kanal wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + Der Kanal wird mit %1$d von %2$d Relais gestartet. Fortfahren? + alert message + + + Channels + Kanäle + No comment provided by engineer. + Chat Chat @@ -1629,6 +1867,26 @@ set passcode view Benutzerprofil No comment provided by engineer. + + Chat relay + Chat-Relais + No comment provided by engineer. + + + Chat relays + Chat-Relais + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + Chat‑Relais leiten Nachrichten in den von Ihnen erstellten Kanälen weiter. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + Chat‑Relais leiten Nachrichten an Kanal-Abonnenten weiter. + No comment provided by engineer. + Chat theme Chat-Design @@ -1647,7 +1905,8 @@ set passcode view Chat with admins Chat mit Administratoren - chat toolbar + chat feature +chat toolbar Chat with member @@ -1664,11 +1923,26 @@ set passcode view Chats No comment provided by engineer. + + Chats with admins are prohibited. + Chats mit Administratoren sind nicht erlaubt. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + Chats mit Administratoren in öffentlichen Kanälen sind nicht Ende‑zu‑Ende‑verschlüsselt – bitte nur über vertrauenswürdige Chat‑Relais nutzen. + alert message + Chats with members Chats mit Mitgliedern No comment provided by engineer. + + Chats with members are disabled + Chats mit Mitgliedern sind deaktiviert + No comment provided by engineer. + Check messages every 20 min. Alle 20min Nachrichten überprüfen. @@ -1679,6 +1953,16 @@ set passcode view Wenn es erlaubt ist, Nachrichten überprüfen. No comment provided by engineer. + + Check relay address and try again. + Relais-Adresse überprüfen und erneut versuchen. + alert message + + + Check relay name and try again. + Relais-Name überprüfen und erneut versuchen. + alert message + Check server address and try again. Überprüfen Sie die Serveradresse und versuchen Sie es nochmal. @@ -1802,7 +2086,7 @@ set passcode view Conditions of use Nutzungsbedingungen - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1824,9 +2108,9 @@ set passcode view ICE-Server konfigurieren No comment provided by engineer. - - Configure server operators - Server-Betreiber konfigurieren + + Configure relays + Relais konfigurieren No comment provided by engineer. @@ -1887,7 +2171,8 @@ set passcode view Connect Verbinden - server test step + relay test step +server test step Connect automatically @@ -1933,6 +2218,11 @@ Das ist Ihr eigener Einmal-Link! Über einen Link verbinden new chat sheet title + + Connect via link or QR code + Über einen Link oder QR-Code verbinden + No comment provided by engineer. + Connect via one-time link Über einen Einmal-Link verbinden @@ -2011,12 +2301,17 @@ Das ist Ihr eigener Einmal-Link! Connection error (AUTH) Verbindungsfehler (AUTH) + conn error description + + + Connection failed + Verbindung fehlgeschlagen No comment provided by engineer. Connection is blocked by server operator: %@ - Die Verbindung wurde vom Server-Betreiber blockiert: + Die Verbindung wurde vom Serverbetreiber blockiert: %@ No comment provided by engineer. @@ -2065,6 +2360,11 @@ Das ist Ihr eigener Einmal-Link! Verbindungen No comment provided by engineer. + + Contact address + Kontaktadresse + chat link info line + Contact allows Der Kontakt erlaubt @@ -2135,6 +2435,11 @@ Das ist Ihr eigener Einmal-Link! Weiter No comment provided by engineer. + + Contribute + Unterstützen Sie uns + No comment provided by engineer. + Conversation deleted! Chat-Inhalte entfernt! @@ -2163,12 +2468,7 @@ Das ist Ihr eigener Einmal-Link! Correct name to %@? Richtiger Name für %@? - No comment provided by engineer. - - - Create - Erstellen - No comment provided by engineer. + alert message Create 1-time link @@ -2182,7 +2482,7 @@ Das ist Ihr eigener Einmal-Link! Create a group using a random profile. - Erstellen Sie eine Gruppe mit einem zufälligen Profil. + Gruppe mit einem zufälligen Profil erstellen. No comment provided by engineer. @@ -2202,7 +2502,7 @@ Das ist Ihr eigener Einmal-Link! Create link - Link erzeugen + Link erstellen No comment provided by engineer. @@ -2220,9 +2520,19 @@ Das ist Ihr eigener Einmal-Link! Profil erstellen No comment provided by engineer. + + Create public channel + Öffentlichen Kanal erstellen + No comment provided by engineer. + + + Create public channel (BETA) + Öffentlichen Kanal erstellen (BETA) + No comment provided by engineer. + Create queue - Erzeuge Warteschlange + Warteschlange erstellen server test step @@ -2230,9 +2540,19 @@ Das ist Ihr eigener Einmal-Link! Ihre Adresse erstellen No comment provided by engineer. + + Create your link + Ihren Link erstellen + No comment provided by engineer. + Create your profile - Erstellen Sie Ihr Profil + Ihr Profil erstellen + No comment provided by engineer. + + + Create your public address + Ihre öffentliche Adresse erstellen No comment provided by engineer. @@ -2255,6 +2575,11 @@ Das ist Ihr eigener Einmal-Link! Archiv-Link erzeugen No comment provided by engineer. + + Creating channel + Kanal wird erstellt + No comment provided by engineer. + Creating link… Link wird erstellt… @@ -2413,10 +2738,10 @@ Das ist Ihr eigener Einmal-Link! Debugging-Zustellung No comment provided by engineer. - - Decentralized - Dezentral - No comment provided by engineer. + + Decode link + Link dekodieren + relay test step Decryption error @@ -2464,6 +2789,16 @@ swipe action Kontakt löschen und benachrichtigen No comment provided by engineer. + + Delete channel + Kanal löschen + No comment provided by engineer. + + + Delete channel? + Kanal löschen? + No comment provided by engineer. + Delete chat Chat löschen @@ -2579,6 +2914,16 @@ swipe action Nachricht des Mitglieds löschen? No comment provided by engineer. + + Delete member messages + Mitgliedsnachrichten löschen + No comment provided by engineer. + + + Delete member messages? + Mitgliedsnachrichten löschen? + alert title + Delete message? Die Nachricht löschen? @@ -2587,7 +2932,8 @@ swipe action Delete messages Nachrichten löschen - alert button + alert action +alert button Delete messages after @@ -2624,6 +2970,11 @@ swipe action Lösche Warteschlange server test step + + Delete relay + Relais löschen + No comment provided by engineer. + Delete report Meldung löschen @@ -2789,6 +3140,16 @@ swipe action In dieser Gruppe sind Direktnachrichten zwischen Mitgliedern nicht erlaubt. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + Direktnachrichten zwischen Abonnenten sind nicht erlaubt. + No comment provided by engineer. + + + Disable + Deaktivieren + alert button + Disable (keep overrides) Deaktivieren (vorgenommene Einstellungen bleiben erhalten) @@ -2894,6 +3255,11 @@ swipe action Den Nachrichtenverlauf nicht an neue Mitglieder senden. No comment provided by engineer. + + Do not send history to new subscribers. + Den Nachrichtenverlauf nicht an neue Abonnenten senden. + No comment provided by engineer. + Do not use credentials with proxy. Verwenden Sie keine Anmeldeinformationen mit einem Proxy. @@ -2995,11 +3361,21 @@ chat item action E2E-verschlüsselte Benachrichtigungen. No comment provided by engineer. + + Easier to invite your friends 👋 + Freunde einladen – jetzt noch einfacher 👋 + No comment provided by engineer. + Edit Bearbeiten chat item action + + Edit channel profile + Kanalprofil bearbeiten + No comment provided by engineer. + Edit group profile Gruppenprofil bearbeiten @@ -3013,7 +3389,7 @@ chat item action Enable Aktivieren - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3035,6 +3411,11 @@ chat item action TCP-Keep-alive aktivieren No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + Aktivieren Sie mindestens ein Chat‑Relais unter 'Netzwerk & Server'. + channel creation warning + Enable automatic message deletion? Automatisches Löschen von Nachrichten aktivieren? @@ -3045,6 +3426,11 @@ chat item action Kamera-Zugriff aktivieren No comment provided by engineer. + + Enable chats with admins? + Chats mit Administratoren aktivieren? + alert title + Enable disappearing messages by default. Verschwindende Nachrichten sind per Voreinstellung aktiviert. @@ -3065,16 +3451,16 @@ chat item action Sofortige Benachrichtigungen aktivieren? No comment provided by engineer. + + Enable link previews? + Linkvorschau aktivieren? + alert title + Enable lock Sperre aktivieren No comment provided by engineer. - - Enable notifications - Benachrichtigungen aktivieren - No comment provided by engineer. - Enable periodic notifications? Periodische Benachrichtigungen aktivieren? @@ -3180,6 +3566,11 @@ chat item action Zugangscode eingeben No comment provided by engineer. + + Enter channel name… + Kanalname eingeben… + No comment provided by engineer. + Enter correct passphrase. Geben Sie das korrekte Passwort ein. @@ -3205,6 +3596,16 @@ chat item action Für die Anzeige das Passwort im Suchfeld eingeben! No comment provided by engineer. + + Enter profile name... + Profilname eingeben... + No comment provided by engineer. + + + Enter relay name… + Relais-Name eingeben… + No comment provided by engineer. + Enter server manually Geben Sie den Server manuell ein @@ -3233,7 +3634,7 @@ chat item action Error Fehler - No comment provided by engineer. + conn error description Error aborting address change @@ -3260,6 +3661,11 @@ chat item action Fehler beim Hinzufügen von Mitgliedern No comment provided by engineer. + + Error adding relay + Fehler beim Hinzufügen des Relais + alert title + Error adding server Fehler beim Hinzufügen des Servers @@ -3310,11 +3716,21 @@ chat item action Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Fehler beim Herstellen der Verbindung zum Server, der für den Empfang von Nachrichten dieser Verbindung genutzt wird: %@ + subscription status explanation + Error creating address Fehler beim Erstellen der Adresse No comment provided by engineer. + + Error creating channel + Fehler beim Erstellen des Kanals + alert title + Error creating group Fehler beim Erzeugen der Gruppe @@ -3450,11 +3866,6 @@ chat item action Fehler beim Öffnen des Chat No comment provided by engineer. - - Error opening group - Fehler beim Vorbereiten der Gruppe - No comment provided by engineer. - Error receiving file Fehler beim Herunterladen der Datei @@ -3500,6 +3911,11 @@ chat item action Fehler beim Speichern der ICE-Server No comment provided by engineer. + + Error saving channel profile + Fehler beim Speichern des Kanalprofils + No comment provided by engineer. + Error saving chat list Fehler beim Speichern der Chat-Liste @@ -3565,6 +3981,11 @@ chat item action Fehler beim Setzen von Empfangsbestätigungen! No comment provided by engineer. + + Error sharing channel + Fehler beim Teilen des Kanals + alert title + Error starting chat Fehler beim Starten des Chats @@ -3644,7 +4065,9 @@ snd error text Error: %@. - server test error + Fehler: %@. + relay test error +server test error Error: URL is invalid @@ -3766,7 +4189,7 @@ snd error text File is blocked by server operator: %@. - Datei wurde vom Server-Betreiber blockiert: + Die Datei wurde vom Serverbetreiber blockiert: %@. file error text @@ -3845,6 +4268,11 @@ snd error text Dateien und Medien sind nicht erlaubt! No comment provided by engineer. + + Filter + Filter + No comment provided by engineer. + Filter unread and favorite chats. Nach ungelesenen und favorisierten Chats filtern. @@ -3872,19 +4300,23 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig - server test error + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein. + relay test error +server test error Fingerprint in server address does not match certificate: %@. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %@. No comment provided by engineer. @@ -3922,10 +4354,16 @@ snd error text Für alle Moderatoren No comment provided by engineer. + + For anyone to reach you + Damit Sie jeder erreichen kann + No comment provided by engineer. + For chat profile %@: Für das Chat-Profil %@: - servers error + servers error +servers warning For console @@ -4066,11 +4504,21 @@ Fehler: %2$@ GIFs und Sticker No comment provided by engineer. + + Get link + Link erhalten + relay test step + Get notified when mentioned. Bei Erwähnung benachrichtigt werden. No comment provided by engineer. + + Get started + Jetzt starten + No comment provided by engineer. + Good afternoon! Guten Nachmittag! @@ -4129,7 +4577,7 @@ Fehler: %2$@ Group link Gruppen-Link - No comment provided by engineer. + chat link info line Group links @@ -4241,6 +4689,11 @@ Fehler: %2$@ Der Nachrichtenverlauf wird nicht an neue Gruppenmitglieder gesendet. No comment provided by engineer. + + History is not sent to new subscribers. + Der Nachrichtenverlauf wird nicht an neue Abonnenten gesendet. + No comment provided by engineer. + How SimpleX works Wie SimpleX funktioniert @@ -4306,6 +4759,11 @@ Fehler: %2$@ Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + Kanäle, welche Sie erstellt haben oder denen Sie beigetreten sind, werden dauerhaft deaktiviert. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Tippen Sie unten auf **Später wiederholen**, wenn Sie den Chat jetzt benötigen (es wird Ihnen angeboten, die Datenbank bei einem Neustart der App zu migrieren). @@ -4326,16 +4784,16 @@ Fehler: %2$@ Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. + + Images + Bilder + No comment provided by engineer. + Immediately Sofort No comment provided by engineer. - - Immune to spam - Immun gegen Spam und Missbrauch - No comment provided by engineer. - Import Importieren @@ -4478,9 +4936,9 @@ Weitere Verbesserungen sind bald verfügbar! Anfängliche Rolle No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Installieren Sie [SimpleX Chat als Terminalanwendung](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Installieren Sie SimpleX Chat als Terminalanwendung No comment provided by engineer. @@ -4538,7 +4996,7 @@ Weitere Verbesserungen sind bald verfügbar! Invalid connection link Ungültiger Verbindungslink - No comment provided by engineer. + conn error description Invalid display name! @@ -4558,7 +5016,17 @@ Weitere Verbesserungen sind bald verfügbar! Invalid name! Ungültiger Name! - No comment provided by engineer. + alert title + + + Invalid relay address! + Ungültige Relais-Adresse! + alert title + + + Invalid relay name! + Ungültiger Relais-Name! + alert title Invalid response @@ -4585,11 +5053,21 @@ Weitere Verbesserungen sind bald verfügbar! Freunde einladen No comment provided by engineer. + + Invite member + Mitglied einladen + No comment provided by engineer. + Invite members Mitglieder einladen No comment provided by engineer. + + Invite someone privately + Für privaten Chat einladen + No comment provided by engineer. + Invite to chat Zum Chat einladen @@ -4666,6 +5144,11 @@ Weitere Verbesserungen sind bald verfügbar! Als %@ beitreten No comment provided by engineer. + + Join channel + Kanal beitreten + No comment provided by engineer. + Join group Treten Sie der Gruppe bei @@ -4753,6 +5236,16 @@ Das ist Ihr Link für die Gruppe %@! Verlassen swipe action + + Leave channel + Kanal verlassen + No comment provided by engineer. + + + Leave channel? + Kanal verlassen? + No comment provided by engineer. + Leave chat Chat verlassen @@ -4778,6 +5271,11 @@ Das ist Ihr Link für die Gruppe %@! Weniger Datenverkehr in mobilen Netzen. No comment provided by engineer. + + Let someone connect to you + Jemand mit Ihnen verbinden lassen + No comment provided by engineer. + Let's talk in SimpleX Chat Lassen Sie uns in SimpleX Chat kommunizieren @@ -4798,6 +5296,11 @@ Das ist Ihr Link für die Gruppe %@! Verknüpfe Mobiltelefon- und Desktop-Apps! 🔗 No comment provided by engineer. + + Link signature verified. + Linksignatur erfolgreich überprüft. + owner verification + Linked desktop options Verknüpfte Desktop-Optionen @@ -4808,6 +5311,11 @@ Das ist Ihr Link für die Gruppe %@! Verknüpfte Desktops No comment provided by engineer. + + Links + Links + No comment provided by engineer. + List Liste @@ -4933,6 +5441,11 @@ Das ist Ihr Link für die Gruppe %@! Mitglied ist gelöscht - Anfrage kann nicht angenommen werden No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden! + alert message + Member reports Mitglieder-Meldungen @@ -4956,12 +5469,12 @@ Das ist Ihr Link für die Gruppe %@! Member will be removed from chat - this cannot be undone! Das Mitglied wird aus dem Chat entfernt. Dies kann nicht rückgängig gemacht werden! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4973,6 +5486,11 @@ Das ist Ihr Link für die Gruppe %@! Gruppenmitglieder können eine Reaktion auf Nachrichten geben. No comment provided by engineer. + + Members can chat with admins. + Mitglieder können mit Administratoren chatten. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden) @@ -5038,6 +5556,11 @@ Das ist Ihr Link für die Gruppe %@! Nachrichtenentwurf No comment provided by engineer. + + Message error + Übertragungsfehler + No comment provided by engineer. + Message forwarded Nachricht weitergeleitet @@ -5133,6 +5656,16 @@ Das ist Ihr Link für die Gruppe %@! Die Nachrichten von %@ werden angezeigt! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + Nachrichten in diesem Kanal sind **nicht Ende‑zu‑Ende‑verschlüsselt**. Chat‑Relais können diese Nachrichten sehen. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + Nachrichten in diesem Kanal sind nicht Ende‑zu‑Ende‑verschlüsselt. Chat‑Relais können diese Nachrichten sehen. + E2EE info chat item + Messages in this chat will never be deleted. Nachrichten in diesem Chat werden nie gelöscht. @@ -5163,16 +5696,16 @@ Das ist Ihr Link für die Gruppe %@! Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt. No comment provided by engineer. + + Migrate + Migrieren + No comment provided by engineer. + Migrate device Gerät migrieren No comment provided by engineer. - - Migrate from another device - Von einem anderen Gerät migrieren - No comment provided by engineer. - Migrate here Hierher migrieren @@ -5293,6 +5826,11 @@ Das ist Ihr Link für die Gruppe %@! Netzwerk & Server No comment provided by engineer. + + Network commitments + Netzwerk Verpflichtungen + No comment provided by engineer. + Network connection Netzwerkverbindung @@ -5303,6 +5841,11 @@ Das ist Ihr Link für die Gruppe %@! Dezentralisiertes Netzwerk No comment provided by engineer. + + Network error + Netzwerk-Fehler + conn error description + Network issues - message expired after many attempts to send it. Netzwerk-Fehler - die Nachricht ist nach vielen Sende-Versuchen abgelaufen. @@ -5318,6 +5861,13 @@ Das ist Ihr Link für die Gruppe %@! Netzwerk-Betreiber No comment provided by engineer. + + Network routers cannot know +who talks to whom + Netzwerk‑Router können nicht erkennen, +wer mit wem kommuniziert + No comment provided by engineer. + Network settings Netzwerkeinstellungen @@ -5326,13 +5876,18 @@ Das ist Ihr Link für die Gruppe %@! Network status Netzwerkstatus - No comment provided by engineer. + alert title New Neu token status text + + New 1-time link + Neuer Einmal-Link + No comment provided by engineer. + New Passcode Neuer Zugangscode @@ -5358,6 +5913,11 @@ Das ist Ihr Link für die Gruppe %@! Neue Chat-Erfahrung 🎉 No comment provided by engineer. + + New chat relay + Neues Chat-Relais + No comment provided by engineer. + New contact request Neue Kontaktanfrage @@ -5428,11 +5988,33 @@ Das ist Ihr Link für die Gruppe %@! Nein No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + Kein Account. Keine Telefonnummer. Keine E‑Mail. Keine ID. +Die sicherste Verschlüsselung. + No comment provided by engineer. + + + No active relays + Keine aktiven Relais + No comment provided by engineer. + No app password Kein App-Passwort Authentication unavailable + + No chat relays + Keine Chat-Relais + No comment provided by engineer. + + + No chat relays enabled. + Es sind keine Chat-Relais aktiviert. + servers warning + No chats Keine Chats @@ -5578,11 +6160,26 @@ Das ist Ihr Link für die Gruppe %@! Keine ungelesenen Chats No comment provided by engineer. - - No user identifiers. - Keine Benutzerkennungen. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + Niemand verfolgte Ihre Gespräche. Niemand erstellte eine Karte, wo Sie sich aufgehalten haben. Privatsphäre war nie ein Feature - sie war selbstverständlich. No comment provided by engineer. + + Non-profit governance + Non‑Profit‑Governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + Nicht ein besseres Schloss an der Tür eines Anderen. Kein freundlicher Vermieter, der Ihre Privatsphäre respektiert, aber dennoch jeden Besucher registriert. Sie sind kein Gast. Sie sind zu Hause. Kein Vermieter, kein Fremder kann es betreten - Sie sind souverän. + No comment provided by engineer. + + + Not all relays connected + Es sind nicht alle Relais verbunden + alert title + Not compatible! Nicht kompatibel! @@ -5640,7 +6237,7 @@ Das ist Ihr Link für die Gruppe %@! OK OK - No comment provided by engineer. + alert button Off @@ -5659,11 +6256,21 @@ new chat action Alte Datenbank No comment provided by engineer. + + On your phone, not on servers. + Auf Ihrem Gerät, nicht auf Servern. + No comment provided by engineer. + One-time invitation link Einmal-Einladungslink No comment provided by engineer. + + One-time link + Einmal-Link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5683,9 +6290,14 @@ Dies erfordert die Aktivierung eines VPNs. Onion-Hosts werden nicht verwendet. No comment provided by engineer. + + Only channel owners can change channel preferences. + Kanal-Präferenzen können nur von Kanal-Eigentümern geändert werden. + No comment provided by engineer. + Only chat owners can change preferences. - Nur Chat-Eigentümer können die Präferenzen ändern. + Präferenzen können nur von Chat-Eigentümern geändert werden. No comment provided by engineer. @@ -5786,7 +6398,8 @@ Dies erfordert die Aktivierung eines VPNs. Open Öffnen - alert action + alert action +alert button Open Settings @@ -5798,6 +6411,11 @@ Dies erfordert die Aktivierung eines VPNs. Änderungen öffnen No comment provided by engineer. + + Open channel + Kanal öffnen + new chat action + Open chat Chat öffnen @@ -5818,6 +6436,11 @@ Dies erfordert die Aktivierung eines VPNs. Nutzungsbedingungen öffnen No comment provided by engineer. + + Open external link? + Externen Link öffnen? + alert title + Open full link Vollständigen Link öffnen @@ -5838,6 +6461,11 @@ Dies erfordert die Aktivierung eines VPNs. Migration auf ein anderes Gerät öffnen authentication reason + + Open new channel + Neuen Kanal öffnen + new chat action + Open new chat Neuen Chat öffnen @@ -5883,6 +6511,17 @@ Dies erfordert die Aktivierung eines VPNs. Betreiber-Server alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + Betreiber verpflichten sich: +- Unabhängig zu bleiben +- Metadaten auf ein Minimum zu reduzieren +- Geprüften Open‑Source‑Code einzusetzen + No comment provided by engineer. + Or import archive file Oder importieren Sie eine Archiv-Datei @@ -5903,6 +6542,11 @@ Dies erfordert die Aktivierung eines VPNs. Oder teilen Sie diesen Datei-Link sicher No comment provided by engineer. + + Or show QR in person or via video call. + Oder den QR‑Code persönlich oder per Videoanruf zeigen. + No comment provided by engineer. + Or show this code Oder diesen QR-Code anzeigen @@ -5913,6 +6557,11 @@ Dies erfordert die Aktivierung eines VPNs. Oder zum privaten Teilen No comment provided by engineer. + + Or use this QR - print or show online. + Oder diesen QR‑Code verwenden – ausgedruckt oder online. + No comment provided by engineer. + Organize chats into lists Chats in Listen verwalten @@ -5930,6 +6579,21 @@ Dies erfordert die Aktivierung eines VPNs. %@ alert message + + Owner + Eigentümer + No comment provided by engineer. + + + Owners + Eigentümer + No comment provided by engineer. + + + Ownership: you can run your own relays. + Volle Kontrolle: Sie können Ihre eigenen Relais betreiben. + No comment provided by engineer. + PING count PING-Zähler @@ -5985,6 +6649,11 @@ Dies erfordert die Aktivierung eines VPNs. Bild einfügen No comment provided by engineer. + + Paste link / Scan + Link einfügen / Scannen + No comment provided by engineer. + Paste link to connect! Zum Verbinden den Link einfügen! @@ -6139,6 +6808,16 @@ Fehler: %@ Den letzten Nachrichtenentwurf, auch mit seinen Anhängen, aufbewahren. No comment provided by engineer. + + Preset relay address + Voreingestellte Relais-Adresse + No comment provided by engineer. + + + Preset relay name + Voreingestellter Relais-Name + No comment provided by engineer. + Preset server address Voreingestellte Serveradresse @@ -6174,14 +6853,14 @@ Fehler: %@ Datenschutz- und Nutzungsbedingungen. No comment provided by engineer. - - Privacy redefined - Datenschutz neu definiert + + Privacy: for owners and subscribers. + Privatsphäre: für Besitzer und Abonnenten. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich. + + Private and secure messaging. + Private und sichere Kommunikation. No comment provided by engineer. @@ -6224,6 +6903,11 @@ Fehler: %@ Zeitüberschreitung der privaten Routing-Sitzung alert title + + Proceed + Fortfahren + alert action + Profile and server connections Profil und Serververbindungen @@ -6249,9 +6933,9 @@ Fehler: %@ Profil-Design No comment provided by engineer. - - Profile update will be sent to your contacts. - Profil-Aktualisierung wird an Ihre Kontakte gesendet. + + Profile update will be sent to your SimpleX contacts. + Profil-Aktualisierung wird an Ihre SimpleX-Kontakte gesendet. alert message @@ -6259,6 +6943,11 @@ Fehler: %@ Audio-/Video-Anrufe nicht erlauben. No comment provided by engineer. + + Prohibit chats with admins. + Chat mit Administratoren nicht erlauben. + No comment provided by engineer. + Prohibit irreversible message deletion. Unwiederbringliches löschen von Nachrichten nicht erlauben. @@ -6289,6 +6978,11 @@ Fehler: %@ Das Senden von Direktnachrichten an Gruppenmitglieder nicht erlauben. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + Das Senden von Direktnachrichten an Abonnenten nicht erlauben. + No comment provided by engineer. + Prohibit sending disappearing messages. Das Senden von verschwindenden Nachrichten nicht erlauben. @@ -6356,6 +7050,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Der Proxy benötigt ein Passwort No comment provided by engineer. + + Public channels - speak freely 🚀 + Öffentliche Kanäle – frei sprechen 🚀 + No comment provided by engineer. + Push notifications Push-Benachrichtigungen @@ -6396,24 +7095,14 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Mehr erfahren No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Lesen Sie mehr dazu im [Benutzerhandbuch](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Lesen Sie mehr dazu im Benutzerhandbuch. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Mehr dazu in der [Benutzeranleitung](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses) lesen. - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Mehr dazu in der [Benutzeranleitung](https://simplex.chat/docs/guide/readme.html#connect-to-friends) lesen. - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Erfahren Sie in unserem [GitHub-Repository](https://github.com/simplex-chat/simplex-chat#readme) mehr dazu. + + Read more in our GitHub repository. + Erfahren Sie in unserem GitHub-Repository mehr dazu. No comment provided by engineer. @@ -6436,11 +7125,6 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Empfangen um: %@ copied message info - - Received file event - Datei-Ereignis empfangen - notification - Received message Empfangene Nachricht @@ -6578,6 +7262,31 @@ swipe action Mitglied ablehnen? alert title + + Relay + Relais + No comment provided by engineer. + + + Relay address + Relais-Adresse + alert title + + + Relay connection failed + Relais-Verbindung fehlgeschlagen + alert title + + + Relay link + Relais-Link + No comment provided by engineer. + + + Relay results: + Relay‑Status: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Relais-Server werden nur genutzt, wenn sie benötigt werden. Ihre IP-Adresse kann von Anderen erfasst werden. @@ -6588,10 +7297,25 @@ swipe action Relais-Server schützen Ihre IP-Adresse, aber sie können die Anrufdauer erfassen. No comment provided by engineer. + + Relay test failed! + Relais-Test fehlgeschlagen! + No comment provided by engineer. + + + Reliability: many relays per channel. + Zuverlässigkeit: mehrere Relais pro Kanal. + No comment provided by engineer. + Remove Entfernen - No comment provided by engineer. + alert action + + + Remove and delete messages + Mitglied entfernen und Nachrichten löschen + alert action Remove archive? @@ -6616,13 +7340,23 @@ swipe action Remove member? Das Mitglied entfernen? - No comment provided by engineer. + alert title Remove passphrase from keychain? Passwort aus dem Schlüsselbund entfernen? No comment provided by engineer. + + Remove subscriber + Abonnent entfernen + No comment provided by engineer. + + + Remove subscriber? + Abonnent entfernen? + alert title + Removes messages and blocks members. Entfernt Nachrichten und blockiert Mitglieder. @@ -6858,6 +7592,11 @@ swipe action SOCKS-Proxy No comment provided by engineer. + + Safe web links + Sichere Web-Links + No comment provided by engineer. + Safely receive files Dateien sicher herunterladen @@ -6884,6 +7623,11 @@ chat item action Speichern (und Mitglieder benachrichtigen) alert button + + Save (and notify subscribers) + Speichern (Abonnenten benachrichtigen) + alert button + Save admission settings? Speichern der Aufnahme-Einstellungen? @@ -6899,6 +7643,11 @@ chat item action Speichern und Gruppenmitglieder benachrichtigen No comment provided by engineer. + + Save and notify subscribers + Speichern und Abonnenten benachrichtigen + No comment provided by engineer. + Save and reconnect Speichern und neu verbinden @@ -6909,6 +7658,16 @@ chat item action Gruppen-Profil sichern und aktualisieren No comment provided by engineer. + + Save channel profile + Kanalprofil speichern + No comment provided by engineer. + + + Save channel profile? + Kanalprofil speichern? + alert title + Save group profile Gruppenprofil speichern @@ -7034,11 +7793,36 @@ chat item action In der Suchleiste werden nun auch Einladungslinks angenommen. No comment provided by engineer. + + Search files + Dateien suchen + No comment provided by engineer. + + + Search images + Bilder suchen + No comment provided by engineer. + + + Search links + Links suchen + No comment provided by engineer. + Search or paste SimpleX link Suchen oder SimpleX-Link einfügen No comment provided by engineer. + + Search videos + Videos suchen + No comment provided by engineer. + + + Search voice messages + Sprachnachrichten suchen + No comment provided by engineer. + Secondary Zweite Farbe @@ -7056,7 +7840,7 @@ chat item action Security assessment - Sicherheits-Gutachten + Security-Gutachten No comment provided by engineer. @@ -7064,6 +7848,11 @@ chat item action Sicherheitscode No comment provided by engineer. + + Security: owners hold channel keys. + Sicherheit: Eigentümer besitzen die Kanalschlüssel. + No comment provided by engineer. + Select Auswählen @@ -7081,7 +7870,7 @@ chat item action Selected chat preferences prohibit this message. - Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt. + Diese Nachricht ist wegen der gewählten Chat-Präferenzen nicht erlaubt. No comment provided by engineer. @@ -7141,7 +7930,7 @@ chat item action Send link previews - Link-Vorschau senden + Linkvorschau senden No comment provided by engineer. @@ -7194,6 +7983,11 @@ chat item action Anfrage ohne Nachricht senden No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + Den Link über einen beliebigen Messenger versenden – es ist sicher. Bitte in SimpleX einfügen. + No comment provided by engineer. + Send them from gallery or custom keyboards. Senden Sie diese aus dem Fotoalbum oder von individuellen Tastaturen. @@ -7204,6 +7998,11 @@ chat item action Bis zu 100 der letzten Nachrichten an neue Gruppenmitglieder senden. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + Bis zu 100 der letzten Nachrichten an neue Abonnenten senden. + No comment provided by engineer. + Send your private feedback to groups. Senden Sie Ihr privates Feedback an Gruppen. @@ -7219,6 +8018,11 @@ chat item action Der Absender hat möglicherweise die Verbindungsanfrage gelöscht. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + Das Senden einer Link-Vorschau kann Ihre IP‑Adresse an die Website übermitteln. Sie können dies später in den Datenschutzeinstellungen ändern. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Das Senden von Empfangsbestätigungen an alle Kontakte in allen sichtbaren Chat-Profilen wird aktiviert. @@ -7274,11 +8078,6 @@ chat item action Direkt gesendet No comment provided by engineer. - - Sent file event - Datei-Ereignis wurde gesendet - notification - Sent message Gesendete Nachricht @@ -7349,14 +8148,19 @@ chat item action Das Server-Protokoll wurde geändert. alert title + + Server requires authorization to connect to relay, check password. + Der Server erfordert eine Autorisierung, um eine Verbindung zum Relais herzustellen. Bitte Passwort überprüfen. + relay test error + Server requires authorization to create queues, check password. - Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort + Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort. server test error Server requires authorization to upload, check password. - Bitte das Passwort überprüfen - für den Upload benötigt der Server eine Berechtigung + Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort. server test error @@ -7479,6 +8283,16 @@ chat item action Die Einstellungen wurden geändert. alert message + + Setup notifications + Benachrichtigungen einrichten + No comment provided by engineer. + + + Setup routers + Router einrichten + No comment provided by engineer. + Shape profile images Form der Profil-Bilder @@ -7515,11 +8329,16 @@ chat item action Die Adresse öffentlich teilen No comment provided by engineer. - - Share address with contacts? - Die Adresse mit Kontakten teilen? + + Share address with SimpleX contacts? + Die Adresse mit SimpleX-Kontakten teilen? alert title + + Share channel + Kanal teilen + No comment provided by engineer. + Share from other apps. Aus anderen Apps heraus teilen. @@ -7545,6 +8364,11 @@ chat item action Profil teilen No comment provided by engineer. + + Share relay address + Relais-Adresse teilen + No comment provided by engineer. + Share this 1-time invite link Teilen Sie diesen Einmal-Einladungslink @@ -7555,9 +8379,14 @@ chat item action Mit SimpleX teilen No comment provided by engineer. - - Share with contacts - Mit Kontakten teilen + + Share via chat + Per Chat teilen + No comment provided by engineer. + + + Share with SimpleX contacts + Mit SimpleX-Kontakten teilen No comment provided by engineer. @@ -7687,7 +8516,7 @@ chat item action SimpleX channel link - SimpleX-Kanal-Link + SimpleX-Kanallink simplex link type @@ -7730,9 +8559,9 @@ chat item action Die SimpleX-Protokolle wurden von Trail of Bits überprüft. No comment provided by engineer. - - SimpleX relay link - SimpleX Relais-Link + + SimpleX relay address + SimpleX Relais-Adresse simplex link type @@ -7808,6 +8637,11 @@ report reason Quadratisch, kreisförmig oder irgendetwas dazwischen. No comment provided by engineer. + + Star on GitHub + Stern auf GitHub vergeben + No comment provided by engineer. + Start chat Starten Sie den Chat @@ -7908,6 +8742,78 @@ report reason Abonniert No comment provided by engineer. + + Subscriber + Abonnent + No comment provided by engineer. + + + Subscriber reports + Abonnenten-Meldungen + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + Abonnent wird aus dem Kanal entfernt. Dies kann nicht rückgängig gemacht werden! + alert message + + + Subscribers + Abonnenten + No comment provided by engineer. + + + Subscribers can add message reactions. + Abonnenten können eine Reaktion auf Nachrichten geben. + No comment provided by engineer. + + + Subscribers can chat with admins. + Abonnenten können mit Administratoren chatten. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + Abonnenten können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + Abonnenten können Nachrichten an Moderatoren melden. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + Abonnenten können SimpleX-Links versenden. + No comment provided by engineer. + + + Subscribers can send direct messages. + Abonnenten können Direktnachrichten versenden. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + Abonnenten können verschwindende Nachrichten versenden. + No comment provided by engineer. + + + Subscribers can send files and media. + Abonnenten können Dateien und Medien versenden. + No comment provided by engineer. + + + Subscribers can send voice messages. + Abonnenten können Sprachnachrichten versenden. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + Abonnenten verbinden sich über den Relais‑Link mit dem Kanal. +Die Relais-Adresse wurde zur Einrichtung dieses Relais für diesen Kanal verwendet. + No comment provided by engineer. + Subscription errors Fehler beim Abonnieren @@ -7988,6 +8894,11 @@ report reason Machen Sie ein Foto No comment provided by engineer. + + Talk to someone + Mit jemandem sprechen + No comment provided by engineer. + Tap Connect to chat Verbinden tippen, um zu chatten @@ -8003,9 +8914,9 @@ report reason Verbinden tippen, um den Bot zu nutzen. No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Tippen Sie im Menü auf SimpleX-Adresse erstellen, um sie später zu erstellen. + + Tap Join channel + Tippen, um dem Kanal beizutreten No comment provided by engineer. @@ -8025,7 +8936,7 @@ report reason Tap to activate profile. - Zum Aktivieren des Profils tippen. + Tippen, um das Profil zu aktivieren. No comment provided by engineer. @@ -8038,9 +8949,14 @@ report reason Zum Inkognito beitreten tippen No comment provided by engineer. + + Tap to open + Zum Öffnen tippen + No comment provided by engineer. + Tap to paste link - Zum Link einfügen tippen + Tippen, um den Link einzufügen No comment provided by engineer. @@ -8056,13 +8972,19 @@ report reason Test failed at step %@. Der Test ist beim Schritt %@ fehlgeschlagen. - server test failure + relay test failure +server test failure Test notifications Benachrichtigungen testen No comment provided by engineer. + + Test relay + Relais testen + No comment provided by engineer. + Test server Teste Server @@ -8115,6 +9037,11 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Durch Verwendung verschiedener Netzwerk-Betreiber für jede Unterhaltung schützt die App Ihre Privatsphäre. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + Die App hat diese Nachricht nach %lld Empfangsversuchen entfernt. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). Die App wird eine Bestätigung bei Downloads von unbekannten Datei-Servern anfordern (außer bei .onion). @@ -8130,6 +9057,11 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Der von Ihnen gescannte Code ist kein SimpleX-Link-QR-Code. No comment provided by engineer. + + The connection reached the limit of undelivered messages + Die Verbindung hat das Limit für nicht zugestellte Nachrichten erreicht + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. Diese Verbindung hat das Limit der nicht ausgelieferten Nachrichten erreicht. Ihr Kontakt ist möglicherweise offline. @@ -8155,9 +9087,11 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Die Verschlüsselung funktioniert und ein neues Verschlüsselungsabkommen ist nicht erforderlich. Es kann zu Verbindungsfehlern kommen! No comment provided by engineer. - - The future of messaging - Die nächste Generation von privatem Messaging + + The first network where you own +your contacts and groups. + Das erste Netzwerk, +in dem Sie Ihre Kontakte und Gruppen besitzen. No comment provided by engineer. @@ -8195,6 +9129,11 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Die alte Datenbank wurde während der Migration nicht entfernt. Sie kann gelöscht werden. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + Die älteste Freiheit des Menschen - mit einem anderen Menschen sprechen zu können, ohne beobachtet zu werden - gestützt auf einer Infrastruktur, die Sie nicht verraten kann. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**. @@ -8240,6 +9179,16 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Design No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + Dann sind wir online gegangen, und jede Plattform wollte Etwas von Ihnen - Ihren Namen, Ihre Nummer, Ihre Freunde. Wir akzeptierten, dass es der Preis mit Anderen zu kommunizieren ist, Jemandem preiszugeben, mit wem und wie wir miteinander kommunizieren. Jede Generation, Menschen und Technologien, kannten es nur so - Telefon, E-Mail, Messenger, soziale Medien. Es schien der einzig mögliche Weg zu sein. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + Es gibt einen anderen Weg. Ein Netzwerk ohne Telefonnummern, ohne Benutzernamen, ohne Benutzerkennungen und ohne jegliche Benutzeridentität. Ein Netzwerk, welches Menschen verbindet und verschlüsselte Nachrichten überträgt, ohne zu wissen, wer mit wem verbunden ist. + No comment provided by engineer. + These conditions will also apply for: **%@**. Diese Nutzungsbedingungen gelten auch für: **%@**. @@ -8305,6 +9254,16 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Diese Gruppe existiert nicht mehr. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + Dies ist eine Chat‑Relais-Adresse, welche nicht zum Verbinden verwendet werden kann. + alert message + + + This is your link for channel %@! + Dies ist Ihr Link für den Kanal %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden. @@ -8355,6 +9314,11 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Um unerwünschte Nachrichten zu verbergen. No comment provided by engineer. + + To make SimpleX Network last. + Für ein dauerhaftes SimpleX-Netzwerk. + No comment provided by engineer. + To make a new connection Um eine Verbindung mit einem neuen Kontakt zu erstellen @@ -8442,11 +9406,6 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen. No comment provided by engineer. - - Toggle chat list: - Chat-Liste umschalten: - No comment provided by engineer. - Toggle incognito when connecting. Inkognito beim Verbinden einschalten. @@ -8462,6 +9421,11 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Deckkraft der Symbolleiste No comment provided by engineer. + + Top bar + Obere Leiste + No comment provided by engineer. + Total Summe aller Abonnements @@ -8477,15 +9441,10 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Transport-Sitzungen No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Beim Versuch die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + subscription status explanation Turkish interface @@ -8532,6 +9491,11 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Mitglied freigeben? No comment provided by engineer. + + Unblock subscriber for all? + Abonnent für alle freigeben? + No comment provided by engineer. + Undelivered messages Nicht ausgelieferte Nachrichten @@ -8632,13 +9596,18 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Unsupported connection link Verbindungs-Link wird nicht unterstützt - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Bis zu 100 der letzten Nachrichten werden an neue Mitglieder gesendet. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + Bis zu 100 der letzten Nachrichten werden an neue Abonnenten gesendet. + No comment provided by engineer. + Update Aktualisieren @@ -8764,11 +9733,6 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s TCP-Port 443 nur für voreingestellte Server verwenden. No comment provided by engineer. - - Use chat - Verwenden Sie Chat - No comment provided by engineer. - Use current profile Aktuelles Profil nutzen @@ -8784,6 +9748,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Für Nachrichten verwenden No comment provided by engineer. + + Use for new channels + Für neue Kanäle verwenden + No comment provided by engineer. + Use for new connections Für neue Verbindungen nutzen @@ -8824,6 +9793,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Sie nutzen privates Routing mit unbekannten Servern. No comment provided by engineer. + + Use relay + Relais verwenden + No comment provided by engineer. + Use server Server nutzen @@ -8844,6 +9818,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Die App mit einer Hand bedienen. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + Diese Adresse in Ihrem Social‑Media‑Profil, auf Ihrer Webseite oder in Ihrer E‑Mail‑Signatur verwenden. + No comment provided by engineer. + Use web port Web-Port nutzen @@ -8864,6 +9843,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verwendung von SimpleX-Chat-Servern. No comment provided by engineer. + + Verify + Überprüfen + relay test step + Verify code with desktop Code mit dem Desktop überprüfen @@ -8924,6 +9908,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! No comment provided by engineer. + + Videos + Videos + No comment provided by engineer. + Videos and files up to 1gb Videos und Dateien bis zu 1GB @@ -8979,6 +9968,21 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Sprachnachrichten… No comment provided by engineer. + + Wait + Abwarten + alert action + + + Wait response + Antwort abwarten + relay test step + + + Waiting for channel owner to add relays. + Warte auf das Hinzufügen von Relais durch den Eigentümer des Kanals. + No comment provided by engineer. + Waiting for desktop... Es wird auf den Desktop gewartet... @@ -9019,6 +10023,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Warnung: Sie könnten einige Daten verlieren! No comment provided by engineer. + + We made connecting simpler for new users. + Wir haben das Verbinden für neue Nutzer vereinfacht. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICE-Server @@ -9069,6 +10078,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Wenn Sie ein Inkognito-Profil mit Jemandem teilen, wird dieses Profil auch für die Gruppen verwendet, für die Sie von diesem Kontakt eingeladen werden. No comment provided by engineer. + + Why SimpleX is built. + Warum SimpleX entwickelt wurde. + No comment provided by engineer. + WiFi WiFi @@ -9196,16 +10210,21 @@ Repeat join request? Verbindungsanfrage wiederholen? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + subscription status explanation You are invited to group Sie sind zu der Gruppe eingeladen No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Sie sind nicht mit diesen Servern verbunden. Zur Auslieferung von Nachrichten an diese Server wird privates Routing genutzt. @@ -9276,6 +10295,11 @@ Verbindungsanfrage wiederholen? Über die Geräte-Einstellungen können Sie die Benachrichtigungsvorschau im Sperrbildschirm erlauben. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + Sie können einen Link oder QR-Code teilen - damit kann jeder dem Kanal beitreten. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Sie können diesen Link oder QR-Code teilen - Damit kann jede Person der Gruppe beitreten. Wenn Sie den Link später löschen, werden Sie keine Gruppenmitglieder verlieren, die der Gruppe darüber beigetreten sind. @@ -9321,16 +10345,25 @@ Verbindungsanfrage wiederholen? Sie können keine Nachrichten versenden! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + Sie verpflichten sich dazu: +- nur legale Inhalte in öffentlichen Gruppen zu versenden +- andere Nutzer zu respektieren - kein Spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + Sie haben sich über diesen Relais‑Link mit dem Kanal verbunden. + No comment provided by engineer. + You could not be verified; please try again. Sie konnten nicht überprüft werden; bitte versuchen Sie es erneut. No comment provided by engineer. - - You decide who can connect. - Sie entscheiden, wer sich mit Ihnen verbinden kann. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9398,6 +10431,11 @@ Verbindungsanfrage wiederholen? Sie sollten Benachrichtigungen erhalten. token info + + You were born without an account + Sie wurden ohne eine Benutzerkennung geboren. + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. Sie können erst dann Nachrichten versenden, **sobald Ihre Anfrage angenommen wurde**. @@ -9433,6 +10471,11 @@ Verbindungsanfrage wiederholen? Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + Sie werden keine Nachrichten mehr aus diesem Kanal erhalten. Der Chatverlauf bleibt erhalten. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf bleibt erhalten. @@ -9478,6 +10521,11 @@ Verbindungsanfrage wiederholen? Anrufe No comment provided by engineer. + + Your channel + Ihr Kanal + No comment provided by engineer. + Your chat database Chat-Datenbank @@ -9528,6 +10576,11 @@ Verbindungsanfrage wiederholen? Ihre Kontakte bleiben weiterhin verbunden. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + Ihre Kommunikation gehört Ihnen, so wie es immer war, bevor es das Internet gab. Das Netzwerk ist kein Ort, den Sie besuchen. Es ist ein Ort, den Sie erschaffen und besitzen und Niemand kann es Ihnen nehmen, egal ob Sie es privat oder öffentlich machen. + No comment provided by engineer. + Your credentials may be sent unencrypted. Ihre Anmeldeinformationen können unverschlüsselt versendet werden. @@ -9548,6 +10601,11 @@ Verbindungsanfrage wiederholen? Ihre Gruppe No comment provided by engineer. + + Your network + Ihr Netzwerk + No comment provided by engineer. + Your preferences Ihre Präferenzen @@ -9563,6 +10621,13 @@ Verbindungsanfrage wiederholen? Mein Profil No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + Ihr Profil **%@** wird mit Kanal‑Relais und Abonnenten geteilt. +Relais können auf Kanalnachrichten zugreifen. + No comment provided by engineer. + Your profile **%@** will be shared. Ihr Profil **%@** wird geteilt. @@ -9583,11 +10648,26 @@ Verbindungsanfrage wiederholen? Ihr Profil wurde geändert. Wenn Sie es speichern, wird das aktualisierte Profil an alle Ihre Kontakte gesendet. alert message + + Your public address + Ihre öffentliche Adresse + No comment provided by engineer. + Your random profile Ihr Zufallsprofil No comment provided by engineer. + + Your relay address + Ihre Relais-Adresse + No comment provided by engineer. + + + Your relay name + Ihr Relais-Name + No comment provided by engineer. + Your server address Ihre Serveradresse @@ -9603,21 +10683,11 @@ Verbindungsanfrage wiederholen? Einstellungen No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Senden Sie uns eine E-Mail](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_kursiv_ @@ -9633,6 +10703,11 @@ Verbindungsanfrage wiederholen? Danach die gewünschte Aktion auswählen: No comment provided by engineer. + + accepted + Angenommen + No comment provided by engineer. + accepted %@ %@ angenommen @@ -9653,6 +10728,11 @@ Verbindungsanfrage wiederholen? hat Sie angenommen rcv group event chat item + + active + Aktiv + No comment provided by engineer. + admin Admin @@ -9730,7 +10810,7 @@ Verbindungsanfrage wiederholen? blocked %@ - %@ wurde blockiert + hat %@ blockiert rcv group event chat item @@ -9764,6 +10844,11 @@ marked deleted chat item preview text Anrufen… call status + + can't broadcast + Broadcast nicht möglich + No comment provided by engineer. + can't send messages Es können keine Nachrichten gesendet werden @@ -9799,6 +10884,16 @@ marked deleted chat item preview text Wechsel der Empfängeradresse wurde gestartet… chat item text + + channel + Kanal + shown as sender role for channel messages + + + channel profile updated + Kanalprofil wurde aktualisiert + snd group event chat item + colored farbig @@ -9821,7 +10916,7 @@ marked deleted chat item preview text connecting - verbinde + Verbinde No comment provided by engineer. @@ -9841,7 +10936,7 @@ marked deleted chat item preview text connecting (introduction invitation) - Verbinde (nach einer Einladung) + Verbindung (nach einer Einladung) No comment provided by engineer. @@ -9945,6 +11040,11 @@ pref value Gelöscht deleted chat item + + deleted channel + Kanal gelöscht + rcv group event chat item + deleted contact Gelöschter Kontakt @@ -9967,7 +11067,7 @@ pref value disabled - deaktiviert + Deaktiviert No comment provided by engineer. @@ -10055,11 +11155,21 @@ pref value Fehler No comment provided by engineer. + + error: %@ + Fehler: %@ + receive error chat item + expired Abgelaufen No comment provided by engineer. + + failed + Fehlgeschlagen + No comment provided by engineer. + forwarded weitergeleitet @@ -10180,6 +11290,11 @@ pref value hat die Gruppe verlassen rcv group event chat item + + link + Link + No comment provided by engineer. + marked deleted als gelöscht markiert @@ -10197,7 +11312,7 @@ pref value connected - ist der Gruppe beigetreten + Verbunden rcv group event chat item @@ -10250,6 +11365,11 @@ pref value nie delete after time + + new + Neu + No comment provided by engineer. + new message Neue Nachricht @@ -10265,6 +11385,11 @@ pref value Keine E2E-Verschlüsselung No comment provided by engineer. + + no subscription + Kein Abonnement + No comment provided by engineer. + no text Kein Text @@ -10368,6 +11493,11 @@ time to disappear Abgelehnter Anruf call status + + relay + Relais + member role + removed entfernt @@ -10378,6 +11508,16 @@ time to disappear hat %@ aus der Gruppe entfernt rcv group event chat item + + removed (%d attempts) + Entfernt (%d Versuche) + receive error chat item + + + removed by operator + Vom Betreiber entfernt + No comment provided by engineer. + removed contact address Die Kontaktadresse wurde entfernt @@ -10509,7 +11649,7 @@ Zuletzt empfangene Nachricht: %2$@ unblocked %@ - %@ wurde freigegeben + hat %@ freigegeben rcv group event chat item @@ -10532,6 +11672,11 @@ Zuletzt empfangene Nachricht: %2$@ Ungeschützt No comment provided by engineer. + + updated channel profile + Kanalprofil aktualisiert + rcv group event chat item + updated group profile Aktualisiertes Gruppenprofil @@ -10552,6 +11697,11 @@ Zuletzt empfangene Nachricht: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + via %@ + relay hostname + via contact address link über einen Kontaktadressen-Link @@ -10627,6 +11777,11 @@ Zuletzt empfangene Nachricht: %2$@ Sie sind Beobachter No comment provided by engineer. + + you are subscriber + Sie sind Abonnent + No comment provided by engineer. + you blocked %@ Sie haben %@ blockiert @@ -10687,6 +11842,11 @@ Zuletzt empfangene Nachricht: %2$@ \~durchstreichen~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + ⚠️ Signaturüberprüfung fehlgeschlagen: %@. + owner verification + @@ -10743,7 +11903,7 @@ Zuletzt empfangene Nachricht: %2$@ Copyright © 2022 SimpleX Chat. All rights reserved. - Copyright © 2024 SimpleX Chat. All rights reserved. + Copyright © 2025 SimpleX Chat. All rights reserved. Copyright (human-readable) @@ -10919,7 +12079,7 @@ Zuletzt empfangene Nachricht: %2$@ Ok - OK + Ok No comment provided by engineer. @@ -10939,12 +12099,12 @@ Zuletzt empfangene Nachricht: %2$@ Please create a profile in the SimpleX app - Bitte erstellen Sie ein Profil in der SimpleX-App + Bitte erstellen Sie in der SimpleX-App ein Profil No comment provided by engineer. Selected chat preferences prohibit this message. - Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt. + Diese Nachricht ist wegen der gewählten Chat-Präferenzen nicht erlaubt. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 6761ff6fce..5e95cf39cc 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -185,6 +185,24 @@ %d months time interval + + %d relays failed + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d sec @@ -200,11 +218,63 @@ %d skipped message(s) integrity error chat item + + %d subscriber + %d subscriber + channel subscriber count + + + %d subscribers + %d subscribers + channel subscriber count + %d weeks %d weeks time interval + + %1$d/%2$d relays active + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +285,11 @@ %lld %@ No comment provided by engineer. + + %lld channel events + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld contact(s) selected @@ -315,11 +390,21 @@ %u messages skipped. No comment provided by engineer. + + (from owner) + (from owner) + chat link info line + (new) (new) No comment provided by engineer. + + (signed) + (signed) + chat link info line + (this device v%@) (this device v%@) @@ -365,6 +450,11 @@ **Scan / Paste link**: to connect via a link you received. No comment provided by engineer. + + **Test relay** to retrieve its name. + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Warning**: Instant push notifications require passphrase saved in Keychain. @@ -408,6 +498,15 @@ - and more! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +605,11 @@ time interval A few more things No comment provided by engineer. + + A link for one person to connect + A link for one person to connect + No comment provided by engineer. + A new contact A new contact @@ -632,9 +736,9 @@ swipe action Active connections No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -702,6 +806,11 @@ swipe action Added message servers No comment provided by engineer. + + Adding relays will be supported later. + Adding relays will be supported later. + No comment provided by engineer. + Additional accent Additional accent @@ -792,6 +901,11 @@ swipe action All group members will remain connected. No comment provided by engineer. + + All messages + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. @@ -817,6 +931,16 @@ swipe action All profiles profile dropdown + + All relays failed + All relays failed + No comment provided by engineer. + + + All relays removed + All relays removed + No comment provided by engineer. + All reports will be archived for you. All reports will be archived for you. @@ -877,6 +1001,11 @@ swipe action Allow irreversible message deletion only if your contact allows it to you. (24 hours) No comment provided by engineer. + + Allow members to chat with admins. + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Allow message reactions only if your contact allows them. @@ -892,6 +1021,11 @@ swipe action Allow sending direct messages to members. No comment provided by engineer. + + Allow sending direct messages to subscribers. + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Allow sending disappearing messages. @@ -902,6 +1036,11 @@ swipe action Allow sharing No comment provided by engineer. + + Allow subscribers to chat with admins. + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Allow to irreversibly delete sent messages. (24 hours) @@ -1007,11 +1146,6 @@ swipe action Answer call No comment provided by engineer. - - Anybody can host servers. - Anybody can host servers. - No comment provided by engineer. - App build: %@ App build: %@ @@ -1142,6 +1276,11 @@ swipe action Audio and video calls No comment provided by engineer. + + Audio call + Audio call + No comment provided by engineer. + Audio/video calls Audio/video calls @@ -1212,6 +1351,23 @@ swipe action Bad message hash No comment provided by engineer. + + Be free +in your network + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls Better calls @@ -1307,6 +1463,11 @@ swipe action Block member? No comment provided by engineer. + + Block subscriber for all? + Block subscriber for all? + No comment provided by engineer. + Blocked by admin Blocked by admin @@ -1357,6 +1518,16 @@ swipe action Both you and your contact can send voice messages. No comment provided by engineer. + + Bottom bar + Bottom bar + No comment provided by engineer. + + + Broadcast + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1365,7 +1536,7 @@ swipe action Business address Business address - No comment provided by engineer. + chat link info line Business chats @@ -1387,15 +1558,6 @@ swipe action By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - No comment provided by engineer. - Call already ended! Call already ended! @@ -1544,6 +1706,82 @@ new chat action authentication reason set passcode view + + Channel + Channel + No comment provided by engineer. + + + Channel display name + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + Channel image + No comment provided by engineer. + + + Channel link + Channel link + chat link info line + + + Channel preferences + Channel preferences + No comment provided by engineer. + + + Channel profile + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + Channels + No comment provided by engineer. + Chat Chat @@ -1629,6 +1867,26 @@ set passcode view Chat profile No comment provided by engineer. + + Chat relay + Chat relay + No comment provided by engineer. + + + Chat relays + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme Chat theme @@ -1647,7 +1905,8 @@ set passcode view Chat with admins Chat with admins - chat toolbar + chat feature +chat toolbar Chat with member @@ -1664,11 +1923,26 @@ set passcode view Chats No comment provided by engineer. + + Chats with admins are prohibited. + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members Chats with members No comment provided by engineer. + + Chats with members are disabled + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. Check messages every 20 min. @@ -1679,6 +1953,16 @@ set passcode view Check messages when allowed. No comment provided by engineer. + + Check relay address and try again. + Check relay address and try again. + alert message + + + Check relay name and try again. + Check relay name and try again. + alert message + Check server address and try again. Check server address and try again. @@ -1802,7 +2086,7 @@ set passcode view Conditions of use Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1824,9 +2108,9 @@ set passcode view Configure ICE servers No comment provided by engineer. - - Configure server operators - Configure server operators + + Configure relays + Configure relays No comment provided by engineer. @@ -1887,7 +2171,8 @@ set passcode view Connect Connect - server test step + relay test step +server test step Connect automatically @@ -1933,6 +2218,11 @@ This is your own one-time link! Connect via link new chat sheet title + + Connect via link or QR code + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Connect via one-time link @@ -2011,6 +2301,11 @@ This is your own one-time link! Connection error (AUTH) Connection error (AUTH) + conn error description + + + Connection failed + Connection failed No comment provided by engineer. @@ -2065,6 +2360,11 @@ This is your own one-time link! Connections No comment provided by engineer. + + Contact address + Contact address + chat link info line + Contact allows Contact allows @@ -2135,6 +2435,11 @@ This is your own one-time link! Continue No comment provided by engineer. + + Contribute + Contribute + No comment provided by engineer. + Conversation deleted! Conversation deleted! @@ -2163,12 +2468,7 @@ This is your own one-time link! Correct name to %@? Correct name to %@? - No comment provided by engineer. - - - Create - Create - No comment provided by engineer. + alert message Create 1-time link @@ -2220,6 +2520,16 @@ This is your own one-time link! Create profile No comment provided by engineer. + + Create public channel + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + Create public channel (BETA) + No comment provided by engineer. + Create queue Create queue @@ -2230,11 +2540,21 @@ This is your own one-time link! Create your address No comment provided by engineer. + + Create your link + Create your link + No comment provided by engineer. + Create your profile Create your profile No comment provided by engineer. + + Create your public address + Create your public address + No comment provided by engineer. + Created Created @@ -2255,6 +2575,11 @@ This is your own one-time link! Creating archive link No comment provided by engineer. + + Creating channel + Creating channel + No comment provided by engineer. + Creating link… Creating link… @@ -2413,10 +2738,10 @@ This is your own one-time link! Debug delivery No comment provided by engineer. - - Decentralized - Decentralized - No comment provided by engineer. + + Decode link + Decode link + relay test step Decryption error @@ -2464,6 +2789,16 @@ swipe action Delete and notify contact No comment provided by engineer. + + Delete channel + Delete channel + No comment provided by engineer. + + + Delete channel? + Delete channel? + No comment provided by engineer. + Delete chat Delete chat @@ -2579,6 +2914,16 @@ swipe action Delete member message? No comment provided by engineer. + + Delete member messages + Delete member messages + No comment provided by engineer. + + + Delete member messages? + Delete member messages? + alert title + Delete message? Delete message? @@ -2587,7 +2932,8 @@ swipe action Delete messages Delete messages - alert button + alert action +alert button Delete messages after @@ -2624,6 +2970,11 @@ swipe action Delete queue server test step + + Delete relay + Delete relay + No comment provided by engineer. + Delete report Delete report @@ -2789,6 +3140,16 @@ swipe action Direct messages between members are prohibited. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + Disable + alert button + Disable (keep overrides) Disable (keep overrides) @@ -2894,6 +3255,11 @@ swipe action Do not send history to new members. No comment provided by engineer. + + Do not send history to new subscribers. + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. Do not use credentials with proxy. @@ -2995,11 +3361,21 @@ chat item action E2E encrypted notifications. No comment provided by engineer. + + Easier to invite your friends 👋 + Easier to invite your friends 👋 + No comment provided by engineer. + Edit Edit chat item action + + Edit channel profile + Edit channel profile + No comment provided by engineer. + Edit group profile Edit group profile @@ -3013,7 +3389,7 @@ chat item action Enable Enable - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3035,6 +3411,11 @@ chat item action Enable TCP keep-alive No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Enable automatic message deletion? @@ -3045,6 +3426,11 @@ chat item action Enable camera access No comment provided by engineer. + + Enable chats with admins? + Enable chats with admins? + alert title + Enable disappearing messages by default. Enable disappearing messages by default. @@ -3065,16 +3451,16 @@ chat item action Enable instant notifications? No comment provided by engineer. + + Enable link previews? + Enable link previews? + alert title + Enable lock Enable lock No comment provided by engineer. - - Enable notifications - Enable notifications - No comment provided by engineer. - Enable periodic notifications? Enable periodic notifications? @@ -3180,6 +3566,11 @@ chat item action Enter Passcode No comment provided by engineer. + + Enter channel name… + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Enter correct passphrase. @@ -3205,6 +3596,16 @@ chat item action Enter password above to show! No comment provided by engineer. + + Enter profile name... + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + Enter relay name… + No comment provided by engineer. + Enter server manually Enter server manually @@ -3233,7 +3634,7 @@ chat item action Error Error - No comment provided by engineer. + conn error description Error aborting address change @@ -3260,6 +3661,11 @@ chat item action Error adding member(s) No comment provided by engineer. + + Error adding relay + Error adding relay + alert title + Error adding server Error adding server @@ -3310,11 +3716,21 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Error creating address No comment provided by engineer. + + Error creating channel + Error creating channel + alert title + Error creating group Error creating group @@ -3450,11 +3866,6 @@ chat item action Error opening chat No comment provided by engineer. - - Error opening group - Error opening group - No comment provided by engineer. - Error receiving file Error receiving file @@ -3500,6 +3911,11 @@ chat item action Error saving ICE servers No comment provided by engineer. + + Error saving channel profile + Error saving channel profile + No comment provided by engineer. + Error saving chat list Error saving chat list @@ -3565,6 +3981,11 @@ chat item action Error setting delivery receipts! No comment provided by engineer. + + Error sharing channel + Error sharing channel + alert title + Error starting chat Error starting chat @@ -3645,7 +4066,8 @@ snd error text Error: %@. Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3846,6 +4268,11 @@ snd error text Files and media prohibited! No comment provided by engineer. + + Filter + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filter unread and favorite chats. @@ -3884,7 +4311,8 @@ snd error text Fingerprint in server address does not match certificate. Fingerprint in server address does not match certificate. - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3926,10 +4354,16 @@ snd error text For all moderators No comment provided by engineer. + + For anyone to reach you + For anyone to reach you + No comment provided by engineer. + For chat profile %@: For chat profile %@: - servers error + servers error +servers warning For console @@ -4070,11 +4504,21 @@ Error: %2$@ GIFs and stickers No comment provided by engineer. + + Get link + Get link + relay test step + Get notified when mentioned. Get notified when mentioned. No comment provided by engineer. + + Get started + Get started + No comment provided by engineer. + Good afternoon! Good afternoon! @@ -4133,7 +4577,7 @@ Error: %2$@ Group link Group link - No comment provided by engineer. + chat link info line Group links @@ -4245,6 +4689,11 @@ Error: %2$@ History is not sent to new members. No comment provided by engineer. + + History is not sent to new subscribers. + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works How SimpleX works @@ -4310,6 +4759,11 @@ Error: %2$@ If you enter your self-destruct passcode while opening the app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). @@ -4330,16 +4784,16 @@ Error: %2$@ Image will be received when your contact is online, please wait or check later! No comment provided by engineer. + + Images + Images + No comment provided by engineer. + Immediately Immediately No comment provided by engineer. - - Immune to spam - Immune to spam - No comment provided by engineer. - Import Import @@ -4482,9 +4936,9 @@ More improvements are coming soon! Initial role No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Install SimpleX Chat for terminal No comment provided by engineer. @@ -4542,7 +4996,7 @@ More improvements are coming soon! Invalid connection link Invalid connection link - No comment provided by engineer. + conn error description Invalid display name! @@ -4562,7 +5016,17 @@ More improvements are coming soon! Invalid name! Invalid name! - No comment provided by engineer. + alert title + + + Invalid relay address! + Invalid relay address! + alert title + + + Invalid relay name! + Invalid relay name! + alert title Invalid response @@ -4589,11 +5053,21 @@ More improvements are coming soon! Invite friends No comment provided by engineer. + + Invite member + Invite member + No comment provided by engineer. + Invite members Invite members No comment provided by engineer. + + Invite someone privately + Invite someone privately + No comment provided by engineer. + Invite to chat Invite to chat @@ -4670,6 +5144,11 @@ More improvements are coming soon! Join as %@ No comment provided by engineer. + + Join channel + Join channel + No comment provided by engineer. + Join group Join group @@ -4757,6 +5236,16 @@ This is your link for group %@! Leave swipe action + + Leave channel + Leave channel + No comment provided by engineer. + + + Leave channel? + Leave channel? + No comment provided by engineer. + Leave chat Leave chat @@ -4782,6 +5271,11 @@ This is your link for group %@! Less traffic on mobile networks. No comment provided by engineer. + + Let someone connect to you + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat Let's talk in SimpleX Chat @@ -4802,6 +5296,11 @@ This is your link for group %@! Link mobile and desktop apps! 🔗 No comment provided by engineer. + + Link signature verified. + Link signature verified. + owner verification + Linked desktop options Linked desktop options @@ -4812,6 +5311,11 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + Links + No comment provided by engineer. + List List @@ -4937,6 +5441,11 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Member messages will be deleted - this cannot be undone! + alert message + Member reports Member reports @@ -4960,12 +5469,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Member will be removed from group - this cannot be undone! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4977,6 +5486,11 @@ This is your link for group %@! Members can add message reactions. No comment provided by engineer. + + Members can chat with admins. + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Members can irreversibly delete sent messages. (24 hours) @@ -5042,6 +5556,11 @@ This is your link for group %@! Message draft No comment provided by engineer. + + Message error + Message error + No comment provided by engineer. + Message forwarded Message forwarded @@ -5137,6 +5656,16 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. Messages in this chat will never be deleted. @@ -5167,16 +5696,16 @@ This is your link for group %@! Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. No comment provided by engineer. + + Migrate + Migrate + No comment provided by engineer. + Migrate device Migrate device No comment provided by engineer. - - Migrate from another device - Migrate from another device - No comment provided by engineer. - Migrate here Migrate here @@ -5297,6 +5826,11 @@ This is your link for group %@! Network & servers No comment provided by engineer. + + Network commitments + Network commitments + No comment provided by engineer. + Network connection Network connection @@ -5307,6 +5841,11 @@ This is your link for group %@! Network decentralization No comment provided by engineer. + + Network error + Network error + conn error description + Network issues - message expired after many attempts to send it. Network issues - message expired after many attempts to send it. @@ -5322,6 +5861,13 @@ This is your link for group %@! Network operator No comment provided by engineer. + + Network routers cannot know +who talks to whom + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings Network settings @@ -5330,13 +5876,18 @@ This is your link for group %@! Network status Network status - No comment provided by engineer. + alert title New New token status text + + New 1-time link + New 1-time link + No comment provided by engineer. + New Passcode New Passcode @@ -5362,6 +5913,11 @@ This is your link for group %@! New chat experience 🎉 No comment provided by engineer. + + New chat relay + New chat relay + No comment provided by engineer. + New contact request New contact request @@ -5432,11 +5988,33 @@ This is your link for group %@! No No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No active relays + No comment provided by engineer. + No app password No app password Authentication unavailable + + No chat relays + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + No chat relays enabled. + servers warning + No chats No chats @@ -5582,11 +6160,26 @@ This is your link for group %@! No unread chats No comment provided by engineer. - - No user identifiers. - No user identifiers. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + Not all relays connected + alert title + Not compatible! Not compatible! @@ -5644,7 +6237,7 @@ This is your link for group %@! OK OK - No comment provided by engineer. + alert button Off @@ -5663,11 +6256,21 @@ new chat action Old database No comment provided by engineer. + + On your phone, not on servers. + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link One-time invitation link No comment provided by engineer. + + One-time link + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5687,6 +6290,11 @@ Requires compatible VPN. Onion hosts will not be used. No comment provided by engineer. + + Only channel owners can change channel preferences. + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. Only chat owners can change preferences. @@ -5790,7 +6398,8 @@ Requires compatible VPN. Open Open - alert action + alert action +alert button Open Settings @@ -5802,6 +6411,11 @@ Requires compatible VPN. Open changes No comment provided by engineer. + + Open channel + Open channel + new chat action + Open chat Open chat @@ -5822,6 +6436,11 @@ Requires compatible VPN. Open conditions No comment provided by engineer. + + Open external link? + Open external link? + alert title + Open full link Open full link @@ -5842,6 +6461,11 @@ Requires compatible VPN. Open migration to another device authentication reason + + Open new channel + Open new channel + new chat action + Open new chat Open new chat @@ -5887,6 +6511,17 @@ Requires compatible VPN. Operator server alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file Or import archive file @@ -5907,6 +6542,11 @@ Requires compatible VPN. Or securely share this file link No comment provided by engineer. + + Or show QR in person or via video call. + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code Or show this code @@ -5917,6 +6557,11 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Or use this QR - print or show online. + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists Organize chats into lists @@ -5934,6 +6579,21 @@ Requires compatible VPN. %@ alert message + + Owner + Owner + No comment provided by engineer. + + + Owners + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + Ownership: you can run your own relays. + No comment provided by engineer. + PING count PING count @@ -5989,6 +6649,11 @@ Requires compatible VPN. Paste image No comment provided by engineer. + + Paste link / Scan + Paste link / Scan + No comment provided by engineer. + Paste link to connect! Paste link to connect! @@ -6143,6 +6808,16 @@ Error: %@ Preserve the last message draft, with attachments. No comment provided by engineer. + + Preset relay address + Preset relay address + No comment provided by engineer. + + + Preset relay name + Preset relay name + No comment provided by engineer. + Preset server address Preset server address @@ -6178,14 +6853,14 @@ Error: %@ Privacy policy and conditions of use. No comment provided by engineer. - - Privacy redefined - Privacy redefined + + Privacy: for owners and subscribers. + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - Private chats, groups and your contacts are not accessible to server operators. + + Private and secure messaging. + Private and secure messaging. No comment provided by engineer. @@ -6228,6 +6903,11 @@ Error: %@ Private routing timeout alert title + + Proceed + Proceed + alert action + Profile and server connections Profile and server connections @@ -6253,9 +6933,9 @@ Error: %@ Profile theme No comment provided by engineer. - - Profile update will be sent to your contacts. - Profile update will be sent to your contacts. + + Profile update will be sent to your SimpleX contacts. + Profile update will be sent to your SimpleX contacts. alert message @@ -6263,6 +6943,11 @@ Error: %@ Prohibit audio/video calls. No comment provided by engineer. + + Prohibit chats with admins. + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Prohibit irreversible message deletion. @@ -6293,6 +6978,11 @@ Error: %@ Prohibit sending direct messages to members. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Prohibit sending disappearing messages. @@ -6360,6 +7050,11 @@ Enable in *Network & servers* settings. Proxy requires password No comment provided by engineer. + + Public channels - speak freely 🚀 + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Push notifications @@ -6400,24 +7095,14 @@ Enable in *Network & servers* settings. Read more No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Read more in User Guide. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Read more in our GitHub repository. No comment provided by engineer. @@ -6440,11 +7125,6 @@ Enable in *Network & servers* settings. Received at: %@ copied message info - - Received file event - Received file event - notification - Received message Received message @@ -6582,6 +7262,31 @@ swipe action Reject member? alert title + + Relay + Relay + No comment provided by engineer. + + + Relay address + Relay address + alert title + + + Relay connection failed + Relay connection failed + alert title + + + Relay link + Relay link + No comment provided by engineer. + + + Relay results: + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Relay server is only used if necessary. Another party can observe your IP address. @@ -6592,10 +7297,25 @@ swipe action Relay server protects your IP address, but it can observe the duration of the call. No comment provided by engineer. + + Relay test failed! + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + Reliability: many relays per channel. + No comment provided by engineer. + Remove Remove - No comment provided by engineer. + alert action + + + Remove and delete messages + Remove and delete messages + alert action Remove archive? @@ -6620,13 +7340,23 @@ swipe action Remove member? Remove member? - No comment provided by engineer. + alert title Remove passphrase from keychain? Remove passphrase from keychain? No comment provided by engineer. + + Remove subscriber + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + Remove subscriber? + alert title + Removes messages and blocks members. Removes messages and blocks members. @@ -6862,6 +7592,11 @@ swipe action SOCKS proxy No comment provided by engineer. + + Safe web links + Safe web links + No comment provided by engineer. + Safely receive files Safely receive files @@ -6888,6 +7623,11 @@ chat item action Save (and notify members) alert button + + Save (and notify subscribers) + Save (and notify subscribers) + alert button + Save admission settings? Save admission settings? @@ -6903,6 +7643,11 @@ chat item action Save and notify group members No comment provided by engineer. + + Save and notify subscribers + Save and notify subscribers + No comment provided by engineer. + Save and reconnect Save and reconnect @@ -6913,6 +7658,16 @@ chat item action Save and update group profile No comment provided by engineer. + + Save channel profile + Save channel profile + No comment provided by engineer. + + + Save channel profile? + Save channel profile? + alert title + Save group profile Save group profile @@ -7038,11 +7793,36 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + Search files + No comment provided by engineer. + + + Search images + Search images + No comment provided by engineer. + + + Search links + Search links + No comment provided by engineer. + Search or paste SimpleX link Search or paste SimpleX link No comment provided by engineer. + + Search videos + Search videos + No comment provided by engineer. + + + Search voice messages + Search voice messages + No comment provided by engineer. + Secondary Secondary @@ -7068,6 +7848,11 @@ chat item action Security code No comment provided by engineer. + + Security: owners hold channel keys. + Security: owners hold channel keys. + No comment provided by engineer. + Select Select @@ -7198,6 +7983,11 @@ chat item action Send request without message No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Send them from gallery or custom keyboards. @@ -7208,6 +7998,11 @@ chat item action Send up to 100 last messages to new members. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. Send your private feedback to groups. @@ -7223,6 +8018,11 @@ chat item action Sender may have deleted the connection request. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Sending delivery receipts will be enabled for all contacts in all visible chat profiles. @@ -7278,11 +8078,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Sent file event - notification - Sent message Sent message @@ -7353,6 +8148,11 @@ chat item action Server protocol changed. alert title + + Server requires authorization to connect to relay, check password. + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. Server requires authorization to create queues, check password. @@ -7483,6 +8283,16 @@ chat item action Settings were changed. alert message + + Setup notifications + Setup notifications + No comment provided by engineer. + + + Setup routers + Setup routers + No comment provided by engineer. + Shape profile images Shape profile images @@ -7519,11 +8329,16 @@ chat item action Share address publicly No comment provided by engineer. - - Share address with contacts? - Share address with contacts? + + Share address with SimpleX contacts? + Share address with SimpleX contacts? alert title + + Share channel + Share channel + No comment provided by engineer. + Share from other apps. Share from other apps. @@ -7549,6 +8364,11 @@ chat item action Share profile No comment provided by engineer. + + Share relay address + Share relay address + No comment provided by engineer. + Share this 1-time invite link Share this 1-time invite link @@ -7559,9 +8379,14 @@ chat item action Share to SimpleX No comment provided by engineer. - - Share with contacts - Share with contacts + + Share via chat + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts + Share with SimpleX contacts No comment provided by engineer. @@ -7734,9 +8559,9 @@ chat item action SimpleX protocols reviewed by Trail of Bits. No comment provided by engineer. - - SimpleX relay link - SimpleX relay link + + SimpleX relay address + SimpleX relay address simplex link type @@ -7812,6 +8637,11 @@ report reason Square, circle, or anything in between. No comment provided by engineer. + + Star on GitHub + Star on GitHub + No comment provided by engineer. + Start chat Start chat @@ -7912,6 +8742,78 @@ report reason Subscribed No comment provided by engineer. + + Subscriber + Subscriber + No comment provided by engineer. + + + Subscriber reports + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors Subscription errors @@ -7992,6 +8894,11 @@ report reason Take picture No comment provided by engineer. + + Talk to someone + Talk to someone + No comment provided by engineer. + Tap Connect to chat Tap Connect to chat @@ -8007,9 +8914,9 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Tap Create SimpleX address in the menu to create it later. + + Tap Join channel + Tap Join channel No comment provided by engineer. @@ -8042,6 +8949,11 @@ report reason Tap to join incognito No comment provided by engineer. + + Tap to open + Tap to open + No comment provided by engineer. + Tap to paste link Tap to paste link @@ -8060,13 +8972,19 @@ report reason Test failed at step %@. Test failed at step %@. - server test failure + relay test failure +server test failure Test notifications Test notifications No comment provided by engineer. + + Test relay + Test relay + No comment provided by engineer. + Test server Test server @@ -8119,6 +9037,11 @@ It can happen because of some bug or when the connection is compromised.The app protects your privacy by using different operators in each conversation. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). The app will ask to confirm downloads from unknown file servers (except .onion). @@ -8134,6 +9057,11 @@ It can happen because of some bug or when the connection is compromised.The code you scanned is not a SimpleX link QR code. No comment provided by engineer. + + The connection reached the limit of undelivered messages + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. The connection reached the limit of undelivered messages, your contact may be offline. @@ -8159,9 +9087,11 @@ It can happen because of some bug or when the connection is compromised.The encryption is working and the new encryption agreement is not required. It may result in connection errors! No comment provided by engineer. - - The future of messaging - The future of messaging + + The first network where you own +your contacts and groups. + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -8199,6 +9129,11 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. The same conditions will apply to operator **%@**. @@ -8244,6 +9179,16 @@ It can happen because of some bug or when the connection is compromised.Themes No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. These conditions will also apply for: **%@**. @@ -8309,6 +9254,16 @@ It can happen because of some bug or when the connection is compromised.This group no longer exists. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. @@ -8359,6 +9314,11 @@ It can happen because of some bug or when the connection is compromised.To hide unwanted messages. No comment provided by engineer. + + To make SimpleX Network last. + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection To make a new connection @@ -8446,11 +9406,6 @@ You will be prompted to complete authentication before this feature is enabled.< To verify end-to-end encryption with your contact compare (or scan) the code on your devices. No comment provided by engineer. - - Toggle chat list: - Toggle chat list: - No comment provided by engineer. - Toggle incognito when connecting. Toggle incognito when connecting. @@ -8466,6 +9421,11 @@ You will be prompted to complete authentication before this feature is enabled.< Toolbar opacity No comment provided by engineer. + + Top bar + Top bar + No comment provided by engineer. + Total Total @@ -8481,15 +9441,10 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Trying to connect to the server used to receive messages from this contact (error: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Trying to connect to the server used to receive messages from this contact. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8536,6 +9491,11 @@ You will be prompted to complete authentication before this feature is enabled.< Unblock member? No comment provided by engineer. + + Unblock subscriber for all? + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages Undelivered messages @@ -8636,13 +9596,18 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link Unsupported connection link - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Up to 100 last messages are sent to new members. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Update @@ -8768,11 +9733,6 @@ To connect, please ask your contact to create another connection link and check Use TCP port 443 for preset servers only. No comment provided by engineer. - - Use chat - Use chat - No comment provided by engineer. - Use current profile Use current profile @@ -8788,6 +9748,11 @@ To connect, please ask your contact to create another connection link and check Use for messages No comment provided by engineer. + + Use for new channels + Use for new channels + No comment provided by engineer. + Use for new connections Use for new connections @@ -8828,6 +9793,11 @@ To connect, please ask your contact to create another connection link and check Use private routing with unknown servers. No comment provided by engineer. + + Use relay + Use relay + No comment provided by engineer. + Use server Use server @@ -8848,6 +9818,11 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port Use web port @@ -8868,6 +9843,11 @@ To connect, please ask your contact to create another connection link and check Using SimpleX Chat servers. No comment provided by engineer. + + Verify + Verify + relay test step + Verify code with desktop Verify code with desktop @@ -8928,6 +9908,11 @@ To connect, please ask your contact to create another connection link and check Video will be received when your contact is online, please wait or check later! No comment provided by engineer. + + Videos + Videos + No comment provided by engineer. + Videos and files up to 1gb Videos and files up to 1gb @@ -8983,6 +9968,21 @@ To connect, please ask your contact to create another connection link and check Voice message… No comment provided by engineer. + + Wait + Wait + alert action + + + Wait response + Wait response + relay test step + + + Waiting for channel owner to add relays. + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... Waiting for desktop... @@ -9023,6 +10023,11 @@ To connect, please ask your contact to create another connection link and check Warning: you may lose some data! No comment provided by engineer. + + We made connecting simpler for new users. + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICE servers @@ -9073,6 +10078,11 @@ To connect, please ask your contact to create another connection link and check When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. No comment provided by engineer. + + Why SimpleX is built. + Why SimpleX is built. + No comment provided by engineer. + WiFi WiFi @@ -9200,16 +10210,21 @@ Repeat join request? Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - You are connected to the server used to receive messages from this contact. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group You are invited to group No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. You are not connected to these servers. Private routing is used to deliver messages to them. @@ -9280,6 +10295,11 @@ Repeat join request? You can set lock screen notification preview via settings. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. @@ -9325,16 +10345,25 @@ Repeat join request? You can't send messages! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. You could not be verified; please try again. No comment provided by engineer. - - You decide who can connect. - You decide who can connect. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9402,6 +10431,11 @@ Repeat connection request? You should receive notifications. token info + + You were born without an account + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. You will be able to send messages **only after your request is accepted**. @@ -9437,6 +10471,11 @@ Repeat connection request? You will still receive calls and notifications from muted profiles when they are active. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. You will stop receiving messages from this chat. Chat history will be preserved. @@ -9482,6 +10521,11 @@ Repeat connection request? Your calls No comment provided by engineer. + + Your channel + Your channel + No comment provided by engineer. + Your chat database Your chat database @@ -9532,6 +10576,11 @@ Repeat connection request? Your contacts will remain connected. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. Your credentials may be sent unencrypted. @@ -9552,6 +10601,11 @@ Repeat connection request? Your group No comment provided by engineer. + + Your network + Your network + No comment provided by engineer. + Your preferences Your preferences @@ -9567,6 +10621,13 @@ Repeat connection request? Your profile No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Your profile **%@** will be shared. @@ -9587,11 +10648,26 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message + + Your public address + Your public address + No comment provided by engineer. + Your random profile Your random profile No comment provided by engineer. + + Your relay address + Your relay address + No comment provided by engineer. + + + Your relay name + Your relay name + No comment provided by engineer. + Your server address Your server address @@ -9607,21 +10683,11 @@ Repeat connection request? Your settings No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Send us email](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_italic_ @@ -9637,6 +10703,11 @@ Repeat connection request? above, then choose: No comment provided by engineer. + + accepted + accepted + No comment provided by engineer. + accepted %@ accepted %@ @@ -9657,6 +10728,11 @@ Repeat connection request? accepted you rcv group event chat item + + active + active + No comment provided by engineer. + admin admin @@ -9768,6 +10844,11 @@ marked deleted chat item preview text calling… call status + + can't broadcast + can't broadcast + No comment provided by engineer. + can't send messages can't send messages @@ -9803,6 +10884,16 @@ marked deleted chat item preview text changing address… chat item text + + channel + channel + shown as sender role for channel messages + + + channel profile updated + channel profile updated + snd group event chat item + colored colored @@ -9949,6 +11040,11 @@ pref value deleted deleted chat item + + deleted channel + deleted channel + rcv group event chat item + deleted contact deleted contact @@ -10059,11 +11155,21 @@ pref value error No comment provided by engineer. + + error: %@ + error: %@ + receive error chat item + expired expired No comment provided by engineer. + + failed + failed + No comment provided by engineer. + forwarded forwarded @@ -10184,6 +11290,11 @@ pref value left rcv group event chat item + + link + link + No comment provided by engineer. + marked deleted marked deleted @@ -10254,6 +11365,11 @@ pref value never delete after time + + new + new + No comment provided by engineer. + new message new message @@ -10269,6 +11385,11 @@ pref value no e2e encryption No comment provided by engineer. + + no subscription + no subscription + No comment provided by engineer. + no text no text @@ -10372,6 +11493,11 @@ time to disappear rejected call call status + + relay + relay + member role + removed removed @@ -10382,6 +11508,16 @@ time to disappear removed %@ rcv group event chat item + + removed (%d attempts) + removed (%d attempts) + receive error chat item + + + removed by operator + removed by operator + No comment provided by engineer. + removed contact address removed contact address @@ -10536,6 +11672,11 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + updated channel profile + updated channel profile + rcv group event chat item + updated group profile updated group profile @@ -10556,6 +11697,11 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + via %@ + relay hostname + via contact address link via contact address link @@ -10631,6 +11777,11 @@ last received msg: %2$@ you are observer No comment provided by engineer. + + you are subscriber + you are subscriber + No comment provided by engineer. + you blocked %@ you blocked %@ @@ -10691,6 +11842,11 @@ last received msg: %2$@ \~strike~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index f11ad704ac..43d3895cc4 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -185,6 +185,24 @@ %d mes(es) time interval + + %d relays failed + %d servidores han fallado + channel relay bar +channel subscriber relay bar + + + %d relays not active + %d servidores inactivos + channel relay bar +channel subscriber relay bar + + + %d relays removed + %d servidores eliminados + channel relay bar +channel subscriber relay bar + %d sec %d segundo(s) @@ -200,11 +218,63 @@ %d mensaje(s) omitido(s) integrity error chat item + + %d subscriber + %d suscriptor + channel subscriber count + + + %d subscribers + %d suscriptores + channel subscriber count + %d weeks %d semana(s) time interval + + %1$d/%2$d relays active + %1$d/%2$d servidores activos + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + %1$d/%2$d servidores activos, %3$d errores + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d servidores activos, %3$d han fallado + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + %1$d/%2$d servidores activos, %3$d servidores eliminados + channel relay bar + + + %1$d/%2$d relays connected + %1$d/%2$d servidores conectados + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d servidores conectados, %3$d errores + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d servidores conectados, %3$d con fallo + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d servidores conectados, %3$d eliminados + channel subscriber relay bar + %lld %lld @@ -215,6 +285,11 @@ %lld %@ No comment provided by engineer. + + %lld channel events + %lld eventos del canal + No comment provided by engineer. + %lld contact(s) selected %lld contacto(s) seleccionado(s) @@ -227,7 +302,7 @@ %lld group events - %lld evento(s) de grupo + %lld evento(s) del grupo No comment provided by engineer. @@ -315,11 +390,21 @@ %u mensaje(s) omitido(s). No comment provided by engineer. + + (from owner) + (del propietario) + chat link info line + (new) (nuevo) No comment provided by engineer. + + (signed) + (firmado) + chat link info line + (this device v%@) (este dispositivo v%@) @@ -365,6 +450,11 @@ **Escanear / Pegar enlace**: para conectar mediante un enlace recibido. No comment provided by engineer. + + **Test relay** to retrieve its name. + **Test servidor** para recibir su nombre. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Advertencia**: Las notificaciones automáticas instantáneas requieren una contraseña guardada en Keychain. @@ -408,6 +498,15 @@ - ¡y más! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + - aceptar el envío de vistas previas de los enlaces. +- prevenir el phishing mediante hipervínculos. +- eliminar el seguimiento de los enlaces. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -498,7 +597,7 @@ time interval <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> <p>¡Hola!</p> -<p><a href="%@"> Conecta conmigo a través de SimpleX Chat</a></p> +<p><a href="%@">Conecta conmigo a través de SimpleX Chat</a></p> email text @@ -506,6 +605,11 @@ time interval Algunas cosas más No comment provided by engineer. + + A link for one person to connect + Enlace para un solo contacto + No comment provided by engineer. + A new contact Contacto nuevo @@ -632,9 +736,9 @@ swipe action Conexiones activas No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Añade la dirección a tu perfil para que tus contactos puedan compartirla con otros. La actualización del perfil se enviará a tus contactos. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. + Añade la dirección a tu perfil para que tus contactos SimpleX puedan compartirla con otros. La actualización del perfil se enviará a tus contactos SimpleX. No comment provided by engineer. @@ -702,6 +806,11 @@ swipe action Servidores de mensajes añadidos No comment provided by engineer. + + Adding relays will be supported later. + Añadir servidores estará disponible en una versión posterior. + No comment provided by engineer. + Additional accent Acento adicional @@ -792,6 +901,11 @@ swipe action Todos los miembros del grupo permanecerán conectados. No comment provided by engineer. + + All messages + Todos los mensajes + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Todos los mensajes y archivos son enviados **cifrados de extremo a extremo** y con seguridad de cifrado postcuántico en mensajes directos. @@ -817,6 +931,16 @@ swipe action Todos los perfiles profile dropdown + + All relays failed + Todos los servidores han fallado + No comment provided by engineer. + + + All relays removed + Todos los servidores eliminados + No comment provided by engineer. + All reports will be archived for you. Todos los informes serán archivados para ti. @@ -877,6 +1001,11 @@ swipe action Se permite la eliminación irreversible de mensajes pero sólo si tu contacto también lo permite. (24 horas) No comment provided by engineer. + + Allow members to chat with admins. + Permitir que los miembros chateen con administradores. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Se permiten las reacciones a los mensajes pero sólo si tu contacto también las permite. @@ -892,6 +1021,11 @@ swipe action Se permiten mensajes directos entre miembros. No comment provided by engineer. + + Allow sending direct messages to subscribers. + Se permiten mensajes directos entre suscriptores. + No comment provided by engineer. + Allow sending disappearing messages. Permites el envío de mensajes temporales. @@ -902,6 +1036,11 @@ swipe action Permitir compartir No comment provided by engineer. + + Allow subscribers to chat with admins. + Permitir que los suscriptores chateen con administradores. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Se permite la eliminación irreversible de mensajes. (24 horas) @@ -1007,11 +1146,6 @@ swipe action Responder llamada No comment provided by engineer. - - Anybody can host servers. - Cualquiera puede alojar servidores. - No comment provided by engineer. - App build: %@ Compilación app: %@ @@ -1142,6 +1276,11 @@ swipe action Llamadas y videollamadas No comment provided by engineer. + + Audio call + Llamada + No comment provided by engineer. + Audio/video calls Llamadas y videollamadas @@ -1212,6 +1351,23 @@ swipe action Hash de mensaje incorrecto No comment provided by engineer. + + Be free +in your network + Se libre +en tu red + No comment provided by engineer. + + + Be free in your network. + Se libre en tu red. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Porque hemos destruido el poder de saber quien eres. De manera que tu poder nunca se pueda arrebatar. + No comment provided by engineer. + Better calls Llamadas mejoradas @@ -1307,6 +1463,11 @@ swipe action ¿Bloquear miembro? No comment provided by engineer. + + Block subscriber for all? + ¿Bloquear al suscriptor para todos? + No comment provided by engineer. + Blocked by admin Bloqueado por administrador @@ -1357,6 +1518,16 @@ swipe action Tanto tú como tu contacto podéis enviar mensajes de voz. No comment provided by engineer. + + Bottom bar + Barra inferior + No comment provided by engineer. + + + Broadcast + Emisión + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Búlgaro, Finlandés, Tailandés y Ucraniano - gracias a los usuarios y [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1365,7 +1536,7 @@ swipe action Business address Dirección empresarial - No comment provided by engineer. + chat link info line Business chats @@ -1387,15 +1558,6 @@ swipe action Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - Al usar SimpleX Chat, aceptas: -- enviar únicamente contenido legal en los grupos públicos. -- respetar a los demás usuarios – spam prohibido. - No comment provided by engineer. - Call already ended! ¡La llamada ha terminado! @@ -1544,6 +1706,82 @@ new chat action authentication reason set passcode view + + Channel + Canal + No comment provided by engineer. + + + Channel display name + Título mostrado del canal + No comment provided by engineer. + + + Channel full name (optional) + Título completo del canal (opcional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + El canal no tiene servidores activos. Por favor, intenta unirte más tarde. + alert message +alert subtitle + + + Channel image + Imagen del canal + No comment provided by engineer. + + + Channel link + Enlace del canal + chat link info line + + + Channel preferences + Preferencias del canal + No comment provided by engineer. + + + Channel profile + Perfil del canal + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + El perfil del canal se almacena en los dispositivos de los suscriptores y en los servidores de chat. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + El perfil del canal ha sido modificado. Si lo guardas, el perfil actualizado será enviado a los suscriptores. + alert message + + + Channel temporarily unavailable + Canales no disponibles temporalmente + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + El canal será eliminado para todos los suscriptores. ¡No puede deshacerse! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + El canal será eliminado para tí. ¡No puede deshacerse! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + El canal comenzará a funcionar con %1$d de %2$d servidores. ¿Continuar? + alert message + + + Channels + Canales + No comment provided by engineer. + Chat Chat @@ -1629,6 +1867,26 @@ set passcode view Perfil de usuario No comment provided by engineer. + + Chat relay + Servidor de chat + No comment provided by engineer. + + + Chat relays + Servidores de chat + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + Los servidores de chat reenvían los mensajes en los canales que has creado. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + Los servidores de chat reenvían los mensajes a los suscriptores del canal. + No comment provided by engineer. + Chat theme Tema de chat @@ -1647,7 +1905,8 @@ set passcode view Chat with admins Chatea con administradores - chat toolbar + chat feature +chat toolbar Chat with member @@ -1664,11 +1923,26 @@ set passcode view Chats No comment provided by engineer. + + Chats with admins are prohibited. + Chat con administradores no permitido. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + El chat con administradores en el canal público no dispone de cifrado E2E. Úsalo sólo con servidores de confianza. + alert message + Chats with members Chat con miembros No comment provided by engineer. + + Chats with members are disabled + Chats con miembros desactivado + No comment provided by engineer. + Check messages every 20 min. Comprobar mensajes cada 20 min. @@ -1679,6 +1953,16 @@ set passcode view Comprobar mensajes cuando se permita. No comment provided by engineer. + + Check relay address and try again. + Comprueba la dirección del servidor y prueba de nuevo. + alert message + + + Check relay name and try again. + Comprueba el nombre del servidor y prueba de nuevo. + alert message + Check server address and try again. Comprueba la dirección del servidor e inténtalo de nuevo. @@ -1802,7 +2086,7 @@ set passcode view Conditions of use Condiciones de uso - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1824,9 +2108,9 @@ set passcode view Configure servidores ICE No comment provided by engineer. - - Configure server operators - Configurar operadores de servidores + + Configure relays + Configurar servidores No comment provided by engineer. @@ -1887,7 +2171,8 @@ set passcode view Connect Conectar - server test step + relay test step +server test step Connect automatically @@ -1933,6 +2218,11 @@ This is your own one-time link! Conectar mediante enlace new chat sheet title + + Connect via link or QR code + Conecta vía enlace o QR + No comment provided by engineer. + Connect via one-time link Conectar mediante enlace de un sólo uso @@ -2011,6 +2301,11 @@ This is your own one-time link! Connection error (AUTH) Error de conexión (Autenticación) + conn error description + + + Connection failed + Conexión fallida No comment provided by engineer. @@ -2065,6 +2360,11 @@ This is your own one-time link! Conexiones No comment provided by engineer. + + Contact address + Dirección de contacto + chat link info line + Contact allows El contacto permite @@ -2135,6 +2435,11 @@ This is your own one-time link! Continuar No comment provided by engineer. + + Contribute + Contribuye + No comment provided by engineer. + Conversation deleted! ¡Conversación eliminada! @@ -2163,12 +2468,7 @@ This is your own one-time link! Correct name to %@? ¿Corregir el nombre a %@? - No comment provided by engineer. - - - Create - Crear - No comment provided by engineer. + alert message Create 1-time link @@ -2220,6 +2520,16 @@ This is your own one-time link! Crear perfil No comment provided by engineer. + + Create public channel + Crear canal público + No comment provided by engineer. + + + Create public channel (BETA) + Crear canal público (BETA) + No comment provided by engineer. + Create queue Crear cola @@ -2230,11 +2540,21 @@ This is your own one-time link! Crea tu dirección No comment provided by engineer. + + Create your link + Crea tu enlace + No comment provided by engineer. + Create your profile Crea tu perfil No comment provided by engineer. + + Create your public address + Crea tu dirección pública + No comment provided by engineer. + Created Creadas @@ -2255,6 +2575,11 @@ This is your own one-time link! Creando enlace al archivo No comment provided by engineer. + + Creating channel + Creando canal + No comment provided by engineer. + Creating link… Creando enlace… @@ -2413,10 +2738,10 @@ This is your own one-time link! Informe debug No comment provided by engineer. - - Decentralized - Descentralizada - No comment provided by engineer. + + Decode link + Decodificar enlace + relay test step Decryption error @@ -2464,6 +2789,16 @@ swipe action Eliminar y notificar contacto No comment provided by engineer. + + Delete channel + Eliminar canal + No comment provided by engineer. + + + Delete channel? + ¿Eliminar el canal? + No comment provided by engineer. + Delete chat Eliminar chat @@ -2579,6 +2914,16 @@ swipe action ¿Eliminar el mensaje de miembro? No comment provided by engineer. + + Delete member messages + Eliminar mensajes del miembro + No comment provided by engineer. + + + Delete member messages? + ¿Eliminar mensajes del miembro? + alert title + Delete message? ¿Eliminar mensaje? @@ -2587,7 +2932,8 @@ swipe action Delete messages Activar - alert button + alert action +alert button Delete messages after @@ -2624,6 +2970,11 @@ swipe action Eliminar cola server test step + + Delete relay + Eliminar servidor + No comment provided by engineer. + Delete report Eliminar informe @@ -2789,6 +3140,16 @@ swipe action Los mensajes directos entre miembros del grupo no están permitidos. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + Los mensajes directos entre suscriptores del canal no están permitidos. + No comment provided by engineer. + + + Disable + Desactivar + alert button + Disable (keep overrides) Desactivar (conservando anulaciones) @@ -2806,7 +3167,7 @@ swipe action Disable delete messages - Desactivar + Desactivar eliminar mensajes alert button @@ -2894,6 +3255,11 @@ swipe action No se envía el historial a los miembros nuevos. No comment provided by engineer. + + Do not send history to new subscribers. + No se envía el historial a los suscriptores nuevos. + No comment provided by engineer. + Do not use credentials with proxy. No se usan credenciales con proxy. @@ -2995,11 +3361,21 @@ chat item action Notificaciones cifradas E2E. No comment provided by engineer. + + Easier to invite your friends 👋 + Invitar a tus amigos es más fácil 👋 + No comment provided by engineer. + Edit Editar chat item action + + Edit channel profile + Editar perfil del canal + No comment provided by engineer. + Edit group profile Editar perfil de grupo @@ -3013,7 +3389,7 @@ chat item action Enable Activar - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3035,6 +3411,11 @@ chat item action Activar TCP keep-alive No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + Activar al menos un servidor de chat en Servidores y Redes. + channel creation warning + Enable automatic message deletion? ¿Activar eliminación automática de mensajes? @@ -3045,6 +3426,11 @@ chat item action Permitir acceso a la cámara No comment provided by engineer. + + Enable chats with admins? + ¿Activar chat con administradores? + alert title + Enable disappearing messages by default. Activa por defecto los mensajes temporales. @@ -3065,16 +3451,16 @@ chat item action ¿Activar notificaciones instantáneas? No comment provided by engineer. + + Enable link previews? + ¿Activar previsualización de enlaces? + alert title + Enable lock Activar bloqueo No comment provided by engineer. - - Enable notifications - Activar notificaciones - No comment provided by engineer. - Enable periodic notifications? ¿Activar notificaciones periódicas? @@ -3180,6 +3566,11 @@ chat item action Introduce Código No comment provided by engineer. + + Enter channel name… + Introduce el título del canal… + No comment provided by engineer. + Enter correct passphrase. Introduce la contraseña correcta. @@ -3205,6 +3596,16 @@ chat item action ¡Introduce la contraseña arriba para mostrar! No comment provided by engineer. + + Enter profile name... + Introduce el nombre del perfil… + No comment provided by engineer. + + + Enter relay name… + Introduce el nombre del servidor… + No comment provided by engineer. + Enter server manually Añadir manualmente @@ -3233,7 +3634,7 @@ chat item action Error Error - No comment provided by engineer. + conn error description Error aborting address change @@ -3260,6 +3661,11 @@ chat item action Error al añadir miembro(s) No comment provided by engineer. + + Error adding relay + Error al añadir el servidor + alert title + Error adding server Error al añadir servidor @@ -3310,11 +3716,21 @@ chat item action Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Error al conectar con el servidor usado para recibir mensajes de esta conexión: %@ + subscription status explanation + Error creating address Error al crear dirección No comment provided by engineer. + + Error creating channel + Error al crear el canal + alert title + Error creating group Error al crear grupo @@ -3357,7 +3773,7 @@ chat item action Error deleting chat - Error al eliminar el chat con el miembro + Error al eliminar el chat alert title @@ -3450,11 +3866,6 @@ chat item action Error al abrir chat No comment provided by engineer. - - Error opening group - Error al abrir el grupo - No comment provided by engineer. - Error receiving file Error al recibir archivo @@ -3500,6 +3911,11 @@ chat item action Error al guardar servidores ICE No comment provided by engineer. + + Error saving channel profile + Error al guardar el perfil del canal + No comment provided by engineer. + Error saving chat list Error al guardar listas @@ -3565,6 +3981,11 @@ chat item action ¡Error al configurar confirmaciones de entrega! No comment provided by engineer. + + Error sharing channel + Error al compartir el canal + alert title + Error starting chat Error al iniciar Chat @@ -3644,7 +4065,9 @@ snd error text Error: %@. - server test error + Error: %@. + relay test error +server test error Error: URL is invalid @@ -3845,6 +4268,11 @@ snd error text ¡Archivos y multimedia no permitidos! No comment provided by engineer. + + Filter + Filtro + No comment provided by engineer. + Filter unread and favorite chats. Filtra chats no leídos y favoritos. @@ -3872,19 +4300,23 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + La huella en la dirección del servidor de destino no coincide con el certificado: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + La huella en la dirección del servidor de reenvío no coincide con el certificado: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Posiblemente la huella del certificado en la dirección del servidor es incorrecta - server test error + La huella en la dirección del servidor no coincide con el certificado. + relay test error +server test error Fingerprint in server address does not match certificate: %@. + La huella en la dirección del servidor no coincide con el certificado: %@. No comment provided by engineer. @@ -3922,10 +4354,16 @@ snd error text Para todos los moderadores No comment provided by engineer. + + For anyone to reach you + Cualquiera puede contactarte + No comment provided by engineer. + For chat profile %@: Para el perfil de chat %@: - servers error + servers error +servers warning For console @@ -4066,11 +4504,21 @@ Error: %2$@ GIFs y stickers No comment provided by engineer. + + Get link + Recibir el enlace + relay test step + Get notified when mentioned. Las menciones ahora se notifican. No comment provided by engineer. + + Get started + Empezar + No comment provided by engineer. + Good afternoon! ¡Buenas tardes! @@ -4129,7 +4577,7 @@ Error: %2$@ Group link Enlace de grupo - No comment provided by engineer. + chat link info line Group links @@ -4241,6 +4689,11 @@ Error: %2$@ El historial no se envía a miembros nuevos. No comment provided by engineer. + + History is not sent to new subscribers. + El historial no se envía a suscriptores nuevos. + No comment provided by engineer. + How SimpleX works Cómo funciona SimpleX @@ -4306,6 +4759,11 @@ Error: %2$@ Si al abrir la aplicación introduces el código de autodestrucción: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + Si te has unido o has creado canales, dejarán de funcionar permanentemente. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Si necesitas usar el chat ahora pulsa **Hacerlo más tarde** más abajo (se ofrecerá migrar la base de datos cuando se reinicie la aplicación). @@ -4326,16 +4784,16 @@ Error: %2$@ La imagen se recibirá cuando el contacto esté en línea, ¡por favor espera o revisa más tarde! No comment provided by engineer. + + Images + Imágenes + No comment provided by engineer. + Immediately Inmediatamente No comment provided by engineer. - - Immune to spam - Inmune a spam y abuso - No comment provided by engineer. - Import Importar @@ -4478,9 +4936,9 @@ More improvements are coming soon! Rol inicial No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Instalar terminal para [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Instalar terminal para SimpleX Chat No comment provided by engineer. @@ -4538,7 +4996,7 @@ More improvements are coming soon! Invalid connection link Enlace de conexión no válido - No comment provided by engineer. + conn error description Invalid display name! @@ -4558,7 +5016,17 @@ More improvements are coming soon! Invalid name! ¡Nombre no válido! - No comment provided by engineer. + alert title + + + Invalid relay address! + ¡Dirección de servidor no válido! + alert title + + + Invalid relay name! + ¡Nombre de servidor no válido! + alert title Invalid response @@ -4585,11 +5053,21 @@ More improvements are coming soon! Invitar amigos No comment provided by engineer. + + Invite member + Invitar miembro + No comment provided by engineer. + Invite members Invitar miembros No comment provided by engineer. + + Invite someone privately + Invitación privada + No comment provided by engineer. + Invite to chat Invitar al chat @@ -4666,6 +5144,11 @@ More improvements are coming soon! Unirme como %@ No comment provided by engineer. + + Join channel + Unirme al canal + No comment provided by engineer. + Join group Unirme al grupo @@ -4753,6 +5236,16 @@ This is your link for group %@! Salir swipe action + + Leave channel + Salir del canal + No comment provided by engineer. + + + Leave channel? + ¿Salir del canal? + No comment provided by engineer. + Leave chat Salir del chat @@ -4778,6 +5271,11 @@ This is your link for group %@! Menos tráfico en redes móviles. No comment provided by engineer. + + Let someone connect to you + Conecta con alguien + No comment provided by engineer. + Let's talk in SimpleX Chat Hablemos en SimpleX Chat @@ -4798,6 +5296,11 @@ This is your link for group %@! ¡Enlazar aplicación móvil con ordenador! 🔗 No comment provided by engineer. + + Link signature verified. + Firma del enlace verificada. + owner verification + Linked desktop options Opciones ordenador enlazado @@ -4808,6 +5311,11 @@ This is your link for group %@! Ordenadores enlazados No comment provided by engineer. + + Links + Enlaces + No comment provided by engineer. + List Lista @@ -4933,6 +5441,11 @@ This is your link for group %@! Miembro eliminado, no puede aceptar solicitudes No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Los mensajes del miembro serán eliminados. ¡No puede deshacerse! + alert message + Member reports Informes de miembros @@ -4956,12 +5469,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! El miembro será eliminado del chat. ¡No puede deshacerse! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! El miembro será expulsado del grupo. ¡No puede deshacerse! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4973,6 +5486,11 @@ This is your link for group %@! Los miembros pueden añadir reacciones a los mensajes. No comment provided by engineer. + + Members can chat with admins. + Los miembros pueden chatear con los administradores. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Los miembros del grupo pueden eliminar mensajes de forma irreversible. (24 horas) @@ -5038,6 +5556,11 @@ This is your link for group %@! Borrador de mensaje No comment provided by engineer. + + Message error + Mensaje de error + No comment provided by engineer. + Message forwarded Mensaje reenviado @@ -5133,6 +5656,16 @@ This is your link for group %@! ¡Los mensajes nuevos de %@ serán mostrados! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + Los mensajes en este canal **no están cifrados de extremo a extremo**. Los servidores pueden ver estos mensajes. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + Los mensajes en este canal no están cifrados de extremo a extremo. Los servidores pueden ver estos mensajes. + E2EE info chat item + Messages in this chat will never be deleted. Los mensajes de esta conversación nunca se eliminan. @@ -5163,16 +5696,16 @@ This is your link for group %@! Los mensajes, archivos y llamadas están protegidos mediante **cifrado de extremo a extremo resistente a tecnología cuántica** con secreto perfecto hacía adelante, repudio y recuperación tras ataque. No comment provided by engineer. + + Migrate + Migrar + No comment provided by engineer. + Migrate device Migrar dispositivo No comment provided by engineer. - - Migrate from another device - Migrar desde otro dispositivo - No comment provided by engineer. - Migrate here Migrar aquí @@ -5293,6 +5826,11 @@ This is your link for group %@! Servidores y Redes No comment provided by engineer. + + Network commitments + Compromisos en la red + No comment provided by engineer. + Network connection Conexión de red @@ -5303,6 +5841,11 @@ This is your link for group %@! Descentralización de la red No comment provided by engineer. + + Network error + Error de red + conn error description + Network issues - message expired after many attempts to send it. Problema en la red - el mensaje ha expirado tras muchos intentos de envío. @@ -5318,6 +5861,13 @@ This is your link for group %@! Operador de red No comment provided by engineer. + + Network routers cannot know +who talks to whom + Los routers de la red no pueden saber +quién se comunica con quién + No comment provided by engineer. + Network settings Configuración de red @@ -5326,13 +5876,18 @@ This is your link for group %@! Network status Estado de la red - No comment provided by engineer. + alert title New Nuevo token status text + + New 1-time link + Nuevo enlace de 1 solo uso + No comment provided by engineer. + New Passcode Código Nuevo @@ -5358,6 +5913,11 @@ This is your link for group %@! Nueva experiencia de chat 🎉 No comment provided by engineer. + + New chat relay + Nuevo servidor de chat + No comment provided by engineer. + New contact request Nueva solicitud de contacto @@ -5428,11 +5988,33 @@ This is your link for group %@! No No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + Sin cuenta. Sin teléfono. Sin email. Sin ID. +El cifrado más seguro. + No comment provided by engineer. + + + No active relays + Sin servidores activos + No comment provided by engineer. + No app password Sin contraseña de la aplicación Authentication unavailable + + No chat relays + Sin servidores de chat + No comment provided by engineer. + + + No chat relays enabled. + Ningún servidor de chat activado. + servers warning + No chats Sin chats @@ -5475,7 +6057,7 @@ This is your link for group %@! No direct connection yet, message is forwarded by admin. - Aún no hay conexión directa con este miembro, el mensaje es reenviado por el administrador. + Aún no hay conexión directa, los mensajes son reenviados por el administrador. item status description @@ -5578,11 +6160,26 @@ This is your link for group %@! Ningún chat sin leer No comment provided by engineer. - - No user identifiers. - Sin identificadores de usuario. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + Nadie monitorizaba tus conversaciones. Nadie registraba tus ubicaciones. La privacidad nunca fue un lujo, era la manera de vivir. No comment provided by engineer. + + Non-profit governance + Gobernanza no lucrativa + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No un candado mejorado en la puerta de otro. No un terrateniente que respeta tu privacidad pero sigue guardando un registro de tus visitantes. Tu no eres el invitado. Estás en tu casa y ningún rey podrá entrar. Tu eres el soberano. + No comment provided by engineer. + + + Not all relays connected + Hay servidores no conectados + alert title + Not compatible! ¡No compatible! @@ -5640,7 +6237,7 @@ This is your link for group %@! OK OK - No comment provided by engineer. + alert button Off @@ -5659,11 +6256,21 @@ new chat action Base de datos antigua No comment provided by engineer. + + On your phone, not on servers. + En tu teléfono, no en algún servidor. + No comment provided by engineer. + One-time invitation link Enlace de invitación de un solo uso No comment provided by engineer. + + One-time link + Enlace de un solo uso + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5683,6 +6290,11 @@ Requiere activación de la VPN. No se usarán hosts .onion. No comment provided by engineer. + + Only channel owners can change channel preferences. + Sólo los propietarios pueden modificar las preferencias de los canales. + No comment provided by engineer. + Only chat owners can change preferences. Sólo los propietarios del chat pueden cambiar las preferencias. @@ -5786,7 +6398,8 @@ Requiere activación de la VPN. Open Abrir - alert action + alert action +alert button Open Settings @@ -5798,6 +6411,11 @@ Requiere activación de la VPN. Abrir cambios No comment provided by engineer. + + Open channel + Abrir canal + new chat action + Open chat Abrir chat @@ -5818,6 +6436,11 @@ Requiere activación de la VPN. Abrir condiciones No comment provided by engineer. + + Open external link? + ¿Abrir enlace externo? + alert title + Open full link Abrir enlace completo @@ -5838,6 +6461,11 @@ Requiere activación de la VPN. Abrir menú migración a otro dispositivo authentication reason + + Open new channel + Abrir canal nuevo + new chat action + Open new chat Abrir chat nuevo @@ -5883,6 +6511,17 @@ Requiere activación de la VPN. Servidor del operador alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + Los operadores se comprometen a: +- Ser independientes +- Minimizar el tratamiento de metadatos +- Ejecutar código open-source verificado + No comment provided by engineer. + Or import archive file O importa desde un archivo @@ -5903,9 +6542,14 @@ Requiere activación de la VPN. O comparte de forma segura este enlace al archivo No comment provided by engineer. + + Or show QR in person or via video call. + O muestra el código QR en persona o por videollamada. + No comment provided by engineer. + Or show this code - O muestra el código QR + O muestra este código No comment provided by engineer. @@ -5913,6 +6557,11 @@ Requiere activación de la VPN. O para compartir en privado No comment provided by engineer. + + Or use this QR - print or show online. + O usa el QR, imprímelo o muestralo en línea. + No comment provided by engineer. + Organize chats into lists Organiza tus chats en listas @@ -5930,6 +6579,21 @@ Requiere activación de la VPN. %@ alert message + + Owner + Propietario + No comment provided by engineer. + + + Owners + Propietarios + No comment provided by engineer. + + + Ownership: you can run your own relays. + En propiedad: puedes poner en marcha tus propios servidores. + No comment provided by engineer. + PING count Contador PING @@ -5985,6 +6649,11 @@ Requiere activación de la VPN. Pegar imagen No comment provided by engineer. + + Paste link / Scan + Pegar enlace / Escanear + No comment provided by engineer. + Paste link to connect! Pegar enlace para conectar! @@ -6139,6 +6808,16 @@ Error: %@ Conserva el último borrador del mensaje con los datos adjuntos. No comment provided by engineer. + + Preset relay address + Direcciones predefinidas + No comment provided by engineer. + + + Preset relay name + Nombres predefinidos + No comment provided by engineer. + Preset server address Dirección predefinida del servidor @@ -6174,14 +6853,14 @@ Error: %@ Política de privacidad y condiciones de uso. No comment provided by engineer. - - Privacy redefined - Privacidad redefinida + + Privacy: for owners and subscribers. + Privacidad: para propietarios y suscriptores. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores. + + Private and secure messaging. + Mensajería segura y privada. No comment provided by engineer. @@ -6224,6 +6903,11 @@ Error: %@ Timeout enrutamiento privado alert title + + Proceed + Continuar + alert action + Profile and server connections Eliminar perfil y conexiones @@ -6249,9 +6933,9 @@ Error: %@ Tema del perfil No comment provided by engineer. - - Profile update will be sent to your contacts. - La actualización del perfil se enviará a tus contactos. + + Profile update will be sent to your SimpleX contacts. + La actualización del perfil se enviará a tus contactos SimpleX. alert message @@ -6259,6 +6943,11 @@ Error: %@ No se permiten llamadas y videollamadas. No comment provided by engineer. + + Prohibit chats with admins. + El chat con los administradores no está permitido. + No comment provided by engineer. + Prohibit irreversible message deletion. No se permite la eliminación irreversible de mensajes. @@ -6289,6 +6978,11 @@ Error: %@ No se permiten mensajes directos entre miembros. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No se permiten mensajes directos entre suscriptores. + No comment provided by engineer. + Prohibit sending disappearing messages. No se permiten mensajes temporales. @@ -6356,6 +7050,11 @@ Actívalo en ajustes de *Servidores y Redes*. El proxy requiere contraseña No comment provided by engineer. + + Public channels - speak freely 🚀 + Canales públicos - habla con libertad 🚀 + No comment provided by engineer. + Push notifications Notificaciones push @@ -6396,24 +7095,14 @@ Actívalo en ajustes de *Servidores y Redes*. Saber más No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Conoce más en la [Guía del Usuario](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Conoce más en la Guía del Usuario. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Conoce más en el [Manual del Usuario](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Conoce más en el [Manual del Usuario](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Conoce más en nuestro [repositorio GitHub](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Conoce más en nuestro repositorio GitHub. No comment provided by engineer. @@ -6436,11 +7125,6 @@ Actívalo en ajustes de *Servidores y Redes*. Recibido: %@ copied message info - - Received file event - Evento de archivo recibido - notification - Received message Mensaje entrante @@ -6578,6 +7262,31 @@ swipe action ¿Rechazar al miembro? alert title + + Relay + Servidor + No comment provided by engineer. + + + Relay address + Dirección del servidor + alert title + + + Relay connection failed + La conexión con el servidor ha fallado + alert title + + + Relay link + Enlace servidor + No comment provided by engineer. + + + Relay results: + Resultados del servidor: + alert message + Relay server is only used if necessary. Another party can observe your IP address. El servidor de retransmisión sólo se usa en caso de necesidad. Un tercero podría ver tu IP. @@ -6588,10 +7297,25 @@ swipe action El servidor de retransmisión protege tu IP pero puede ver la duración de la llamada. No comment provided by engineer. + + Relay test failed! + ¡El test del servidor ha fallado! + No comment provided by engineer. + + + Reliability: many relays per channel. + Fiabilidad: muchos servidores por canal. + No comment provided by engineer. + Remove Eliminar - No comment provided by engineer. + alert action + + + Remove and delete messages + Eliminar miembro y sus mensajes + alert action Remove archive? @@ -6616,13 +7340,23 @@ swipe action Remove member? ¿Expulsar miembro? - No comment provided by engineer. + alert title Remove passphrase from keychain? ¿Eliminar contraseña de Keychain? No comment provided by engineer. + + Remove subscriber + Eliminar suscriptor + No comment provided by engineer. + + + Remove subscriber? + ¿Eliminar suscriptor? + alert title + Removes messages and blocks members. Elimina mensajes y bloquea miembros. @@ -6858,6 +7592,11 @@ swipe action Proxy SOCKS No comment provided by engineer. + + Safe web links + Enlaces web seguros + No comment provided by engineer. + Safely receive files Recibe archivos de forma segura @@ -6884,6 +7623,11 @@ chat item action Guardar (y notificar miembros) alert button + + Save (and notify subscribers) + Guardar (y notificar suscriptores) + alert button + Save admission settings? ¿Guardar configuración? @@ -6899,6 +7643,11 @@ chat item action Guardar y notificar grupo No comment provided by engineer. + + Save and notify subscribers + Guardar y notificar suscriptores + No comment provided by engineer. + Save and reconnect Guardar y reconectar @@ -6909,6 +7658,16 @@ chat item action Guardar y actualizar perfil del grupo No comment provided by engineer. + + Save channel profile + Guardar perfil del canal + No comment provided by engineer. + + + Save channel profile? + ¿Guardar perfil del canal? + alert title + Save group profile Guardar perfil de grupo @@ -7034,11 +7793,36 @@ chat item action La barra de búsqueda acepta enlaces de invitación. No comment provided by engineer. + + Search files + Buscar archivos + No comment provided by engineer. + + + Search images + Buscar imágenes + No comment provided by engineer. + + + Search links + Buscar enlaces + No comment provided by engineer. + Search or paste SimpleX link Buscar o pegar enlace SimpleX No comment provided by engineer. + + Search videos + Buscar vídeos + No comment provided by engineer. + + + Search voice messages + Buscar mensajes de voz + No comment provided by engineer. + Secondary Secundario @@ -7064,6 +7848,11 @@ chat item action Código de seguridad No comment provided by engineer. + + Security: owners hold channel keys. + Seguridad: los propietarios tienen la llave del canal. + No comment provided by engineer. + Select Seleccionar @@ -7194,6 +7983,11 @@ chat item action Enviar solicitud sin mensaje No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + Envía el enlace con cualquier mensajero, es seguro. El contacto debe pegarlo en SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Envíalos desde la galería o desde teclados personalizados. @@ -7204,6 +7998,11 @@ chat item action Se envían hasta 100 mensajes más recientes a los miembros nuevos. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + Se envían hasta 100 mensajes más recientes a los suscriptores nuevos. + No comment provided by engineer. + Send your private feedback to groups. Envía tu comentario privado a los grupos. @@ -7219,6 +8018,11 @@ chat item action El remitente puede haber eliminado la solicitud de conexión. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + Enviar una previsualización del enlace puede revelar tu dirección IP al sitio web. Puedes cambiarlo más tarde en los ajustes de privacidad. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. El envío de confirmaciones de entrega se activará para todos los contactos en todos los perfiles visibles. @@ -7274,11 +8078,6 @@ chat item action Directamente No comment provided by engineer. - - Sent file event - Evento de archivo enviado - notification - Sent message Mensaje saliente @@ -7349,14 +8148,19 @@ chat item action El protocolo del servidor ha cambiado. alert title + + Server requires authorization to connect to relay, check password. + El servidor requiere autorización para conectar con el servidor, comprueba la contraseña. + relay test error + Server requires authorization to create queues, check password. - El servidor requiere autorización para crear colas, comprueba la contraseña + El servidor requiere autorización para crear colas, comprueba la contraseña. server test error Server requires authorization to upload, check password. - El servidor requiere autorización para subir, comprueba la contraseña + El servidor requiere autorización para subir, comprueba la contraseña. server test error @@ -7479,6 +8283,16 @@ chat item action La configuración ha sido modificada. alert message + + Setup notifications + Configurar notificaciones + No comment provided by engineer. + + + Setup routers + Configurar routers + No comment provided by engineer. + Shape profile images Dar forma a las imágenes de perfil @@ -7515,11 +8329,16 @@ chat item action Campartir dirección públicamente No comment provided by engineer. - - Share address with contacts? - ¿Compartir la dirección con los contactos? + + Share address with SimpleX contacts? + ¿Compartir la dirección con los contactos SimpleX? alert title + + Share channel + Compartir canal + No comment provided by engineer. + Share from other apps. Comparte desde otras aplicaciones. @@ -7545,6 +8364,11 @@ chat item action Perfil a compartir No comment provided by engineer. + + Share relay address + Compartir dirección del servidor + No comment provided by engineer. + Share this 1-time invite link Comparte este enlace de un solo uso @@ -7555,9 +8379,14 @@ chat item action Compartir con Simplex No comment provided by engineer. - - Share with contacts - Compartir con contactos + + Share via chat + Compartir mediante chat + No comment provided by engineer. + + + Share with SimpleX contacts + Compartir con contactos SimpleX No comment provided by engineer. @@ -7730,9 +8559,9 @@ chat item action Protocolos de SimpleX auditados por Trail of Bits. No comment provided by engineer. - - SimpleX relay link - Enlace de servidor SimpleX + + SimpleX relay address + Dirección de servidor SimpleX simplex link type @@ -7808,6 +8637,11 @@ report reason Cuadrada, circular o cualquier forma intermedia. No comment provided by engineer. + + Star on GitHub + Estrella en GitHub + No comment provided by engineer. + Start chat Iniciar chat @@ -7908,6 +8742,78 @@ report reason Suscritas No comment provided by engineer. + + Subscriber + Suscriptor + No comment provided by engineer. + + + Subscriber reports + Informes de suscriptores + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + El suscriptor será eliminado del canal. ¡No puede deshacerse! + alert message + + + Subscribers + Suscriptores + No comment provided by engineer. + + + Subscribers can add message reactions. + Los suscriptores pueden añadir reacciones a los mensajes. + No comment provided by engineer. + + + Subscribers can chat with admins. + Los suscriptores pueden chatear con los administradores. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + Los suscriptores del canal pueden eliminar mensajes de forma irreversible. (24 horas) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + Los suscriptores pueden informar de mensajes a los moderadores. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + Los suscriptores del canal pueden enviar enlaces SimpleX. + No comment provided by engineer. + + + Subscribers can send direct messages. + Los suscriptores del canal pueden enviar mensajes directos. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + Los suscriptores del canal pueden enviar mensajes temporales. + No comment provided by engineer. + + + Subscribers can send files and media. + Los suscriptores del canal pueden enviar archivos y multimedia. + No comment provided by engineer. + + + Subscribers can send voice messages. + Los suscriptores del canal pueden enviar mensajes de voz. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + Los suscriptores usan el enlace del servidor para conectarse a los canales. +La dirección del servidor se usó para establecer el servidor para el canal. + No comment provided by engineer. + Subscription errors Errores de suscripción @@ -7988,6 +8894,11 @@ report reason Hacer foto No comment provided by engineer. + + Talk to someone + Para comunicarte + No comment provided by engineer. + Tap Connect to chat Pulsa Conectar para chatear @@ -8003,9 +8914,9 @@ report reason Pulsa Conectar para usar el bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Pulsa Crear dirección SimpleX en el menú para crearla más tarde. + + Tap Join channel + Pulsa Unirme al canal No comment provided by engineer. @@ -8038,6 +8949,11 @@ report reason Pulsa para unirte en modo incógnito No comment provided by engineer. + + Tap to open + Pulsa para abrir + No comment provided by engineer. + Tap to paste link Pulsa aquí para pegar el enlace @@ -8056,13 +8972,19 @@ report reason Test failed at step %@. Prueba no superada en el paso %@. - server test failure + relay test failure +server test failure Test notifications Probar notificaciones No comment provided by engineer. + + Test relay + Test servidor + No comment provided by engineer. + Test server Probar servidor @@ -8115,6 +9037,11 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. La aplicación protege tu privacidad mediante el uso de diferentes operadores en cada conversación. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + La app ha eliminado el mensaje tras %lld intentos de recibirlo. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). La aplicación pedirá que confirmes las descargas desde servidores de archivos desconocidos (excepto si son .onion). @@ -8130,6 +9057,11 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. El código QR escaneado no es un enlace de SimpleX. No comment provided by engineer. + + The connection reached the limit of undelivered messages + La conexión ha alcanzado al límite de mensajes no entregados + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. La conexión ha alcanzado el límite de mensajes no entregados. es posible que tu contacto esté desconectado. @@ -8155,9 +9087,11 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. El cifrado funciona y un cifrado nuevo no es necesario. ¡Podría dar lugar a errores de conexión! No comment provided by engineer. - - The future of messaging - La nueva generación de mensajería privada + + The first network where you own +your contacts and groups. + La primera red donde los grupos +y los contactos son tuyos. No comment provided by engineer. @@ -8195,6 +9129,11 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. La base de datos antigua no se eliminó durante la migración, puede eliminarse. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + La libertad más antigua del ser humano, la de hablar con otra persona sin ser observado, materializada sobre una infraestructura que no puede traicionarla. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Las mismas condiciones se aplicarán al operador **%@**. @@ -8240,6 +9179,16 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Temas No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + Después pasamos a internet y cada plataforma pedía una parte de tí: tu nombre, tu número, tus amistades. Aceptamos que el precio de hablar con los demás es informar a alguien de quién es interlocutor. Cada generación, personas y tecnología, ha funcionado así: teléfono, email, mensajería, redes sociales. Parecía el único camino. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + Existe otro camino. Una red sin números de teléfono. Sin nombres de usuario. Sin cuentas. Sin identificadores de ningún tipo. Una red que conecta las personas y entrega mensajes cifrados sin saber quien está conectado. + No comment provided by engineer. + These conditions will also apply for: **%@**. Estas condiciones también se aplican para: **%@**. @@ -8305,6 +9254,16 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Este grupo ya no existe. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + Esto es una dirección de servidor, no puede usarse para conectar. + alert message + + + This is your link for channel %@! + Este es tu enlace para el canal %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible. @@ -8355,6 +9314,11 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Para ocultar mensajes no deseados. No comment provided by engineer. + + To make SimpleX Network last. + Para que la Red SimpleX perdure. + No comment provided by engineer. + To make a new connection Para hacer una conexión nueva @@ -8442,11 +9406,6 @@ Se te pedirá que completes la autenticación antes de activar esta función.Para verificar el cifrado de extremo a extremo con tu contacto, compara (o escanea) el código en ambos dispositivos. No comment provided by engineer. - - Toggle chat list: - Alternar lista de chats: - No comment provided by engineer. - Toggle incognito when connecting. Activa incógnito al conectar. @@ -8462,6 +9421,11 @@ Se te pedirá que completes la autenticación antes de activar esta función.Opacidad barra No comment provided by engineer. + + Top bar + Barra superior + No comment provided by engineer. + Total Total @@ -8477,15 +9441,10 @@ Se te pedirá que completes la autenticación antes de activar esta función.Sesiones de transporte No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Intentando conectar con el servidor usado para recibir mensajes de este contacto (error: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Intentando conectar con el servidor usado para recibir mensajes de este contacto. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Intentando conectar con el servidor usado para recibir mensajes de esta conexión. + subscription status explanation Turkish interface @@ -8532,6 +9491,11 @@ Se te pedirá que completes la autenticación antes de activar esta función.¿Desbloquear miembro? No comment provided by engineer. + + Unblock subscriber for all? + ¿Desbloquear al suscriptor para todos? + No comment provided by engineer. + Undelivered messages Mensajes no entregados @@ -8632,13 +9596,18 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Unsupported connection link Enlace de conexión no compatible - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Hasta 100 últimos mensajes son enviados a los miembros nuevos. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + Hasta 100 últimos mensajes son enviados a los suscriptores nuevos. + No comment provided by engineer. + Update Actualizar @@ -8764,11 +9733,6 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Usar puerto TCP 443 solo en servidores predefinidos. No comment provided by engineer. - - Use chat - Usar Chat - No comment provided by engineer. - Use current profile Usar perfil actual @@ -8784,6 +9748,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Uso para mensajes No comment provided by engineer. + + Use for new channels + Usar para canales nuevos + No comment provided by engineer. + Use for new connections Para conexiones nuevas @@ -8824,6 +9793,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Usar enrutamiento privado con servidores de mensaje desconocidos. No comment provided by engineer. + + Use relay + Usar servidor + No comment provided by engineer. + Use server Usar servidor @@ -8844,6 +9818,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Usa la aplicación con una sola mano. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + Usa esta dirección en tu perfil de redes sociales, página web o firma email. + No comment provided by engineer. + Use web port Usar puerto web @@ -8864,6 +9843,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Usar servidores SimpleX Chat. No comment provided by engineer. + + Verify + Verificar + relay test step + Verify code with desktop Verificar código con ordenador @@ -8924,6 +9908,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión El vídeo se recibirá cuando el contacto esté en línea, por favor espera o revisa más tarde. No comment provided by engineer. + + Videos + Vídeos + No comment provided by engineer. + Videos and files up to 1gb Vídeos y archivos de hasta 1Gb @@ -8979,6 +9968,21 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Mensaje de voz… No comment provided by engineer. + + Wait + Espera + alert action + + + Wait response + Espera respuesta + relay test step + + + Waiting for channel owner to add relays. + Esperando a que el propietario del canal añada servidores. + No comment provided by engineer. + Waiting for desktop... Esperando ordenador... @@ -9019,6 +10023,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Atención: ¡puedes perder algunos datos! No comment provided by engineer. + + We made connecting simpler for new users. + Hemos simplificado la conexión para los usuarios nuevos. + No comment provided by engineer. + WebRTC ICE servers Servidores WebRTC ICE @@ -9069,6 +10078,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Cuando compartes un perfil incógnito con alguien, este perfil también se usará para los grupos a los que te inviten. No comment provided by engineer. + + Why SimpleX is built. + Por qué fue creado SimpleX. + No comment provided by engineer. + WiFi WiFi @@ -9196,16 +10210,21 @@ Repeat join request? ¿Repetir solicitud de admisión? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Estás conectado al servidor usado para recibir mensajes de este contacto. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Estás conectado al servidor usado para recibir mensajes de esta conexión. + subscription status explanation You are invited to group Has sido invitado a un grupo No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. @@ -9276,9 +10295,14 @@ Repeat join request? Puedes configurar las notificaciones de la pantalla de bloqueo desde Configuración. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + Puedes compartir un enlace o código QR. Cualquiera podrá unirse al canal. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. - Puedes compartir el enlace o el código QR para que cualquiera pueda unirse al grupo. Si más tarde lo eliminas, no afectará a los miembros del grupo. + Puedes compartir el enlace o código QR. Cualquiera podrá unirse al grupo. Si más tarde lo eliminas, no afectará a los miembros del grupo. No comment provided by engineer. @@ -9321,16 +10345,25 @@ Repeat join request? ¡No puedes enviar mensajes! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + Te comprometes a: +- Sólo contenido legal en grupos públicos +- Respetar a los demás usuarios — no hacer spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + Te conectaste al canal mediante este enlace de servidor. + No comment provided by engineer. + You could not be verified; please try again. No has podido ser autenticado. Inténtalo de nuevo. No comment provided by engineer. - - You decide who can connect. - Tu decides quién se conecta. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9398,6 +10431,11 @@ Repeat connection request? Deberías recibir notificaciones. token info + + You were born without an account + Naciste sin una cuenta + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. Podrás enviar mensajes **después de que tu solicitud sea aceptada**. @@ -9433,9 +10471,14 @@ Repeat connection request? Seguirás recibiendo llamadas y notificaciones de los perfiles silenciados cuando estén activos. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + Dejarás de recibir mensajes de este canal. El historial del chat se conservará. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. - Dejarás de recibir mensajes del chat. El historial del chat se conserva. + Dejarás de recibir mensajes del chat. El historial del chat se conservará. No comment provided by engineer. @@ -9478,6 +10521,11 @@ Repeat connection request? Llamadas No comment provided by engineer. + + Your channel + Tu canal + No comment provided by engineer. + Your chat database Base de datos @@ -9528,6 +10576,11 @@ Repeat connection request? Tus contactos permanecerán conectados. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + Tus conversaciones te pertenecen, tal como ha sido siempre antes de la llegada de internet. Tu red no es un lugar que visitas. Es un lugar que has creado, te pertenece y nadie te la podrá quitar, ya sea pública o privada. + No comment provided by engineer. + Your credentials may be sent unencrypted. Tus credenciales podrían ser enviadas sin cifrar. @@ -9548,6 +10601,11 @@ Repeat connection request? Mi grupo No comment provided by engineer. + + Your network + Tu red + No comment provided by engineer. + Your preferences Mis preferencias @@ -9563,6 +10621,13 @@ Repeat connection request? Tu perfil No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + El perfil **%@** será compartido con los servidores de canal y los suscriptores. +Los servidores tienen acceso a los mensajes del canal. + No comment provided by engineer. + Your profile **%@** will be shared. El perfil **%@** será compartido. @@ -9575,7 +10640,7 @@ Repeat connection request? Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. - Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. + Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil. No comment provided by engineer. @@ -9583,11 +10648,26 @@ Repeat connection request? Tu perfil ha sido modificado. Si lo guardas la actualización será enviada a todos tus contactos. alert message + + Your public address + Tu dirección pública + No comment provided by engineer. + Your random profile Tu perfil aleatorio No comment provided by engineer. + + Your relay address + Tu dirección de servidor + No comment provided by engineer. + + + Your relay name + Tu nombre del servidor + No comment provided by engineer. + Your server address Dirección del servidor @@ -9603,21 +10683,11 @@ Repeat connection request? Configuración No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Contribuye](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Contacta vía email](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Estrella en GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_italic_ @@ -9633,6 +10703,11 @@ Repeat connection request? y después elige: No comment provided by engineer. + + accepted + aceptado + No comment provided by engineer. + accepted %@ %@ aceptado @@ -9650,9 +10725,14 @@ Repeat connection request? accepted you - te ha aceptado + te ha admitido rcv group event chat item + + active + activo + No comment provided by engineer. + admin administrador @@ -9764,6 +10844,11 @@ marked deleted chat item preview text llamando… call status + + can't broadcast + no puedes retransmitir + No comment provided by engineer. + can't send messages no se pueden enviar mensajes @@ -9799,6 +10884,16 @@ marked deleted chat item preview text cambiando de servidor… chat item text + + channel + canal + shown as sender role for channel messages + + + channel profile updated + perfil del canal actualizado + snd group event chat item + colored coloreado @@ -9945,6 +11040,11 @@ pref value eliminado deleted chat item + + deleted channel + canal eliminado + rcv group event chat item + deleted contact contacto eliminado @@ -10055,11 +11155,21 @@ pref value error No comment provided by engineer. + + error: %@ + error: %@ + receive error chat item + expired expirados No comment provided by engineer. + + failed + fallo + No comment provided by engineer. + forwarded reenviado @@ -10180,6 +11290,11 @@ pref value ha salido rcv group event chat item + + link + enlace + No comment provided by engineer. + marked deleted marcado eliminado @@ -10250,6 +11365,11 @@ pref value nunca delete after time + + new + nuevo + No comment provided by engineer. + new message mensaje nuevo @@ -10265,6 +11385,11 @@ pref value sin cifrar No comment provided by engineer. + + no subscription + sin suscripciones + No comment provided by engineer. + no text sin texto @@ -10368,6 +11493,11 @@ time to disappear llamada rechazada call status + + relay + servidor + member role + removed expulsado @@ -10378,6 +11508,16 @@ time to disappear ha expulsado a %@ rcv group event chat item + + removed (%d attempts) + eliminado (%d intentos) + receive error chat item + + + removed by operator + eliminado por el operador + No comment provided by engineer. + removed contact address dirección de contacto eliminada @@ -10529,9 +11669,14 @@ last received msg: %2$@ unprotected - con IP desprotegida + desprotegida No comment provided by engineer. + + updated channel profile + perfil del canal actualizado + rcv group event chat item + updated group profile ha actualizado el perfil del grupo @@ -10552,6 +11697,11 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + mediante %@ + relay hostname + via contact address link mediante enlace de dirección de contacto @@ -10619,7 +11769,7 @@ last received msg: %2$@ you accepted this member - has aceptado al miembro + has admitido al miembro snd group event chat item @@ -10627,6 +11777,11 @@ last received msg: %2$@ Tu rol es observador No comment provided by engineer. + + you are subscriber + eres suscriptor + No comment provided by engineer. + you blocked %@ has bloqueado a %@ @@ -10687,6 +11842,11 @@ last received msg: %2$@ \~strike~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + ⚠️ Verificación de firma fallida: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 6e400a6078..892f686bd2 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -172,6 +172,21 @@ %d kuukautta time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d sek @@ -186,11 +201,53 @@ %d ohitettua viestiä integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d viikkoa time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -201,6 +258,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld kontaktia valittu @@ -296,10 +357,18 @@ %u viestit ohitettu. No comment provided by engineer. + + (from owner) + chat link info line + (new) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) No comment provided by engineer. @@ -340,6 +409,10 @@ **Scan / Paste link**: to connect via a link you received. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Varoitus**: Välittömät push-ilmoitukset vaativat tunnuslauseen, joka on tallennettu Keychainiin. @@ -379,6 +452,12 @@ - ja paljon muuta! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -470,6 +549,10 @@ time interval Muutama asia lisää No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact Uusi kontakti @@ -584,9 +667,8 @@ swipe action Active connections No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Lisää osoite profiiliisi, jotta kontaktisi voivat jakaa sen muiden kanssa. Profiilipäivitys lähetetään kontakteillesi. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -646,6 +728,10 @@ swipe action Added message servers No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent No comment provided by engineer. @@ -726,6 +812,10 @@ swipe action Kaikki ryhmän jäsenet pysyvät yhteydessä. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -747,6 +837,14 @@ swipe action All profiles profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. No comment provided by engineer. @@ -801,6 +899,10 @@ swipe action Salli peruuttamaton viestien poisto vain, jos kontaktisi sallii ne sinulle. (24 tuntia) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Salli reaktiot viesteihin vain, jos kontaktisi sallii ne. @@ -816,6 +918,10 @@ swipe action Salli yksityisviestien lähettäminen jäsenille. No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Salli katoavien viestien lähettäminen. @@ -825,6 +931,10 @@ swipe action Allow sharing No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Salli lähetettyjen viestien peruuttamaton poistaminen. (24 tuntia) @@ -923,11 +1033,6 @@ swipe action Vastaa puheluun No comment provided by engineer. - - Anybody can host servers. - Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia. - No comment provided by engineer. - App build: %@ Sovellusversio: %@ @@ -1042,6 +1147,10 @@ swipe action Ääni- ja videopuhelut No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Ääni/videopuhelut @@ -1110,6 +1219,19 @@ swipe action Virheellinen viestin tarkiste No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls No comment provided by engineer. @@ -1187,6 +1309,10 @@ swipe action Block member? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin No comment provided by engineer. @@ -1232,13 +1358,21 @@ swipe action Sekä sinä että kontaktisi voitte lähettää ääniviestejä. No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. Business address - No comment provided by engineer. + chat link info line Business chats @@ -1257,12 +1391,6 @@ swipe action Chat-profiilin mukaan (oletus) tai [yhteyden mukaan](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - No comment provided by engineer. - Call already ended! Puhelu on jo päättynyt! @@ -1399,6 +1527,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat No comment provided by engineer. @@ -1475,6 +1664,22 @@ set passcode view Käyttäjäprofiili No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme No comment provided by engineer. @@ -1489,7 +1694,8 @@ set passcode view Chat with admins - chat toolbar + chat feature +chat toolbar Chat with member @@ -1504,10 +1710,22 @@ set passcode view Keskustelut No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -1516,6 +1734,14 @@ set passcode view Check messages when allowed. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. Tarkista palvelimen osoite ja yritä uudelleen. @@ -1624,7 +1850,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1643,8 +1869,8 @@ set passcode view Määritä ICE-palvelimet No comment provided by engineer. - - Configure server operators + + Configure relays No comment provided by engineer. @@ -1699,7 +1925,8 @@ set passcode view Connect Yhdistä - server test step + relay test step +server test step Connect automatically @@ -1736,6 +1963,10 @@ This is your own one-time link! Yhdistä linkin kautta new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Yhdistä kertalinkillä @@ -1804,6 +2035,10 @@ This is your own one-time link! Connection error (AUTH) Yhteysvirhe (AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -1849,6 +2084,10 @@ This is your own one-time link! Connections No comment provided by engineer. + + Contact address + chat link info line + Contact allows Kontakti sallii @@ -1914,6 +2153,11 @@ This is your own one-time link! Jatka No comment provided by engineer. + + Contribute + Osallistu + No comment provided by engineer. + Conversation deleted! No comment provided by engineer. @@ -1938,12 +2182,7 @@ This is your own one-time link! Correct name to %@? - No comment provided by engineer. - - - Create - Luo - No comment provided by engineer. + alert message Create 1-time link @@ -1991,6 +2230,14 @@ This is your own one-time link! Luo profiilisi No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue Luo jono @@ -2000,11 +2247,19 @@ This is your own one-time link! Create your address No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile Luo profiilisi No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created No comment provided by engineer. @@ -2021,6 +2276,10 @@ This is your own one-time link! Creating archive link No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… No comment provided by engineer. @@ -2172,10 +2431,9 @@ This is your own one-time link! Debug delivery No comment provided by engineer. - - Decentralized - Hajautettu - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2220,6 +2478,14 @@ swipe action Delete and notify contact No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat No comment provided by engineer. @@ -2328,6 +2594,14 @@ swipe action Poista jäsenviesti? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Poista viesti? @@ -2336,7 +2610,8 @@ swipe action Delete messages Poista viestit - alert button + alert action +alert button Delete messages after @@ -2372,6 +2647,10 @@ swipe action Poista jono server test step + + Delete relay + No comment provided by engineer. + Delete report No comment provided by engineer. @@ -2519,6 +2798,14 @@ swipe action Yksityisviestit jäsenten välillä ovat kiellettyjä tässä ryhmässä. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) Poista käytöstä (pidä ohitukset) @@ -2616,6 +2903,10 @@ swipe action Do not send history to new members. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. No comment provided by engineer. @@ -2704,11 +2995,19 @@ chat item action E2E encrypted notifications. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit Muokkaa chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile Muokkaa ryhmäprofiilia @@ -2721,7 +3020,7 @@ chat item action Enable Salli - No comment provided by engineer. + alert button Enable (keep overrides) @@ -2742,6 +3041,10 @@ chat item action Ota TCP-säilytys käyttöön No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Ota automaattinen viestien poisto käyttöön? @@ -2751,6 +3054,10 @@ chat item action Enable camera access No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. No comment provided by engineer. @@ -2769,16 +3076,15 @@ chat item action Salli välittömät ilmoitukset? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock Ota lukitus käyttöön No comment provided by engineer. - - Enable notifications - Salli ilmoitukset - No comment provided by engineer. - Enable periodic notifications? Salli säännölliset ilmoitukset? @@ -2877,6 +3183,10 @@ chat item action Syötä pääsykoodi No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Anna oikea tunnuslause. @@ -2900,6 +3210,14 @@ chat item action Kirjoita yllä oleva salasana näyttääksesi! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually Syötä palvelin manuaalisesti @@ -2926,7 +3244,7 @@ chat item action Error Virhe - No comment provided by engineer. + conn error description Error aborting address change @@ -2951,6 +3269,10 @@ chat item action Virhe lisättäessä jäseniä No comment provided by engineer. + + Error adding relay + alert title + Error adding server alert title @@ -2994,11 +3316,19 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Virhe osoitteen luomisessa No comment provided by engineer. + + Error creating channel + alert title + Error creating group Virhe ryhmän luomisessa @@ -3124,10 +3454,6 @@ chat item action Error opening chat No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file Virhe tiedoston vastaanottamisessa @@ -3167,6 +3493,10 @@ chat item action Virhe ICE-palvelimien tallentamisessa No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list alert title @@ -3226,6 +3556,10 @@ chat item action Virhe toimituskuittauksien asettamisessa! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat Virhe käynnistettäessä keskustelua @@ -3300,7 +3634,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3479,6 +3814,10 @@ snd error text Tiedostot ja media kielletty! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Suodata lukemattomia- ja suosikkikeskusteluja. @@ -3513,7 +3852,8 @@ snd error text Fingerprint in server address does not match certificate. Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3553,9 +3893,14 @@ snd error text For all moderators No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: - servers error + servers error +servers warning For console @@ -3674,10 +4019,18 @@ Error: %2$@ GIFit ja tarrat No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! message preview @@ -3732,7 +4085,7 @@ Error: %2$@ Group link Ryhmälinkki - No comment provided by engineer. + chat link info line Group links @@ -3840,6 +4193,10 @@ Error: %2$@ History is not sent to new members. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works Miten SimpleX toimii @@ -3900,6 +4257,10 @@ Error: %2$@ Jos syötät itsetuhoutuvan pääsykoodin sovellusta avattaessa: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Jos haluat käyttää keskustelua nyt, napauta **Tee se myöhemmin** alla (sinulle tarjotaan tietokannan siirtämistä, kun käynnistät sovelluksen uudelleen). @@ -3920,16 +4281,15 @@ Error: %2$@ Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Heti No comment provided by engineer. - - Immune to spam - Immuuni roskapostille ja väärinkäytöksille - No comment provided by engineer. - Import Tuo @@ -4060,9 +4420,9 @@ More improvements are coming soon! Alkuperäinen rooli No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Asenna [SimpleX Chat terminaalille](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Asenna SimpleX Chat terminaalille No comment provided by engineer. @@ -4113,7 +4473,7 @@ More improvements are coming soon! Invalid connection link Virheellinen yhteyslinkki - No comment provided by engineer. + conn error description Invalid display name! @@ -4129,7 +4489,15 @@ More improvements are coming soon! Invalid name! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4155,11 +4523,19 @@ More improvements are coming soon! Kutsu ystäviä No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Kutsu jäseniä No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat No comment provided by engineer. @@ -4234,6 +4610,10 @@ More improvements are coming soon! Liity %@:nä No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group Liity ryhmään @@ -4313,6 +4693,14 @@ This is your link for group %@! Poistu swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat No comment provided by engineer. @@ -4335,6 +4723,10 @@ This is your link for group %@! Less traffic on mobile networks. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat Jutellaan SimpleX Chatissa @@ -4354,6 +4746,10 @@ This is your link for group %@! Link mobile and desktop apps! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options No comment provided by engineer. @@ -4362,6 +4758,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4477,6 +4877,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4497,12 +4901,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Jäsen poistetaan ryhmästä - tätä ei voi perua! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4513,6 +4917,10 @@ This is your link for group %@! Ryhmän jäsenet voivat lisätä viestireaktioita. No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti. (24 tuntia) @@ -4573,6 +4981,10 @@ This is your link for group %@! Viestiluonnos No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded item status text @@ -4655,6 +5067,14 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. alert message @@ -4679,12 +5099,12 @@ This is your link for group %@! Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. No comment provided by engineer. - - Migrate device + + Migrate No comment provided by engineer. - - Migrate from another device + + Migrate device No comment provided by engineer. @@ -4798,6 +5218,10 @@ This is your link for group %@! Verkko ja palvelimet No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection No comment provided by engineer. @@ -4806,6 +5230,10 @@ This is your link for group %@! Network decentralization No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. snd error text @@ -4818,6 +5246,11 @@ This is your link for group %@! Network operator No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings Verkkoasetukset @@ -4826,12 +5259,16 @@ This is your link for group %@! Network status Verkon tila - No comment provided by engineer. + alert title New token status text + + New 1-time link + No comment provided by engineer. + New Passcode Uusi pääsykoodi @@ -4853,6 +5290,10 @@ This is your link for group %@! New chat experience 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request Uusi kontaktipyyntö @@ -4917,11 +5358,28 @@ This is your link for group %@! Ei No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password Ei sovelluksen salasanaa Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats No comment provided by engineer. @@ -5048,11 +5506,22 @@ This is your link for group %@! No unread chats No comment provided by engineer. - - No user identifiers. - Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! No comment provided by engineer. @@ -5102,7 +5571,7 @@ This is your link for group %@! OK - No comment provided by engineer. + alert button Off @@ -5121,11 +5590,19 @@ new chat action Vanha tietokanta No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link Kertakutsulinkki No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5145,6 +5622,10 @@ Edellyttää VPN:n sallimista. Onion-isäntiä ei käytetä. No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. No comment provided by engineer. @@ -5241,7 +5722,8 @@ Edellyttää VPN:n sallimista. Open - alert action + alert action +alert button Open Settings @@ -5252,6 +5734,10 @@ Edellyttää VPN:n sallimista. Open changes No comment provided by engineer. + + Open channel + new chat action + Open chat Avaa keskustelu @@ -5270,6 +5756,10 @@ Edellyttää VPN:n sallimista. Open conditions No comment provided by engineer. + + Open external link? + alert title + Open full link alert action @@ -5286,6 +5776,10 @@ Edellyttää VPN:n sallimista. Open migration to another device authentication reason + + Open new channel + new chat action + Open new chat new chat action @@ -5322,6 +5816,13 @@ Edellyttää VPN:n sallimista. Operator server alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file No comment provided by engineer. @@ -5338,6 +5839,10 @@ Edellyttää VPN:n sallimista. Or securely share this file link No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code No comment provided by engineer. @@ -5346,6 +5851,10 @@ Edellyttää VPN:n sallimista. Or to share privately No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists No comment provided by engineer. @@ -5359,6 +5868,18 @@ Edellyttää VPN:n sallimista. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count PING-määrä @@ -5412,6 +5933,10 @@ Edellyttää VPN:n sallimista. Liitä kuva No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! No comment provided by engineer. @@ -5550,6 +6075,14 @@ Error: %@ Säilytä viimeinen viestiluonnos liitteineen. No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address Esiasetettu palvelimen osoite @@ -5581,13 +6114,12 @@ Error: %@ Privacy policy and conditions of use. No comment provided by engineer. - - Privacy redefined - Yksityisyys uudelleen määritettynä + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. + + Private and secure messaging. No comment provided by engineer. @@ -5623,6 +6155,10 @@ Error: %@ Private routing timeout alert title + + Proceed + alert action + Profile and server connections Profiili- ja palvelinyhteydet @@ -5646,9 +6182,8 @@ Error: %@ Profile theme No comment provided by engineer. - - Profile update will be sent to your contacts. - Profiilipäivitys lähetetään kontakteillesi. + + Profile update will be sent to your SimpleX contacts. alert message @@ -5656,6 +6191,10 @@ Error: %@ Estä ääni- ja videopuhelut. No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Estä peruuttamaton viestien poistaminen. @@ -5684,6 +6223,10 @@ Error: %@ Estä suorien viestien lähettäminen jäsenille. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Estä katoavien viestien lähettäminen. @@ -5744,6 +6287,10 @@ Enable in *Network & servers* settings. Proxy requires password No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Push-ilmoitukset @@ -5781,23 +6328,14 @@ Enable in *Network & servers* settings. Lue lisää No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Lue lisää Käyttöoppaasta. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Lue lisää [GitHub-arkistosta](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Lue lisää GitHub-arkistosta. No comment provided by engineer. @@ -5819,11 +6357,6 @@ Enable in *Network & servers* settings. Vastaanotettu klo: %@ copied message info - - Received file event - Tiedoston vastaanottotapahtuma - notification - Received message Vastaanotettu viesti @@ -5947,6 +6480,26 @@ swipe action Reject member? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Välityspalvelinta käytetään vain tarvittaessa. Toinen osapuoli voi tarkkailla IP-osoitettasi. @@ -5957,10 +6510,22 @@ swipe action Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa. No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove Poista - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -5982,13 +6547,21 @@ swipe action Remove member? Poista jäsen? - No comment provided by engineer. + alert title Remove passphrase from keychain? Poista tunnuslause avainnipusta? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. No comment provided by engineer. @@ -6197,6 +6770,10 @@ swipe action SOCKS proxy No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files No comment provided by engineer. @@ -6220,6 +6797,10 @@ chat item action Save (and notify members) alert button + + Save (and notify subscribers) + alert button + Save admission settings? alert title @@ -6234,6 +6815,10 @@ chat item action Tallenna ja ilmoita ryhmän jäsenille No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect No comment provided by engineer. @@ -6243,6 +6828,14 @@ chat item action Tallenna ja päivitä ryhmäprofiili No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile Tallenna ryhmäprofiili @@ -6357,10 +6950,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -6384,6 +6997,10 @@ chat item action Turvakoodi No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select Valitse @@ -6502,6 +7119,10 @@ chat item action Send request without message No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Lähetä ne galleriasta tai mukautetuista näppäimistöistä. @@ -6511,6 +7132,10 @@ chat item action Send up to 100 last messages to new members. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. No comment provided by engineer. @@ -6525,6 +7150,10 @@ chat item action Lähettäjä on saattanut poistaa yhteyspyynnön. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille näkyvissä keskusteluprofiileissa. @@ -6579,11 +7208,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Lähetetty tiedosto tapahtuma - notification - Sent message Lähetetty viesti @@ -6642,6 +7266,10 @@ chat item action Server protocol changed. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. Palvelin vaatii valtuutuksen jonojen luomiseen, tarkista salasana @@ -6759,6 +7387,14 @@ chat item action Settings were changed. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images No comment provided by engineer. @@ -6791,11 +7427,14 @@ chat item action Share address publicly No comment provided by engineer. - - Share address with contacts? - Jaa osoite kontakteille? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. No comment provided by engineer. @@ -6817,6 +7456,10 @@ chat item action Share profile No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link No comment provided by engineer. @@ -6825,9 +7468,12 @@ chat item action Share to SimpleX No comment provided by engineer. - - Share with contacts - Jaa kontaktien kanssa + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -6983,8 +7629,8 @@ chat item action SimpleX protocols reviewed by Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7050,6 +7696,11 @@ report reason Square, circle, or anything in between. No comment provided by engineer. + + Star on GitHub + Tähti GitHubissa + No comment provided by engineer. + Start chat Aloita keskustelu @@ -7142,6 +7793,63 @@ report reason Subscribed No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors No comment provided by engineer. @@ -7214,6 +7922,10 @@ report reason Ota kuva No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat No comment provided by engineer. @@ -7226,8 +7938,8 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. + + Tap Join channel No comment provided by engineer. @@ -7258,6 +7970,10 @@ report reason Napauta liittyäksesi incognito-tilassa No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link No comment provided by engineer. @@ -7273,12 +7989,17 @@ report reason Test failed at step %@. Testi epäonnistui vaiheessa %@. - server test failure + relay test failure +server test failure Test notifications No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server Testipalvelin @@ -7329,6 +8050,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.The app protects your privacy by using different operators in each conversation. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). No comment provided by engineer. @@ -7342,6 +8067,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.The code you scanned is not a SimpleX link QR code. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. No comment provided by engineer. @@ -7366,9 +8095,9 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin! No comment provided by engineer. - - The future of messaging - Seuraavan sukupolven yksityisviestit + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -7403,6 +8132,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7442,6 +8175,14 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Themes No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. No comment provided by engineer. @@ -7501,6 +8242,14 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Tätä ryhmää ei enää ole olemassa. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. No comment provided by engineer. @@ -7544,6 +8293,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.To hide unwanted messages. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection Uuden yhteyden luominen @@ -7622,10 +8375,6 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Voit tarkistaa päästä päähän -salauksen kontaktisi kanssa vertaamalla (tai skannaamalla) laitteidenne koodia. No comment provided by engineer. - - Toggle chat list: - No comment provided by engineer. - Toggle incognito when connecting. No comment provided by engineer. @@ -7638,6 +8387,10 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Toolbar opacity No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total No comment provided by engineer. @@ -7651,15 +8404,9 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -7700,6 +8447,10 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Unblock member? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages No comment provided by engineer. @@ -7795,12 +8546,16 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Unsupported connection link - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Päivitä @@ -7909,11 +8664,6 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use TCP port 443 for preset servers only. No comment provided by engineer. - - Use chat - Käytä chattia - No comment provided by engineer. - Use current profile Käytä nykyistä profiilia @@ -7927,6 +8677,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use for messages No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections Käytä uusiin yhteyksiin @@ -7962,6 +8716,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use private routing with unknown servers. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server Käytä palvelinta @@ -7979,6 +8737,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use the app with one hand. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port No comment provided by engineer. @@ -7996,6 +8758,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Käyttää SimpleX Chat -palvelimia. No comment provided by engineer. + + Verify + relay test step + Verify code with desktop No comment provided by engineer. @@ -8050,6 +8816,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Video vastaanotetaan, kun kontaktisi on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Videot ja tiedostot 1 Gt asti @@ -8101,6 +8871,18 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Ääniviesti… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... No comment provided by engineer. @@ -8137,6 +8919,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Varoitus: saatat menettää joitain tietoja! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICE -palvelimet @@ -8183,6 +8969,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Kun jaat inkognitoprofiilin jonkun kanssa, tätä profiilia käytetään ryhmissä, joihin tämä sinut kutsuu. No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi No comment provided by engineer. @@ -8291,16 +9081,19 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Sinut on kutsuttu ryhmään No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -8364,6 +9157,10 @@ Repeat join request? Voit määrittää lukitusnäytön ilmoituksen esikatselun asetuksista. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Voit jakaa linkin tai QR-koodin - kuka tahansa voi liittyä ryhmään. Et menetä ryhmän jäseniä, jos poistat sen myöhemmin. @@ -8406,16 +9203,21 @@ Repeat join request? Et voi lähettää viestejä! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. Sinua ei voitu todentaa; yritä uudelleen. No comment provided by engineer. - - You decide who can connect. - Kimin bağlanabileceğine siz karar verirsiniz. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -8477,6 +9279,10 @@ Repeat connection request? You should receive notifications. token info + + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. No comment provided by engineer. @@ -8510,6 +9316,10 @@ Repeat connection request? Saat edelleen puheluita ja ilmoituksia mykistetyiltä profiileilta, kun ne ovat aktiivisia. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. No comment provided by engineer. @@ -8553,6 +9363,10 @@ Repeat connection request? Puhelusi No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database Keskustelut-tietokantasi @@ -8599,6 +9413,10 @@ Repeat connection request? Kontaktisi pysyvät yhdistettyinä. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. No comment provided by engineer. @@ -8617,6 +9435,10 @@ Repeat connection request? Your group No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences Asetuksesi @@ -8631,6 +9453,11 @@ Repeat connection request? Your profile No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Profiilisi **%@** jaetaan. @@ -8650,11 +9477,23 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message + + Your public address + No comment provided by engineer. + Your random profile Satunnainen profiilisi No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address Palvelimesi osoite @@ -8669,21 +9508,11 @@ Repeat connection request? Asetuksesi No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Osallistu](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Lähetä meille sähköpostia](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Tähti GitHubissa](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_italic_ @@ -8699,6 +9528,10 @@ Repeat connection request? edellä, valitse sitten: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ rcv group event chat item @@ -8716,6 +9549,10 @@ Repeat connection request? accepted you rcv group event chat item + + active + No comment provided by engineer. + admin ylläpitäjä @@ -8816,6 +9653,10 @@ marked deleted chat item preview text soittaa… call status + + can't broadcast + No comment provided by engineer. + can't send messages No comment provided by engineer. @@ -8850,6 +9691,14 @@ marked deleted chat item preview text muuttamassa osoitetta… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored värillinen @@ -8990,6 +9839,10 @@ pref value poistettu deleted chat item + + deleted channel + rcv group event chat item + deleted contact rcv direct event chat item @@ -9098,10 +9951,18 @@ pref value virhe No comment provided by engineer. + + error: %@ + receive error chat item + expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. @@ -9217,6 +10078,10 @@ pref value poistunut rcv group event chat item + + link + No comment provided by engineer. + marked deleted merkitty poistetuksi @@ -9283,6 +10148,10 @@ pref value ei koskaan delete after time + + new + No comment provided by engineer. + new message uusi viesti @@ -9298,6 +10167,10 @@ pref value ei e2e-salausta No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text ei tekstiä @@ -9392,6 +10265,10 @@ time to disappear hylätty puhelu call status + + relay + member role + removed poistettu @@ -9402,6 +10279,14 @@ time to disappear %@ poistettu rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address profile update event chat item @@ -9533,6 +10418,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile päivitetty ryhmäprofiili @@ -9551,6 +10440,10 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link kontaktiosoitelinkillä @@ -9622,6 +10515,10 @@ last received msg: %2$@ olet tarkkailija No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ snd group event chat item @@ -9680,6 +10577,10 @@ last received msg: %2$@ \~strike~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index d008696a14..be6a766ca1 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -185,6 +185,21 @@ %d mois time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d sec @@ -200,11 +215,53 @@ %d message·s sauté·s integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d semaines time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +272,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld contact·s sélectionné·s @@ -315,11 +376,19 @@ %u messages sautés. No comment provided by engineer. + + (from owner) + chat link info line + (new) (nouveau) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (cet appareil v%@) @@ -365,6 +434,10 @@ **Scanner / Coller** : pour vous connecter via un lien que vous avez reçu. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Avertissement** : les notifications push instantanées nécessitent une phrase secrète enregistrée dans la keychain. @@ -408,6 +481,12 @@ - et bien d'autres choses encore ! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +585,10 @@ time interval Encore quelques points No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact Un nouveau contact @@ -632,9 +715,8 @@ swipe action Connections actives No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d'autres personnes. La mise à jour du profil sera envoyée à vos contacts. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -702,6 +784,10 @@ swipe action Ajout de serveurs de messages No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent Accent additionnel @@ -792,6 +878,10 @@ swipe action Tous les membres du groupe resteront connectés. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs. @@ -817,6 +907,14 @@ swipe action Tous les profiles profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. Tous les rapports seront archivés pour vous. @@ -877,6 +975,10 @@ swipe action Autoriser la suppression irréversible des messages uniquement si votre contact vous l'autorise. (24 heures) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Autoriser les réactions aux messages uniquement si votre contact les autorise. @@ -892,6 +994,10 @@ swipe action Autoriser l'envoi de messages directs aux membres. No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Autorise l’envoi de messages éphémères. @@ -902,6 +1008,10 @@ swipe action Autoriser le partage No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Autoriser la suppression irréversible de messages envoyés. (24 heures) @@ -1007,11 +1117,6 @@ swipe action Répondre à l'appel No comment provided by engineer. - - Anybody can host servers. - N'importe qui peut heberger un serveur. - No comment provided by engineer. - App build: %@ Build de l'app : %@ @@ -1141,6 +1246,10 @@ swipe action Appels audio et vidéo No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Appels audio/vidéo @@ -1211,6 +1320,19 @@ swipe action Mauvais hash de message No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls Appels améliorés @@ -1304,6 +1426,10 @@ swipe action Bloquer ce membre ? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin Bloqué par l'administrateur @@ -1352,6 +1478,14 @@ swipe action Vous et votre contact êtes tous deux en mesure d'envoyer des messages vocaux. No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bulgare, finnois, thaïlandais et ukrainien - grâce aux utilisateurs et à [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) ! @@ -1360,7 +1494,7 @@ swipe action Business address Adresse professionnelle - No comment provided by engineer. + chat link info line Business chats @@ -1381,15 +1515,6 @@ swipe action Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - En utilisant SimpleX Chat, vous acceptez de : -- n'envoyer que du contenu légal dans les groupes publics. -- respecter les autres utilisateurs - pas de spam. - No comment provided by engineer. - Call already ended! Appel déjà terminé ! @@ -1537,6 +1662,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat Discussions @@ -1622,6 +1808,22 @@ set passcode view Profil d'utilisateur No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme Thème de chat @@ -1639,7 +1841,8 @@ set passcode view Chat with admins - chat toolbar + chat feature +chat toolbar Chat with member @@ -1654,10 +1857,22 @@ set passcode view Discussions No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. Consulter les messages toutes les 20 minutes. @@ -1668,6 +1883,14 @@ set passcode view Consulter les messages quand c'est possible. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. Vérifiez l'adresse du serveur et réessayez. @@ -1791,7 +2014,7 @@ set passcode view Conditions of use Conditions d'utilisation - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1813,9 +2036,8 @@ set passcode view Configurer les serveurs ICE No comment provided by engineer. - - Configure server operators - Configurer les opérateurs de serveur + + Configure relays No comment provided by engineer. @@ -1876,7 +2098,8 @@ set passcode view Connect Se connecter - server test step + relay test step +server test step Connect automatically @@ -1921,6 +2144,10 @@ Il s'agit de votre propre lien unique ! Se connecter via un lien new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Se connecter via un lien unique @@ -1999,6 +2226,10 @@ Il s'agit de votre propre lien unique ! Connection error (AUTH) Erreur de connexion (AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -2053,6 +2284,10 @@ Il s'agit de votre propre lien unique ! Connexions No comment provided by engineer. + + Contact address + chat link info line + Contact allows Votre contact autorise @@ -2122,6 +2357,11 @@ Il s'agit de votre propre lien unique ! Continuer No comment provided by engineer. + + Contribute + Contribuer + No comment provided by engineer. + Conversation deleted! Conversation supprimée ! @@ -2150,12 +2390,7 @@ Il s'agit de votre propre lien unique ! Correct name to %@? Corriger le nom pour %@ ? - No comment provided by engineer. - - - Create - Créer - No comment provided by engineer. + alert message Create 1-time link @@ -2207,6 +2442,14 @@ Il s'agit de votre propre lien unique ! Créer le profil No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue Créer une file d'attente @@ -2216,11 +2459,19 @@ Il s'agit de votre propre lien unique ! Create your address No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile Créez votre profil No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created Créées @@ -2241,6 +2492,10 @@ Il s'agit de votre propre lien unique ! Création d'un lien d'archive No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… Création d'un lien… @@ -2399,10 +2654,9 @@ Il s'agit de votre propre lien unique ! Livraison de débogage No comment provided by engineer. - - Decentralized - Décentralisé - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2450,6 +2704,14 @@ swipe action Supprimer et en informer le contact No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat Supprimer la discussion @@ -2564,6 +2826,14 @@ swipe action Supprimer le message de ce membre ? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Supprimer le message ? @@ -2572,7 +2842,8 @@ swipe action Delete messages Supprimer les messages - alert button + alert action +alert button Delete messages after @@ -2609,6 +2880,10 @@ swipe action Supprimer la file d'attente server test step + + Delete relay + No comment provided by engineer. + Delete report Supprimer le rapport @@ -2772,6 +3047,14 @@ swipe action Les messages directs entre membres sont interdits dans ce groupe. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) Désactiver (conserver les remplacements) @@ -2877,6 +3160,10 @@ swipe action Ne pas envoyer d'historique aux nouveaux membres. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. Ne pas utiliser d'identifiants avec le proxy. @@ -2978,11 +3265,19 @@ chat item action Notifications chiffrées E2E. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit Modifier chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile Modifier le profil du groupe @@ -2995,7 +3290,7 @@ chat item action Enable Activer - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3017,6 +3312,10 @@ chat item action Activer le TCP keep-alive No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Activer la suppression automatique des messages ? @@ -3027,6 +3326,10 @@ chat item action Autoriser l'accès à la caméra No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. No comment provided by engineer. @@ -3046,16 +3349,15 @@ chat item action Activer les notifications instantanées ? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock Activer le verrouillage No comment provided by engineer. - - Enable notifications - Activer les notifications - No comment provided by engineer. - Enable periodic notifications? Activer les notifications périodiques ? @@ -3161,6 +3463,10 @@ chat item action Entrer le code d'accès No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Entrez la phrase secrète correcte. @@ -3186,6 +3492,14 @@ chat item action Entrez ci-dessus le mot de passe pour afficher le profil ! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually Entrer un serveur manuellement @@ -3214,7 +3528,7 @@ chat item action Error Erreur - No comment provided by engineer. + conn error description Error aborting address change @@ -3240,6 +3554,10 @@ chat item action Erreur lors de l'ajout de membre·s No comment provided by engineer. + + Error adding relay + alert title + Error adding server Erreur lors de l'ajout du serveur @@ -3288,11 +3606,19 @@ chat item action Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Erreur lors de la création de l'adresse No comment provided by engineer. + + Error creating channel + alert title + Error creating group Erreur lors de la création du groupe @@ -3427,10 +3753,6 @@ chat item action Erreur lors de l'ouverture du chat No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file Erreur lors de la réception du fichier @@ -3475,6 +3797,10 @@ chat item action Erreur lors de la sauvegarde des serveurs ICE No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list Erreur lors de l'enregistrement de la liste des chats @@ -3539,6 +3865,10 @@ chat item action Erreur lors de la configuration des accusés de réception ! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat Erreur lors du démarrage du chat @@ -3618,7 +3948,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3818,6 +4149,10 @@ snd error text Fichiers et médias interdits ! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtrer les messages non lus et favoris. @@ -3854,7 +4189,8 @@ snd error text Fingerprint in server address does not match certificate. Il est possible que l'empreinte du certificat dans l'adresse du serveur soit incorrecte - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3894,10 +4230,15 @@ snd error text For all moderators No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: Pour le profil de discussion %@ : - servers error + servers error +servers warning For console @@ -4037,10 +4378,18 @@ Erreur : %2$@ GIFs et stickers No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! Bonjour ! @@ -4099,7 +4448,7 @@ Erreur : %2$@ Group link Lien du groupe - No comment provided by engineer. + chat link info line Group links @@ -4208,6 +4557,10 @@ Erreur : %2$@ L'historique n'est pas envoyé aux nouveaux membres. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works Comment SimpleX fonctionne @@ -4272,6 +4625,10 @@ Erreur : %2$@ Si vous entrez votre code d'autodestruction à l'ouverture de l'application : No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Si vous avez besoin d'utiliser le chat maintenant appuyez sur **le faire plus tard** (vous pourrez migrer la base de données quand vous relancerez l'app). @@ -4292,16 +4649,15 @@ Erreur : %2$@ L'image sera reçue quand votre contact sera en ligne, merci d'attendre ou de revenir plus tard ! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Immédiatement No comment provided by engineer. - - Immune to spam - Protégé du spam et des abus - No comment provided by engineer. - Import Importer @@ -4442,9 +4798,9 @@ D'autres améliorations sont à venir ! Rôle initial No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Installer [SimpleX Chat pour terminal](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Installer SimpleX Chat pour terminal No comment provided by engineer. @@ -4497,7 +4853,7 @@ D'autres améliorations sont à venir ! Invalid connection link Lien de connection invalide - No comment provided by engineer. + conn error description Invalid display name! @@ -4517,7 +4873,15 @@ D'autres améliorations sont à venir ! Invalid name! Nom invalide ! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4544,11 +4908,19 @@ D'autres améliorations sont à venir ! Inviter des amis No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Inviter des membres No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat Inviter à discuter @@ -4625,6 +4997,10 @@ D'autres améliorations sont à venir ! rejoindre entant que %@ No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group Rejoindre le groupe @@ -4711,6 +5087,14 @@ Voici votre lien pour le groupe %@ ! Quitter swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat Quitter la discussion @@ -4735,6 +5119,10 @@ Voici votre lien pour le groupe %@ ! Less traffic on mobile networks. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat Discutons sur SimpleX Chat @@ -4755,6 +5143,10 @@ Voici votre lien pour le groupe %@ ! Liez vos applications mobiles et de bureau ! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options Options de bureau lié @@ -4765,6 +5157,10 @@ Voici votre lien pour le groupe %@ ! Bureaux liés No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4883,6 +5279,10 @@ Voici votre lien pour le groupe %@ ! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4905,12 +5305,12 @@ Voici votre lien pour le groupe %@ ! Member will be removed from chat - this cannot be undone! Le membre sera retiré de la discussion - cela ne peut pas être annulé ! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Ce membre sera retiré du groupe - impossible de revenir en arrière ! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4921,6 +5321,10 @@ Voici votre lien pour le groupe %@ ! Les membres du groupe peuvent ajouter des réactions aux messages. No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés. (24 heures) @@ -4984,6 +5388,10 @@ Voici votre lien pour le groupe %@ ! Brouillon de message No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded Message transféré @@ -5077,6 +5485,14 @@ Voici votre lien pour le groupe %@ ! Les messages de %@ seront affichés ! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. alert message @@ -5106,16 +5522,15 @@ Voici votre lien pour le groupe %@ ! Les messages, fichiers et appels sont protégés par un chiffrement **e2e résistant post-quantique** avec une confidentialité persistante, une répudiation et une récupération en cas d'effraction. No comment provided by engineer. + + Migrate + No comment provided by engineer. + Migrate device Transférer l'appareil No comment provided by engineer. - - Migrate from another device - Transférer depuis un autre appareil - No comment provided by engineer. - Migrate here Transférer ici @@ -5234,6 +5649,10 @@ Voici votre lien pour le groupe %@ ! Réseau et serveurs No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection Connexion au réseau @@ -5244,6 +5663,10 @@ Voici votre lien pour le groupe %@ ! Décentralisation du réseau No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. Problèmes de réseau - le message a expiré après plusieurs tentatives d'envoi. @@ -5259,6 +5682,11 @@ Voici votre lien pour le groupe %@ ! Opérateur de réseau No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings Paramètres réseau @@ -5267,12 +5695,16 @@ Voici votre lien pour le groupe %@ ! Network status État du réseau - No comment provided by engineer. + alert title New token status text + + New 1-time link + No comment provided by engineer. + New Passcode Nouveau code d'accès @@ -5298,6 +5730,10 @@ Voici votre lien pour le groupe %@ ! Nouvelle expérience de discussion 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request Nouvelle demande de contact @@ -5366,11 +5802,28 @@ Voici votre lien pour le groupe %@ ! Non No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password Pas de mot de passe pour l'app Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats No comment provided by engineer. @@ -5508,11 +5961,22 @@ Voici votre lien pour le groupe %@ ! No unread chats No comment provided by engineer. - - No user identifiers. - Aucun identifiant d'utilisateur. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! Non compatible ! @@ -5567,7 +6031,7 @@ Voici votre lien pour le groupe %@ ! OK OK - No comment provided by engineer. + alert button Off @@ -5586,11 +6050,19 @@ new chat action Ancienne base de données No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link Lien d'invitation unique No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5610,6 +6082,10 @@ Nécessite l'activation d'un VPN. Les hôtes .onion ne seront pas utilisés. No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. Seuls les propriétaires peuvent modifier les préférences. @@ -5709,7 +6185,8 @@ Nécessite l'activation d'un VPN. Open Ouvrir - alert action + alert action +alert button Open Settings @@ -5721,6 +6198,10 @@ Nécessite l'activation d'un VPN. Ouvrir les modifications No comment provided by engineer. + + Open channel + new chat action + Open chat Ouvrir le chat @@ -5740,6 +6221,10 @@ Nécessite l'activation d'un VPN. Ouvrir les conditions No comment provided by engineer. + + Open external link? + alert title + Open full link alert action @@ -5758,6 +6243,10 @@ Nécessite l'activation d'un VPN. Ouvrir le transfert vers un autre appareil authentication reason + + Open new channel + new chat action + Open new chat new chat action @@ -5797,6 +6286,13 @@ Nécessite l'activation d'un VPN. Serveur de l'opérateur alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file Ou importer un fichier d'archive @@ -5817,6 +6313,10 @@ Nécessite l'activation d'un VPN. Ou partagez en toute sécurité le lien de ce fichier No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code Ou montrez ce code @@ -5827,6 +6327,10 @@ Nécessite l'activation d'un VPN. Ou à partager en privé No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists No comment provided by engineer. @@ -5843,6 +6347,18 @@ Nécessite l'activation d'un VPN. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count Nombre de PING @@ -5898,6 +6414,10 @@ Nécessite l'activation d'un VPN. Coller l'image No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! Collez le lien pour vous connecter ! @@ -6048,6 +6568,14 @@ Erreur : %@ Conserver le brouillon du dernier message, avec les pièces jointes. No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address Adresse du serveur prédéfinie @@ -6082,13 +6610,12 @@ Erreur : %@ Privacy policy and conditions of use. No comment provided by engineer. - - Privacy redefined - La vie privée redéfinie + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. + + Private and secure messaging. No comment provided by engineer. @@ -6129,6 +6656,10 @@ Erreur : %@ Private routing timeout alert title + + Proceed + alert action + Profile and server connections Profil et connexions au serveur @@ -6154,9 +6685,8 @@ Erreur : %@ Thème de profil No comment provided by engineer. - - Profile update will be sent to your contacts. - La mise à jour du profil sera envoyée à vos contacts. + + Profile update will be sent to your SimpleX contacts. alert message @@ -6164,6 +6694,10 @@ Erreur : %@ Interdire les appels audio/vidéo. No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Interdire la suppression irréversible des messages. @@ -6193,6 +6727,10 @@ Erreur : %@ Interdire l'envoi de messages directs aux membres. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Interdire l’envoi de messages éphémères. @@ -6259,6 +6797,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Le proxy est protégé par un mot de passe No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Notifications push @@ -6299,24 +6841,14 @@ Activez-le dans les paramètres *Réseau et serveurs*. En savoir plus No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Pour en savoir plus, consultez le [Guide de l'utilisateur](https ://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Pour en savoir plus, consultez le Guide de l'utilisateur. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Pour en savoir plus, consultez le [Guide de l'utilisateur](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Pour en savoir plus, consultez le [Guide de l'utilisateur](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Pour en savoir plus, consultez notre [dépôt GitHub](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Pour en savoir plus, consultez notre dépôt GitHub. No comment provided by engineer. @@ -6339,11 +6871,6 @@ Activez-le dans les paramètres *Réseau et serveurs*. Reçu le : %@ copied message info - - Received file event - Événement de fichier reçu - notification - Received message Message reçu @@ -6477,6 +7004,26 @@ swipe action Reject member? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Le serveur relais n'est utilisé que si nécessaire. Un tiers peut observer votre adresse IP. @@ -6487,10 +7034,22 @@ swipe action Le serveur relais protège votre adresse IP, mais il peut observer la durée de l'appel. No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove Supprimer - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6514,13 +7073,21 @@ swipe action Remove member? Retirer ce membre ? - No comment provided by engineer. + alert title Remove passphrase from keychain? Supprimer la phrase secrète de la keychain ? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. No comment provided by engineer. @@ -6741,6 +7308,10 @@ swipe action proxy SOCKS No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files Réception de fichiers en toute sécurité @@ -6766,6 +7337,10 @@ chat item action Save (and notify members) alert button + + Save (and notify subscribers) + alert button + Save admission settings? alert title @@ -6780,6 +7355,10 @@ chat item action Enregistrer et en informer les membres du groupe No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect Sauvegarder et se reconnecter @@ -6790,6 +7369,14 @@ chat item action Enregistrer et mettre à jour le profil du groupe No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile Enregistrer le profil du groupe @@ -6913,11 +7500,31 @@ chat item action La barre de recherche accepte les liens d'invitation. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Rechercher ou coller un lien SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Secondaire @@ -6943,6 +7550,10 @@ chat item action Code de sécurité No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select Choisir @@ -7069,6 +7680,10 @@ chat item action Send request without message No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Envoyez-les depuis la phototèque ou des claviers personnalisés. @@ -7079,6 +7694,10 @@ chat item action Envoi des 100 derniers messages aux nouveaux membres. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. No comment provided by engineer. @@ -7093,6 +7712,10 @@ chat item action L'expéditeur a peut-être supprimé la demande de connexion. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. L'envoi d'accusés de réception sera activé pour tous les contacts dans tous les profils de chat visibles. @@ -7148,11 +7771,6 @@ chat item action Envoyé directement No comment provided by engineer. - - Sent file event - Événement de fichier envoyé - notification - Sent message Message envoyé @@ -7223,6 +7841,10 @@ chat item action Le protocole du serveur a été modifié. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. Le serveur requiert une autorisation pour créer des files d'attente, vérifiez le mot de passe @@ -7349,6 +7971,14 @@ chat item action Les paramètres ont été modifiés. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images Images de profil modelable @@ -7385,11 +8015,14 @@ chat item action Partager publiquement votre adresse No comment provided by engineer. - - Share address with contacts? - Partager l'adresse avec vos contacts ? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. Partager depuis d'autres applications. @@ -7413,6 +8046,10 @@ chat item action Partager le profil No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link Partagez ce lien d'invitation unique @@ -7423,9 +8060,12 @@ chat item action Partager sur SimpleX No comment provided by engineer. - - Share with contacts - Partager avec vos contacts + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -7593,8 +8233,8 @@ chat item action Protocoles SimpleX audité par Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7669,6 +8309,11 @@ report reason Carré, circulaire, ou toute autre forme intermédiaire. No comment provided by engineer. + + Star on GitHub + Star sur GitHub + No comment provided by engineer. + Start chat Démarrer le chat @@ -7768,6 +8413,63 @@ report reason Inscriptions No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors Erreurs d'inscription @@ -7846,6 +8548,10 @@ report reason Prendre une photo No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat No comment provided by engineer. @@ -7858,9 +8564,8 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. + + Tap Join channel No comment provided by engineer. @@ -7892,6 +8597,10 @@ report reason Appuyez pour rejoindre incognito No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link Appuyez pour coller le lien @@ -7910,12 +8619,17 @@ report reason Test failed at step %@. Échec du test à l'étape %@. - server test failure + relay test failure +server test failure Test notifications No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server Tester le serveur @@ -7967,6 +8681,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. L'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). L'application demandera de confirmer les téléchargements à partir de serveurs de fichiers inconnus (sauf .onion). @@ -7982,6 +8700,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Le code scanné n'est pas un code QR de lien SimpleX. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. @@ -8007,9 +8729,9 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Le chiffrement fonctionne et le nouvel accord de chiffrement n'est pas nécessaire. Cela peut provoquer des erreurs de connexion ! No comment provided by engineer. - - The future of messaging - La nouvelle génération de messagerie privée + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -8046,6 +8768,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. L'ancienne base de données n'a pas été supprimée lors de la migration, elle peut être supprimée. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Les mêmes conditions s'appliquent à l'opérateur **%@**. @@ -8091,6 +8817,14 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Thèmes No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. Ces conditions s'appliquent également aux : **%@**. @@ -8155,6 +8889,14 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Ce groupe n'existe plus. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. No comment provided by engineer. @@ -8201,6 +8943,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Pour cacher les messages indésirables. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection Pour établir une nouvelle connexion @@ -8286,11 +9032,6 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils. No comment provided by engineer. - - Toggle chat list: - Afficher la liste des conversations : - No comment provided by engineer. - Toggle incognito when connecting. Basculer en mode incognito lors de la connexion. @@ -8305,6 +9046,10 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Opacité de la barre d'outils No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total Total @@ -8320,15 +9065,9 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Sessions de transport No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8375,6 +9114,10 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Débloquer ce membre ? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages Messages non distribués @@ -8474,13 +9217,17 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Unsupported connection link - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Les 100 derniers messages sont envoyés aux nouveaux membres. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Mise à jour @@ -8597,11 +9344,6 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use TCP port 443 for preset servers only. No comment provided by engineer. - - Use chat - Utiliser le chat - No comment provided by engineer. - Use current profile Utiliser le profil actuel @@ -8617,6 +9359,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser pour les messages No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections Utiliser pour les nouvelles connexions @@ -8656,6 +9402,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser le routage privé avec des serveurs inconnus. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server Utiliser ce serveur @@ -8676,6 +9426,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser l'application d'une main. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port No comment provided by engineer. @@ -8695,6 +9449,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Vous utilisez les serveurs SimpleX. No comment provided by engineer. + + Verify + relay test step + Verify code with desktop Vérifier le code avec le bureau @@ -8755,6 +9513,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien La vidéo ne sera reçue que lorsque votre contact sera en ligne. Veuillez patienter ou vérifier plus tard ! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Vidéos et fichiers jusqu'à 1Go @@ -8810,6 +9572,18 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Message vocal… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... En attente du bureau... @@ -8850,6 +9624,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Attention : vous risquez de perdre des données ! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers Serveurs WebRTC ICE @@ -8899,6 +9677,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Lorsque vous partagez un profil incognito avec quelqu'un, ce profil sera utilisé pour les groupes auxquels il vous invite. No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi WiFi @@ -9026,16 +9808,19 @@ Repeat join request? Répéter la demande d'adhésion ? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Vous êtes invité·e au groupe No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Vous n'êtes pas connecté à ces serveurs. Le routage privé est utilisé pour leur délivrer des messages. @@ -9106,6 +9891,10 @@ Répéter la demande d'adhésion ? Vous pouvez configurer l'aperçu des notifications sur l'écran de verrouillage via les paramètres. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Vous pouvez partager un lien ou un code QR - n'importe qui pourra rejoindre le groupe. Vous ne perdrez pas les membres du groupe si vous le supprimez par la suite. @@ -9150,16 +9939,21 @@ Répéter la demande d'adhésion ? Vous ne pouvez pas envoyer de messages ! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. Vous n'avez pas pu être vérifié·e ; veuillez réessayer. No comment provided by engineer. - - You decide who can connect. - Vous choisissez qui peut se connecter. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9226,6 +10020,10 @@ Répéter la demande de connexion ? You should receive notifications. token info + + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. No comment provided by engineer. @@ -9260,6 +10058,10 @@ Répéter la demande de connexion ? Vous continuerez à recevoir des appels et des notifications des profils mis en sourdine lorsqu'ils sont actifs. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé. @@ -9304,6 +10106,10 @@ Répéter la demande de connexion ? Vos appels No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database Votre base de données de chat @@ -9352,6 +10158,10 @@ Répéter la demande de connexion ? Vos contacts resteront connectés. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. Vos informations d'identification peuvent être envoyées non chiffrées. @@ -9371,6 +10181,10 @@ Répéter la demande de connexion ? Your group No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences Vos préférences @@ -9386,6 +10200,11 @@ Répéter la demande de connexion ? Votre profil No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Votre profil **%@** sera partagé. @@ -9406,11 +10225,23 @@ Répéter la demande de connexion ? Votre profil a été modifié. Si vous l'enregistrez, le profil mis à jour sera envoyé à tous vos contacts. alert message + + Your public address + No comment provided by engineer. + Your random profile Votre profil aléatoire No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address Votre adresse de serveur @@ -9426,21 +10257,11 @@ Répéter la demande de connexion ? Vos paramètres No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Contribuer](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Contact par mail](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Star sur GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_italique_ @@ -9456,6 +10277,10 @@ Répéter la demande de connexion ? ci-dessus, puis choisissez : No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ rcv group event chat item @@ -9474,6 +10299,10 @@ Répéter la demande de connexion ? accepted you rcv group event chat item + + active + No comment provided by engineer. + admin admin @@ -9583,6 +10412,10 @@ marked deleted chat item preview text appel… call status + + can't broadcast + No comment provided by engineer. + can't send messages No comment provided by engineer. @@ -9617,6 +10450,14 @@ marked deleted chat item preview text changement d'adresse… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored coloré @@ -9759,6 +10600,10 @@ pref value supprimé deleted chat item + + deleted channel + rcv group event chat item + deleted contact contact supprimé @@ -9869,11 +10714,19 @@ pref value erreur No comment provided by engineer. + + error: %@ + receive error chat item + expired expiré No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded transféré @@ -9992,6 +10845,10 @@ pref value a quitté rcv group event chat item + + link + No comment provided by engineer. + marked deleted supprimé @@ -10060,6 +10917,10 @@ pref value jamais delete after time + + new + No comment provided by engineer. + new message nouveau message @@ -10075,6 +10936,10 @@ pref value sans chiffrement de bout en bout No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text aucun texte @@ -10173,6 +11038,10 @@ time to disappear appel rejeté call status + + relay + member role + removed supprimé @@ -10183,6 +11052,14 @@ time to disappear a retiré %@ rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address suppression de l'adresse de contact @@ -10330,6 +11207,10 @@ dernier message reçu : %2$@ non protégé No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile mise à jour du profil de groupe @@ -10350,6 +11231,10 @@ dernier message reçu : %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link via le lien d'adresse du contact @@ -10424,6 +11309,10 @@ dernier message reçu : %2$@ vous êtes observateur No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ vous avez bloqué %@ @@ -10484,6 +11373,10 @@ dernier message reçu : %2$@ \~barré~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index b542c6eabf..7723cabdcb 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -92,12 +92,12 @@ %@ is not verified - %@ nincs hitelesítve + %@ nincs ellenőrizve No comment provided by engineer. %@ is verified - %@ hitelesítve + %@ ellenőrizve No comment provided by engineer. @@ -185,6 +185,24 @@ %d hónap time interval + + %d relays failed + %d átjátszóhoz nem sikerült kapcsolódni + channel relay bar +channel subscriber relay bar + + + %d relays not active + %d átjátszó inaktív + channel relay bar +channel subscriber relay bar + + + %d relays removed + %d átjátszó eltávolítva + channel relay bar +channel subscriber relay bar + %d sec %d mp @@ -200,11 +218,63 @@ %d üzenet kihagyva integrity error chat item + + %d subscriber + %d feliratkozó + channel subscriber count + + + %d subscribers + %d feliratkozó + channel subscriber count + %d weeks %d hét time interval + + %1$d/%2$d relays active + %1$d/%2$d átjátszó aktív + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + %1$d/%2$d átjátszó aktív, %3$d hiba + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d átjátszó aktív, %3$d sikertelen + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + %1$d/%2$d átjátszó aktív, %3$d eltávolítva + channel relay bar + + + %1$d/%2$d relays connected + %1$d/%2$d átjátszó kapcsolódva + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d átjátszó kapcsolódva, %3$d hiba + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d átjátszó kapcsolódott, %3$d átjátszóhoz nem sikerült kapcsolódni + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d átjátszó kapcsolódott, %3$d eltávolítva + channel subscriber relay bar + %lld %lld @@ -215,9 +285,14 @@ %lld %@ No comment provided by engineer. + + %lld channel events + %lld csatornaesemény + No comment provided by engineer. + %lld contact(s) selected - %lld partner kijelölve + %lld partner kiválasztva No comment provided by engineer. @@ -272,7 +347,7 @@ %lldd - %lldn + %lldnap No comment provided by engineer. @@ -292,7 +367,7 @@ %lldmth - %lldh + %lldhónap No comment provided by engineer. @@ -315,11 +390,21 @@ %u üzenet kihagyva. No comment provided by engineer. + + (from owner) + (a tulajdonostól) + chat link info line + (new) (új) No comment provided by engineer. + + (signed) + (aláírva) + chat link info line + (this device v%@) (ez az eszköz: v%@) @@ -342,7 +427,7 @@ **Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app. - **Legprivátabb:** ne használja a SimpleX Chat értesítési kiszolgálót, rendszeresen ellenőrizze az üzeneteket a háttérben (attól függően, hogy milyen gyakran használja az alkalmazást). + **A legprivátabb**: Az alkalmazás nem használja a SimpleX Chat push-kiszolgálóját. Az alkalmazás a háttérben ellenőrzi az üzeneteket, amikor a rendszer ezt lehetővé teszi, attól függően, hogy Ön milyen gyakran használja az alkalmazást. No comment provided by engineer. @@ -352,7 +437,7 @@ **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Megjegyzés:** NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti. + **Megjegyzés:** NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti. No comment provided by engineer. @@ -365,9 +450,14 @@ **Hivatkozás beolvasása / beillesztése**: egy kapott hivatkozáson keresztüli kapcsolódáshoz. No comment provided by engineer. + + **Test relay** to retrieve its name. + **Átjátszó tesztelése** a nevének lekéréséhez. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. - **Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges. + **Figyelmeztetés:** Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges. No comment provided by engineer. @@ -377,12 +467,12 @@ **e2e encrypted** audio call - **e2e titkosított** hanghívás + **végpontok között titkosított** hanghívás No comment provided by engineer. **e2e encrypted** video call - **e2e titkosított** videóhívás + **végpontok között titkosított** videóhívás No comment provided by engineer. @@ -394,8 +484,8 @@ - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. - - kapcsolódás a [könyvtár szolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! -- kézbesítési jelentések (legfeljebb 20 tag). + - kapcsolódás a [könyvtárszolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! +- kézbesítési jelentések (legfeljebb 20 tagig). - gyorsabb és stabilabb. No comment provided by engineer. @@ -408,6 +498,15 @@ - és még sok más! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + - Hivatkozások előnézetének küldése. +- Hiperhivatkozásokon keresztüli adathalászat megakadályozása. +- Hivatkozások nyomonkövetési paramétereinek eltávolítása. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -476,7 +575,7 @@ time interval 1-time link can be used *with one contact only* - share in person or via any messenger. - Az egyszer használható meghívó egy hivatkozás és *csak egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható. + Az egyszer használható meghívó egy hivatkozás és *csak egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltó alkalmazáson keresztül megosztható. No comment provided by engineer. @@ -506,6 +605,11 @@ time interval Néhány további dolog No comment provided by engineer. + + A link for one person to connect + Egy hivatkozás, ami egyetlen partnerrel való kapcsolat létrehozására szolgál + No comment provided by engineer. + A new contact Egy új partner @@ -632,9 +736,9 @@ swipe action Aktív kapcsolatok száma No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. + Cím hozzáadása a profilhoz, hogy a SimpleX partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve a SimpleX partnerei számára. No comment provided by engineer. @@ -702,6 +806,11 @@ swipe action Hozzáadott üzenetkiszolgálók No comment provided by engineer. + + Adding relays will be supported later. + Az átjátszók hozzáadása később lesz támogatott. + No comment provided by engineer. + Additional accent További kiemelőszín @@ -789,7 +898,12 @@ swipe action All group members will remain connected. - Az összes csoporttag kapcsolatban marad. + Az összes csoporttag továbbra is kapcsolatban marad. + No comment provided by engineer. + + + All messages + Összes üzenet No comment provided by engineer. @@ -817,6 +931,16 @@ swipe action Összes profil profile dropdown + + All relays failed + Nem sikerült kapcsolódni egyetlen átjátszóhoz sem + No comment provided by engineer. + + + All relays removed + Az összes átjátszó el lett távolítva + No comment provided by engineer. + All reports will be archived for you. Az összes jelentés archiválva lesz az Ön számára. @@ -829,17 +953,17 @@ swipe action All your contacts will remain connected. - Az összes partnerével kapcsolatban marad. + Az összes partnerével továbbra is kapcsolatban marad. No comment provided by engineer. All your contacts will remain connected. Profile update will be sent to your contacts. - A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. + Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. No comment provided by engineer. All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra. + Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-átjátszókra. No comment provided by engineer. @@ -877,6 +1001,11 @@ swipe action Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) No comment provided by engineer. + + Allow members to chat with admins. + A csevegés az adminisztrátorokkal engedélyezve van a tagok számára. + No comment provided by engineer. + Allow message reactions only if your contact allows them. A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. @@ -892,6 +1021,11 @@ swipe action A közvetlen üzenetek küldése a tagok között engedélyezve van. No comment provided by engineer. + + Allow sending direct messages to subscribers. + A közvetlen üzenetek küldése a feliratkozók között engedélyezve van. + No comment provided by engineer. + Allow sending disappearing messages. Az eltűnő üzenetek küldése engedélyezve van. @@ -902,6 +1036,11 @@ swipe action Megosztás engedélyezése No comment provided by engineer. + + Allow subscribers to chat with admins. + A csevegés az adminisztrátorokkal engedélyezve van a feliratkozók számára. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra) @@ -954,7 +1093,7 @@ swipe action Allow your contacts to send disappearing messages. - Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. + Az eltűnő üzenetek küldése engedélyezve van a partnerei számára. No comment provided by engineer. @@ -984,12 +1123,12 @@ swipe action Always use private routing. - Mindig használjon privát útválasztást. + Mindig legyen használva privát útválasztás. No comment provided by engineer. Always use relay - Mindig használjon továbbítókiszolgálót + Mindig legyen használva átjátszó No comment provided by engineer. @@ -1007,11 +1146,6 @@ swipe action Hívás fogadása No comment provided by engineer. - - Anybody can host servers. - Bárki üzemeltethet kiszolgálókat. - No comment provided by engineer. - App build: %@ Alkalmazás összeállítási száma: %@ @@ -1142,6 +1276,11 @@ swipe action Hang- és videóhívások No comment provided by engineer. + + Audio call + Hanghívás + No comment provided by engineer. + Audio/video calls Hang- és videóhívások @@ -1199,17 +1338,34 @@ swipe action Bad desktop address - Érvénytelen számítógépcím + Hibás a számítógép címe No comment provided by engineer. Bad message ID - Téves üzenet ID + Hibás az üzenet azonosítója No comment provided by engineer. Bad message hash - Érvénytelen az üzenet kivonata + Hibás az üzenet kivonata + No comment provided by engineer. + + + Be free +in your network + Váljon szabaddá +a saját hálózatában + No comment provided by engineer. + + + Be free in your network. + Legyen szabad a saját hálózatában. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Mert felszámoltuk a lehetőségét is annak, hogy megtudjuk, Ön kicsoda. Így az önrendelkezése soha nem kerülhet idegen kezekbe. No comment provided by engineer. @@ -1239,7 +1395,7 @@ swipe action Better networking - Jobb hálózatkezelés + Továbbfejlesztett hálózatkezelés No comment provided by engineer. @@ -1264,12 +1420,12 @@ swipe action Bio - Névjegy + Életrajz No comment provided by engineer. Bio too large - A névjegy túl hosszú + Az életrajz túl hosszú alert title @@ -1307,6 +1463,11 @@ swipe action Letiltja a tagot? No comment provided by engineer. + + Block subscriber for all? + Az összes feliratkozó számára letiltja a feliratkozót? + No comment provided by engineer. + Blocked by admin Letiltva az adminisztrátor által @@ -1357,6 +1518,16 @@ swipe action Mindkét fél küldhet hangüzeneteket. No comment provided by engineer. + + Bottom bar + Alsó sáv + No comment provided by engineer. + + + Broadcast + Közvetítés… + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bolgár, finn, thai és ukrán – köszönet a felhasználóknak és a [Weblate-nek](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1365,7 +1536,7 @@ swipe action Business address Üzleti cím - No comment provided by engineer. + chat link info line Business chats @@ -1387,18 +1558,9 @@ swipe action A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - A SimpleX Chat használatával Ön elfogadja, hogy: -- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban. -- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek. - No comment provided by engineer. - Call already ended! - A hívás már befejeződött! + A hívás már véget ért! No comment provided by engineer. @@ -1544,6 +1706,82 @@ new chat action authentication reason set passcode view + + Channel + Csatorna + No comment provided by engineer. + + + Channel display name + Csatorna megjelenítendő neve + No comment provided by engineer. + + + Channel full name (optional) + Csatorna teljes neve (nem kötelező) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + A csatornának nincsenek aktív átjátszói. Próbáljon meg később csatlakozni. + alert message +alert subtitle + + + Channel image + Csatornakép + No comment provided by engineer. + + + Channel link + Csatornahivatkozás + chat link info line + + + Channel preferences + Csatornabeállítások + No comment provided by engineer. + + + Channel profile + Csatornaprofil + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + A csatornaprofil a feliratkozók eszközén és a csevegési átjátszókon van tárolva. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + A csatornaprofil módosult. Ha menti, akkor a frissített profil el lesz küldve a csatorna feliratkozóinak. + alert message + + + Channel temporarily unavailable + A csatorna ideiglenesen nem érhető el + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + A csatorna az összes feliratkozó számára törölve lesz – ez a művelet nem vonható vissza! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + A csatorna törölve lesz az Ön számára – ez a művelet nem vonható vissza! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + A csatorna %2$d átjátszóból %1$d használatával kezd el működni. Folytatja? + alert message + + + Channels + Csatornák + No comment provided by engineer. + Chat Csevegés @@ -1629,6 +1867,26 @@ set passcode view Csevegési profil No comment provided by engineer. + + Chat relay + Csevegési átjátszó + No comment provided by engineer. + + + Chat relays + Csevegési átjátszók + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + A csevegési átjátszók továbbítják az üzeneteket az Ön által létrehozott csatornákban. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + A csevegési átjátszók továbbítják az üzeneteket a csatorna feliratkozóinak. + No comment provided by engineer. + Chat theme Csevegés témája @@ -1647,7 +1905,8 @@ set passcode view Chat with admins Csevegés az adminisztrátorokkal - chat toolbar + chat feature +chat toolbar Chat with member @@ -1664,11 +1923,26 @@ set passcode view Csevegések No comment provided by engineer. + + Chats with admins are prohibited. + A csevegés az adminisztrátorokkal le van tiltva. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + A nyilvános csatornákban az adminisztrátorokkal való csevegések nem rendelkeznek végpontok közötti titkosítással – csak megbízható csevegési átjátszókkal használja őket. + alert message + Chats with members Csevegés a tagokkal No comment provided by engineer. + + Chats with members are disabled + A csevegés a tagokkal le van tiltva + No comment provided by engineer. + Check messages every 20 min. Üzenetek ellenőrzése 20 percenként. @@ -1679,6 +1953,16 @@ set passcode view Üzenetek ellenőrzése, amikor engedélyezett. No comment provided by engineer. + + Check relay address and try again. + Ellenőrizze az átjátszó címét, és próbálja újra. + alert message + + + Check relay name and try again. + Ellenőrizze az átjátszó nevét, és próbálja újra. + alert message + Check server address and try again. Kiszolgáló címének ellenőrzése és újrapróbálkozás. @@ -1691,7 +1975,7 @@ set passcode view Choose _Migrate from another device_ on the new device and scan QR code. - Válassza az _Átköltöztetés egy másik eszközről_ opciót az új eszközén és olvassa be a QR-kódot. + Válassza az _Átköltöztetés egy másik eszközről_ beállítást az új eszközén és olvassa be a QR-kódot. No comment provided by engineer. @@ -1721,37 +2005,37 @@ set passcode view Clear - Kiürítés + Ürítés swipe action Clear conversation - Üzenetek kiürítése + Üzenetek ürítése No comment provided by engineer. Clear conversation? - Kiüríti az üzeneteket? + Üríti a beszélgetés üzeneteit? No comment provided by engineer. Clear group? - Kiüríti a csoportot? + Üríti a csoport üzeneteit? No comment provided by engineer. Clear or delete group? - Csoport kiürítése vagy törlése? + Csoport ürítése vagy törlése? No comment provided by engineer. Clear private notes? - Kiüríti a privát jegyzeteket? + Üríti a privát jegyzetek tartalmát? No comment provided by engineer. Clear verification - Hitelesítés törlése + Ellenőrzés törlése No comment provided by engineer. @@ -1802,7 +2086,7 @@ set passcode view Conditions of use Használati feltételek - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1824,9 +2108,9 @@ set passcode view ICE-kiszolgálók beállítása No comment provided by engineer. - - Configure server operators - Kiszolgálóüzemeltetők beállítása + + Configure relays + Átjátszók konfigurálása No comment provided by engineer. @@ -1887,7 +2171,8 @@ set passcode view Connect Kapcsolódás - server test step + relay test step +server test step Connect automatically @@ -1933,9 +2218,14 @@ Ez a saját egyszer használható meghívója! Kapcsolódás egy hivatkozáson keresztül new chat sheet title + + Connect via link or QR code + Hivatkozás vagy QR-kód használata + No comment provided by engineer. + Connect via one-time link - Kapcsolódás egyszer használható meghívón keresztül + Kapcsolódás az egyszer használható meghívón keresztül new chat sheet title @@ -1960,7 +2250,7 @@ Ez a saját egyszer használható meghívója! Connected to desktop - Kapcsolódva a számítógéphez + Társítva a számítógéppel No comment provided by engineer. @@ -1985,7 +2275,7 @@ Ez a saját egyszer használható meghívója! Connecting to desktop - Kapcsolódás a számítógéphez + Társítás számítógéppel No comment provided by engineer. @@ -2011,6 +2301,11 @@ Ez a saját egyszer használható meghívója! Connection error (AUTH) Kapcsolódási hiba (AUTH) + conn error description + + + Connection failed + Nem sikerült létrehozni a kapcsolatot No comment provided by engineer. @@ -2065,6 +2360,11 @@ Ez a saját egyszer használható meghívója! Kapcsolatok No comment provided by engineer. + + Contact address + Kapcsolattartási cím + chat link info line + Contact allows Partner engedélyezi @@ -2135,6 +2435,11 @@ Ez a saját egyszer használható meghívója! Folytatás No comment provided by engineer. + + Contribute + Közreműködés + No comment provided by engineer. + Conversation deleted! Beszélgetés törölve! @@ -2147,7 +2452,7 @@ Ez a saját egyszer használható meghívója! Copy error - Másolási hiba + Hiba másolása No comment provided by engineer. @@ -2163,12 +2468,7 @@ Ez a saját egyszer használható meghívója! Correct name to %@? Helyesbíti a nevet a következőre: %@? - No comment provided by engineer. - - - Create - Létrehozás - No comment provided by engineer. + alert message Create 1-time link @@ -2212,7 +2512,7 @@ Ez a saját egyszer használható meghívója! Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 - Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻 + Új profil létrehozása a [számítógépes alkalmazásban](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -2220,6 +2520,16 @@ Ez a saját egyszer használható meghívója! Profil létrehozása No comment provided by engineer. + + Create public channel + Nyilvános csatorna létrehozása + No comment provided by engineer. + + + Create public channel (BETA) + Nyilvános csatorna létrehozása (BÉTA) + No comment provided by engineer. + Create queue Várólista létrehozása @@ -2230,11 +2540,21 @@ Ez a saját egyszer használható meghívója! Saját cím létrehozása No comment provided by engineer. + + Create your link + Saját hivatkozás létrehozása + No comment provided by engineer. + Create your profile Profil létrehozása No comment provided by engineer. + + Create your public address + Saját nyilvános cím létrehozása + No comment provided by engineer. + Created Létrehozva @@ -2255,6 +2575,11 @@ Ez a saját egyszer használható meghívója! Archívum hivatkozás létrehozása No comment provided by engineer. + + Creating channel + Csatorna létrehozása + No comment provided by engineer. + Creating link… Hivatkozás létrehozása… @@ -2267,7 +2592,7 @@ Ez a saját egyszer használható meghívója! Current conditions text couldn't be loaded, you can review conditions via this link: - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: No comment provided by engineer. @@ -2413,10 +2738,10 @@ Ez a saját egyszer használható meghívója! Kézbesítési hibák felderítése No comment provided by engineer. - - Decentralized - Decentralizált - No comment provided by engineer. + + Decode link + Hivatkozás dekódolása + relay test step Decryption error @@ -2456,7 +2781,7 @@ swipe action Delete all files - Az összes fájl törlése + Összes fájl törlése No comment provided by engineer. @@ -2464,6 +2789,16 @@ swipe action Törlés, és a partner értesítése No comment provided by engineer. + + Delete channel + Csatorna törlése + No comment provided by engineer. + + + Delete channel? + Törli a csatornát? + No comment provided by engineer. + Delete chat Csevegés törlése @@ -2579,6 +2914,16 @@ swipe action Törli a tag üzenetét? No comment provided by engineer. + + Delete member messages + Tag üzeneteinek törlése + No comment provided by engineer. + + + Delete member messages? + Törli a tag üzeneteit? + alert title + Delete message? Törli az üzenetet? @@ -2587,7 +2932,8 @@ swipe action Delete messages Üzenetek törlése - alert button + alert action +alert button Delete messages after @@ -2624,6 +2970,11 @@ swipe action Várólista törlése server test step + + Delete relay + Átjátszó törlése + No comment provided by engineer. + Delete report Jelentés törlése @@ -2706,7 +3057,7 @@ swipe action Desktop app version %@ is not compatible with this app. - A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással. + A számítógépes alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással. No comment provided by engineer. @@ -2716,7 +3067,7 @@ swipe action Destination server address of %@ is incompatible with forwarding server %@ settings. - A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbítókiszolgáló beállításaival. + A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbító kiszolgáló beállításaival. No comment provided by engineer. @@ -2726,7 +3077,7 @@ swipe action Destination server version of %@ is incompatible with forwarding server %@. - A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbítókiszolgálóval. + A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbító kiszolgálóval. No comment provided by engineer. @@ -2771,7 +3122,7 @@ swipe action Different names, avatars and transport isolation. - Különböző nevek, profilképek és átvitelizoláció. + Különböző nevek, profilképek és átvitelelkülönítés. No comment provided by engineer. @@ -2789,6 +3140,16 @@ swipe action A tagok közötti közvetlen üzenetek le vannak tiltva. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + A feliratkozók közötti közvetlen üzenetek le vannak tiltva. + No comment provided by engineer. + + + Disable + Letiltás + alert button + Disable (keep overrides) Letiltás (egyéni beállítások megtartása) @@ -2881,7 +3242,7 @@ swipe action Do NOT use private routing. - NE használjon privát útválasztást. + NE legyen használva privát útválasztás. No comment provided by engineer. @@ -2894,6 +3255,11 @@ swipe action Az előzmények ne legyenek elküldve az új tagok számára. No comment provided by engineer. + + Do not send history to new subscribers. + Az előzmények ne legyenek elküldve az új feliratkozók számára. + No comment provided by engineer. + Do not use credentials with proxy. Ne használja a hitelesítési adatokat proxyval. @@ -2911,7 +3277,7 @@ swipe action Don't enable - Ne engedélyezze + Nem engedélyezem No comment provided by engineer. @@ -2921,7 +3287,7 @@ swipe action Don't show again - Ne mutasd újra + Ne jelenjen meg újra alert action @@ -2992,7 +3358,12 @@ chat item action E2E encrypted notifications. - Végpontok közötti titkosított értesítések. + Végpontok között titkosított értesítések. + No comment provided by engineer. + + + Easier to invite your friends 👋 + Könnyebben hívhatja meg a barátait 👋 No comment provided by engineer. @@ -3000,6 +3371,11 @@ chat item action Szerkesztés chat item action + + Edit channel profile + Csatornaprofil szerkesztése + No comment provided by engineer. + Edit group profile Csoportprofil szerkesztése @@ -3013,7 +3389,7 @@ chat item action Enable Engedélyezés - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3035,6 +3411,11 @@ chat item action TCP életben tartása No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + Engedélyezzen legalább egy csevegési átjátszót a „Hálózat és kiszolgálók” menüben. + channel creation warning + Enable automatic message deletion? Engedélyezi az automatikus üzenettörlést? @@ -3045,6 +3426,11 @@ chat item action Kamera-hozzáférés engedélyezése No comment provided by engineer. + + Enable chats with admins? + Engedélyezi a csevegést az adminisztrátorokkal? + alert title + Enable disappearing messages by default. Eltűnő üzenetek engedélyezése alapértelmezetten. @@ -3065,16 +3451,16 @@ chat item action Engedélyezi az azonnali értesítéseket? No comment provided by engineer. + + Enable link previews? + Engedélyezi a hivatkozások előnézetét? + alert title + Enable lock Zárolás engedélyezése No comment provided by engineer. - - Enable notifications - Értesítések engedélyezése - No comment provided by engineer. - Enable periodic notifications? Engedélyezi az időszakos értesítéseket? @@ -3102,7 +3488,7 @@ chat item action Encrypt - Titkosít + Titkosítás No comment provided by engineer. @@ -3180,6 +3566,11 @@ chat item action Adja meg a jelkódot No comment provided by engineer. + + Enter channel name… + Adja meg a csatorna nevét… + No comment provided by engineer. + Enter correct passphrase. Adja meg a helyes jelmondatot. @@ -3205,6 +3596,16 @@ chat item action Adja meg a jelszót fentebb a megjelenítéshez! No comment provided by engineer. + + Enter profile name... + Profil nevének megadása… + No comment provided by engineer. + + + Enter relay name… + Adja meg az átjátszó nevét… + No comment provided by engineer. + Enter server manually Kiszolgáló megadása kézzel @@ -3233,7 +3634,7 @@ chat item action Error Hiba - No comment provided by engineer. + conn error description Error aborting address change @@ -3260,6 +3661,11 @@ chat item action Hiba történt a tag(ok) hozzáadásakor No comment provided by engineer. + + Error adding relay + Hiba az átjátszó hozzáadásakor + alert title + Error adding server Hiba történt a kiszolgáló hozzáadásakor @@ -3307,14 +3713,24 @@ chat item action Error connecting to forwarding server %@. Please try later. - Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. + Hiba történt a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %@ + subscription status explanation + Error creating address Hiba történt a cím létrehozásakor No comment provided by engineer. + + Error creating channel + Hiba a csatorna létrehozásakor + alert title + Error creating group Hiba történt a csoport létrehozásakor @@ -3357,7 +3773,7 @@ chat item action Error deleting chat - Hiba a taggal való csevegés törlésekor + Hiba a csevegés törlésekor alert title @@ -3450,11 +3866,6 @@ chat item action Hiba történt a csevegés megnyitásakor No comment provided by engineer. - - Error opening group - Hiba a csoport előkészítésekor - No comment provided by engineer. - Error receiving file Hiba történt a fájl fogadásakor @@ -3500,6 +3911,11 @@ chat item action Hiba történt az ICE-kiszolgálók mentésekor No comment provided by engineer. + + Error saving channel profile + Hiba a csatornaprofil mentésekor + No comment provided by engineer. + Error saving chat list Hiba történt a csevegési lista mentésekor @@ -3565,6 +3981,11 @@ chat item action Hiba történt a kézbesítési jelentések beállításakor! No comment provided by engineer. + + Error sharing channel + Hiba a csatorna megosztásakor + alert title + Error starting chat Hiba történt a csevegés elindításakor @@ -3627,7 +4048,7 @@ chat item action Error verifying passphrase: - Hiba történt a jelmondat hitelesítésekor: + Hiba történt a jelmondat ellenőrzésekor: No comment provided by engineer. @@ -3644,7 +4065,9 @@ snd error text Error: %@. - server test error + Hiba: %@. + relay test error +server test error Error: URL is invalid @@ -3837,7 +4260,7 @@ snd error text Files and media not allowed - A fájlok és a médiatartalmak nincsenek engedélyezve + A fájlok és a médiatartalmak küldése nincs engedélyezve No comment provided by engineer. @@ -3845,6 +4268,11 @@ snd error text A fájlok és a médiatartalmak küldése le van tiltva! No comment provided by engineer. + + Filter + Szűrő + No comment provided by engineer. + Filter unread and favorite chats. Olvasatlan és kedvenc csevegésekre való szűrés. @@ -3872,19 +4300,23 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + A továbbító kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen - server test error + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal. + relay test error +server test error Fingerprint in server address does not match certificate: %@. + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. No comment provided by engineer. @@ -3922,10 +4354,16 @@ snd error text Az összes moderátor számára No comment provided by engineer. + + For anyone to reach you + Bárki számára, aki el szeretné érni Önt + No comment provided by engineer. + For chat profile %@: A(z) %@ nevű csevegési profilhoz: - servers error + servers error +servers warning For console @@ -3999,30 +4437,30 @@ snd error text Forwarding server %1$@ failed to connect to destination server %2$@. Please try later. - A(z) %1$@ továbbítókiszolgáló nem tudott kapcsolódni a(z) %2$@ célkiszolgálóhoz. Próbálja meg később. + A(z) %1$@ továbbító kiszolgáló nem tudott kapcsolódni a(z) %2$@ célkiszolgálóhoz. Próbálja meg később. alert message Forwarding server address is incompatible with network settings: %@. - A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@. + A továbbító kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@. No comment provided by engineer. Forwarding server version is incompatible with network settings: %@. - A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@. + A továbbító kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@. No comment provided by engineer. Forwarding server: %1$@ Destination server error: %2$@ - Továbbítókiszolgáló: %1$@ + Továbbító kiszolgáló: %1$@ Célkiszolgáló-hiba: %2$@ snd error text Forwarding server: %1$@ Error: %2$@ - Továbbítókiszolgáló: %1$@ + Továbbító kiszolgáló: %1$@ Hiba: %2$@ snd error text @@ -4066,11 +4504,21 @@ Hiba: %2$@ GIF-ek és matricák No comment provided by engineer. + + Get link + Hivatkozás megtekintése + relay test step + Get notified when mentioned. Kapjon értesítést, ha megemlítik. No comment provided by engineer. + + Get started + Vágjunk bele + No comment provided by engineer. + Good afternoon! Jó napot! @@ -4129,7 +4577,7 @@ Hiba: %2$@ Group link Csoporthivatkozás - No comment provided by engineer. + chat link info line Group links @@ -4241,6 +4689,11 @@ Hiba: %2$@ Az előzmények nem lesznek elküldve az új tagok számára. No comment provided by engineer. + + History is not sent to new subscribers. + Az előzmények nem lesznek elküldve az új feliratkozók számára. + No comment provided by engineer. + How SimpleX works Hogyan működik a SimpleX @@ -4263,7 +4716,7 @@ Hiba: %2$@ How to - Hogyan + Útmutató No comment provided by engineer. @@ -4306,9 +4759,14 @@ Hiba: %2$@ Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + Ha csatornákat hozott létre vagy csak csatlakozott hozzájuk, akkor azok véglegesen le fognak állni. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). - Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). + Ha most kell használnia a csevegést, koppintson lentebb a **Befejezés később** beállításra (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). No comment provided by engineer. @@ -4326,16 +4784,16 @@ Hiba: %2$@ A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! No comment provided by engineer. + + Images + Képek + No comment provided by engineer. + Immediately Azonnal No comment provided by engineer. - - Immune to spam - Védett a kéretlen tartalommal szemben - No comment provided by engineer. - Import Importálás @@ -4353,7 +4811,7 @@ Hiba: %2$@ Import failed - Sikertelen importálás + Nem sikerült az importálás No comment provided by engineer. @@ -4380,12 +4838,12 @@ További fejlesztések hamarosan! Improved privacy and security - Fejlesztett adatvédelem és biztonság + Továbbfejlesztett adatvédelem és biztonság No comment provided by engineer. Improved server configuration - Javított kiszolgáló konfiguráció + Továbbfejlesztett kiszolgálókonfiguráció No comment provided by engineer. @@ -4478,9 +4936,9 @@ További fejlesztések hamarosan! Kezdeti szerepkör No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - A [SimpleX Chat terminálhoz] telepítése (https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + A SimpleX Chat terminálhoz telepítése No comment provided by engineer. @@ -4491,7 +4949,7 @@ További fejlesztések hamarosan! Instant push notifications will be hidden! - Az azonnali push-értesítések el lesznek rejtve! + Az azonnali leküldéses értesítések el lesznek rejtve! No comment provided by engineer. @@ -4538,7 +4996,7 @@ További fejlesztések hamarosan! Invalid connection link Érvénytelen kapcsolattartási hivatkozás - No comment provided by engineer. + conn error description Invalid display name! @@ -4558,7 +5016,17 @@ További fejlesztések hamarosan! Invalid name! Érvénytelen név! - No comment provided by engineer. + alert title + + + Invalid relay address! + Érvénytelen az átjátszó címe! + alert title + + + Invalid relay name! + Érvénytelen az átjátszó neve! + alert title Invalid response @@ -4585,11 +5053,21 @@ További fejlesztések hamarosan! Barátok meghívása No comment provided by engineer. + + Invite member + Tag meghívása + No comment provided by engineer. + Invite members Tagok meghívása No comment provided by engineer. + + Invite someone privately + Partner meghívása privátban + No comment provided by engineer. + Invite to chat Meghívás a csevegésbe @@ -4663,7 +5141,12 @@ További fejlesztések hamarosan! Join as %@ - csatlakozás mint %@ + Csatlakozás mint: %@ + No comment provided by engineer. + + + Join channel + Csatlakozás a csatornához No comment provided by engineer. @@ -4705,7 +5188,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Keep the app open to use it from desktop - A számítógépről való használathoz tartsd nyitva az alkalmazást + Alkalmazás megnyitva tartása a számítógépről való használathoz No comment provided by engineer. @@ -4753,6 +5236,16 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Elhagyás swipe action + + Leave channel + Csatorna elhagyása + No comment provided by engineer. + + + Leave channel? + Elhagyja a csatornát? + No comment provided by engineer. + Leave chat Csevegés elhagyása @@ -4778,6 +5271,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Kevesebb adatforgalom a mobilhálózatokon. No comment provided by engineer. + + Let someone connect to you + Hagyja, hogy valaki elérje Önt + No comment provided by engineer. + Let's talk in SimpleX Chat Beszélgessünk a SimpleX Chatben @@ -4795,9 +5293,14 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Link mobile and desktop apps! 🔗 - Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 + Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗 No comment provided by engineer. + + Link signature verified. + Hivatkozás aláírása ellenőrizve. + owner verification + Linked desktop options Társított számítógép beállítások @@ -4808,6 +5311,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Társított számítógépek No comment provided by engineer. + + Links + Hivatkozások + No comment provided by engineer. + List Lista @@ -4885,7 +5393,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Mark verified - Hitelesítés + Megjelölés ellenőrzöttként No comment provided by engineer. @@ -4895,7 +5403,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Max 30 seconds, received instantly. - Max. 30 másodperc, azonnal érkezett. + Legfeljebb 30 másodperc, azonnal megérkezik. No comment provided by engineer. @@ -4933,6 +5441,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! A tag törölve lett – nem lehet elfogadni a kérést No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza! + alert message + Member reports Tagok jelentései @@ -4956,12 +5469,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Member will be removed from chat - this cannot be undone! A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4973,6 +5486,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! A tagok reakciókat adhatnak hozzá az üzenetekhez. No comment provided by engineer. + + Members can chat with admins. + A tagok cseveghetnek az adminisztrátorokkal + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra) @@ -5035,7 +5553,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Message draft - Üzenetvázlat + Piszkozatok + No comment provided by engineer. + + + Message error + Üzenethiba No comment provided by engineer. @@ -5133,6 +5656,16 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! %@ összes üzenete meg fog jelenni! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + Ebben a csatornában az üzenetek **nem rendelkeznek végpontok közötti titkosítással**. A csevegési átjátszók láthatják ezeket az üzeneteket. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + Ebben a csatornában az üzenetek nem rendelkeznek végpontok közötti titkosítással. A csevegési átjátszók láthatják ezeket az üzeneteket. + E2EE info chat item + Messages in this chat will never be deleted. Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. @@ -5150,7 +5683,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Messages were deleted after you selected them. - Az üzeneteket törölték miután kijelölte őket. + Az üzeneteket törölték miután kiválasztotta őket. alert message @@ -5163,16 +5696,16 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Az üzenetek, a fájlok és a hívások **végpontok közötti kvantumbiztos titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve. No comment provided by engineer. + + Migrate + Átköltöztetés + No comment provided by engineer. + Migrate device Eszköz átköltöztetése No comment provided by engineer. - - Migrate from another device - Átköltöztetés egy másik eszközről - No comment provided by engineer. - Migrate here Átköltöztetés ide @@ -5200,7 +5733,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Migration complete - Átköltöztetés befejezve + Átköltöztetés kész No comment provided by engineer. @@ -5210,12 +5743,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). - Sikertelen átköltöztetés. Koppintson a **Kihagyás** lehetőségre a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat). + Sikertelen átköltöztetés. Koppintson a **Kihagyás** beállításra a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat). No comment provided by engineer. Migration is completed - Az átköltöztetés befejeződött + Az átköltöztetés elkészült No comment provided by engineer. @@ -5293,6 +5826,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Hálózat és kiszolgálók No comment provided by engineer. + + Network commitments + Hálózati kötelezettségvállalások + No comment provided by engineer. + Network connection Hálózati kapcsolat @@ -5303,6 +5841,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Hálózati decentralizáció No comment provided by engineer. + + Network error + Hálózati hiba + conn error description + Network issues - message expired after many attempts to send it. Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. @@ -5318,6 +5861,13 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Hálózatüzemeltető No comment provided by engineer. + + Network routers cannot know +who talks to whom + A hálózati útválasztók nem tudhatják, +hogy ki kivel beszélget + No comment provided by engineer. + Network settings Hálózati beállítások @@ -5326,13 +5876,18 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Network status Hálózat állapota - No comment provided by engineer. + alert title New Új token status text + + New 1-time link + Új egyszer használható meghívó + No comment provided by engineer. + New Passcode Új jelkód @@ -5358,6 +5913,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Új csevegési élmény 🎉 No comment provided by engineer. + + New chat relay + Új csevegési átjátszó + No comment provided by engineer. + New contact request Új partneri kapcsolatkérés @@ -5365,12 +5925,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! New contact: - Új kapcsolat: + Új partner: notification New desktop app! - Új számítógép-alkalmazás! + Új számítógépes alkalmazás! No comment provided by engineer. @@ -5428,11 +5988,33 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Nem No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + Nincs fiók. Nincs telefonszám. Nincs e-mail-cím. Nincs személyazonosító. +A legbiztonságosabb titkosítás. + No comment provided by engineer. + + + No active relays + Nincsenek aktív átjátszók + No comment provided by engineer. + No app password Nincs alkalmazás jelszó Authentication unavailable + + No chat relays + Nincsenek csevegési átjátszók + No comment provided by engineer. + + + No chat relays enabled. + Nincsenek engedélyezve csevegési átjátszók. + servers warning + No chats Nincsenek csevegések @@ -5455,7 +6037,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No contacts selected - Nincs partner kijelölve + Nincs partner kiválasztva No comment provided by engineer. @@ -5540,7 +6122,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No push server - Helyi + Nincs kiszolgáló a leküldéses értesítésekhez No comment provided by engineer. @@ -5565,7 +6147,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No servers to send files. - Nincsenek fájlküldő-kiszolgálók. + Nincsenek fájlküldési kiszolgálók. servers error @@ -5578,11 +6160,26 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Nincsenek olvasatlan csevegések No comment provided by engineer. - - No user identifiers. - Nincsenek felhasználói azonosítók. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + Senki sem követte nyomon a beszélgetéseinket. Senki sem készített térképet arról, hogy merre jártunk. A magánéletünk nem csak egy funkció volt, hanem az életmódunk. No comment provided by engineer. + + Non-profit governance + Nonprofit irányítás + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + Nem egy jobb zár mások ajtaján. Nem egy kedvesebb házmester, aki tiszteletben tartja az Ön magánéletét, de mégis nyilvántartást vezet minden látogatójáról. Ön itt nem csak egy vendég. Ön itt otthon van. Nincs az a hatalom, amely beléphetne ide - Ön itt szuverén. + No comment provided by engineer. + + + Not all relays connected + Nem minden átjátszó kapcsolódott + alert title + Not compatible! Nem kompatibilis! @@ -5595,7 +6192,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Nothing selected - Nincs semmi kijelölve + Nincs semmi kiválasztva No comment provided by engineer. @@ -5640,7 +6237,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! OK Rendben - No comment provided by engineer. + alert button Off @@ -5659,11 +6256,21 @@ new chat action Régi adatbázis No comment provided by engineer. + + On your phone, not on servers. + Az eszközön, nem pedig kiszolgálókon. + No comment provided by engineer. + One-time invitation link Egyszer használható meghívó No comment provided by engineer. + + One-time link + Egyszer használható meghívó + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5683,6 +6290,11 @@ VPN engedélyezése szükséges. Az onion kiszolgálók nem lesznek használva. No comment provided by engineer. + + Only channel owners can change channel preferences. + Csak a csatorna tulajdonosai módosíthatják a csatornabeállításokat. + No comment provided by engineer. + Only chat owners can change preferences. Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat. @@ -5730,17 +6342,17 @@ VPN engedélyezése szükséges. Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours) - Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) + Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra) No comment provided by engineer. Only you can make calls. - Csak Ön tud hívásokat indítani. + Csak Ön kezdeményezhet hívásokat. No comment provided by engineer. Only you can send disappearing messages. - Csak Ön tud eltűnő üzeneteket küldeni. + Csak Ön küldhet eltűnő üzeneteket. No comment provided by engineer. @@ -5750,7 +6362,7 @@ VPN engedélyezése szükséges. Only you can send voice messages. - Csak Ön tud hangüzeneteket küldeni. + Csak Ön küldhet hangüzeneteket. No comment provided by engineer. @@ -5760,33 +6372,34 @@ VPN engedélyezése szükséges. Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours) - Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra) No comment provided by engineer. Only your contact can make calls. - Csak a partnere tud hívást indítani. + Csak a partnere kezdeményezhet hívásokat. No comment provided by engineer. Only your contact can send disappearing messages. - Csak a partnere tud eltűnő üzeneteket küldeni. + Csak a partnere küldhet eltűnő üzeneteket. No comment provided by engineer. Only your contact can send files and media. - Csak a partnere küldhet fájlokat és a médiatartalmakat. + Csak a partnere küldhet fájlokat és médiatartalmakat. No comment provided by engineer. Only your contact can send voice messages. - Csak a partnere tud hangüzeneteket küldeni. + Csak a partnere küldhet hangüzeneteket. No comment provided by engineer. Open Megnyitás - alert action + alert action +alert button Open Settings @@ -5798,6 +6411,11 @@ VPN engedélyezése szükséges. Módosítások megtekintése No comment provided by engineer. + + Open channel + Csatorna megnyitása + new chat action + Open chat Csevegés megnyitása @@ -5818,6 +6436,11 @@ VPN engedélyezése szükséges. Feltételek megnyitása No comment provided by engineer. + + Open external link? + Megnyitja a külső hivatkozást? + alert title + Open full link Teljes hivatkozás megnyitása @@ -5838,6 +6461,11 @@ VPN engedélyezése szükséges. Átköltöztetés indítása egy másik eszközre authentication reason + + Open new channel + Új csatorna megnyitása + new chat action + Open new chat Új csevegés megnyitása @@ -5883,6 +6511,17 @@ VPN engedélyezése szükséges. Kiszolgáló-üzemeltető alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + Az üzemeltetők kijelentik, hogy: +- függetlenek maradnak +- minimálisra csökkentik a metaadatok használatát +- ellenőrzött, nyílt forráskódú szoftvereket futtatnak + No comment provided by engineer. + Or import archive file Vagy archívumfájl importálása @@ -5903,6 +6542,11 @@ VPN engedélyezése szükséges. Vagy ossza meg biztonságosan ezt a fájlhivatkozást No comment provided by engineer. + + Or show QR in person or via video call. + Vagy mutassa meg a QR-kódot személyesen vagy videóhíváson keresztül. + No comment provided by engineer. + Or show this code Vagy mutassa meg ezt a kódot @@ -5913,6 +6557,11 @@ VPN engedélyezése szükséges. Vagy a privát megosztáshoz No comment provided by engineer. + + Or use this QR - print or show online. + Vagy használja ezt a QR-kódot – nyomtassa ki vagy mutassa meg online. + No comment provided by engineer. + Organize chats into lists Csevegések listákba szervezése @@ -5930,6 +6579,21 @@ VPN engedélyezése szükséges. %@ alert message + + Owner + Tulajdonos + No comment provided by engineer. + + + Owners + Tulajdonosok + No comment provided by engineer. + + + Ownership: you can run your own relays. + Tulajdonjog: saját átjátszókat üzemeltethet. + No comment provided by engineer. + PING count PING-ek száma @@ -5985,6 +6649,11 @@ VPN engedélyezése szükséges. Kép beillesztése No comment provided by engineer. + + Paste link / Scan + Hivatkozás megadása vagy QR-kód beolvasása + No comment provided by engineer. + Paste link to connect! Hivatkozás beillesztése a kapcsolódáshoz! @@ -6091,7 +6760,7 @@ Hiba: %@ Please restart the app and migrate the database to enable push notifications. - Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges push-értesítések engedélyezéséhez. + Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges leküldéses értesítések engedélyezéséhez. No comment provided by engineer. @@ -6116,7 +6785,7 @@ Hiba: %@ Please wait for token activation to complete. - Várjon, amíg a token aktiválása befejeződik. + Várjon, amíg a token aktiválása elkészül. token info @@ -6139,9 +6808,19 @@ Hiba: %@ Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt. No comment provided by engineer. + + Preset relay address + Előre beállított átjátszó címe + No comment provided by engineer. + + + Preset relay name + Előre beállított átjátszó neve + No comment provided by engineer. + Preset server address - Az előre beállított kiszolgáló címe + Előre beállított kiszolgáló címe No comment provided by engineer. @@ -6174,14 +6853,14 @@ Hiba: %@ Adatvédelmi szabályzat és felhasználási feltételek. No comment provided by engineer. - - Privacy redefined - Újraértelmezett adatvédelem + + Privacy: for owners and subscribers. + Adatvédelem: tulajdonosok és előfizetők számára. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - A privát csevegések, a csoportok és a partnerek nem érhetők el a kiszolgálók üzemeltetői számára. + + Private and secure messaging. + Privát és biztonságos üzenetváltás. No comment provided by engineer. @@ -6224,6 +6903,11 @@ Hiba: %@ Privát útválasztás időtúllépése alert title + + Proceed + Folytatás + alert action + Profile and server connections Profil és kiszolgálókapcsolatok @@ -6249,9 +6933,9 @@ Hiba: %@ Profiltéma No comment provided by engineer. - - Profile update will be sent to your contacts. - A profilfrissítés el lesz küldve a partnerei számára. + + Profile update will be sent to your SimpleX contacts. + A profilfrissítés el lesz küldve a SimpleX partnerei számára. alert message @@ -6259,6 +6943,11 @@ Hiba: %@ A hívások kezdeményezése le van tiltva. No comment provided by engineer. + + Prohibit chats with admins. + A csevegés az adminisztrátorokkal le van tiltva. + No comment provided by engineer. + Prohibit irreversible message deletion. Az elküldött üzenetek végleges törlése le van tiltva. @@ -6276,7 +6965,7 @@ Hiba: %@ Prohibit reporting messages to moderators. - Az üzenetek a moderátorok felé történő jelentésének megtiltása. + Az üzenetek jelentése a moderátorok felé le van tiltva. No comment provided by engineer. @@ -6289,6 +6978,11 @@ Hiba: %@ A közvetlen üzenetek küldése a tagok között le van tiltva. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + A közvetlen üzenetek küldése a feliratkozók között le van tiltva. + No comment provided by engineer. + Prohibit sending disappearing messages. Az eltűnő üzenetek küldése le van tiltva. @@ -6317,7 +7011,7 @@ Hiba: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. - Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben. + Védje az IP-címét a partnerei által kiválasztott üzenetváltási átjátszókkal szemben. Engedélyezze a *Hálózat és kiszolgálók* menüben. No comment provided by engineer. @@ -6356,14 +7050,19 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. A proxy jelszót igényel No comment provided by engineer. + + Public channels - speak freely 🚀 + Nyilvános csatornák – mondja el szabadon a véleményét 🚀 + No comment provided by engineer. + Push notifications - Push-értesítések + Leküldéses értesítések No comment provided by engineer. Push server - Push-kiszolgáló + Leküldéses értesítéskiszolgáló No comment provided by engineer. @@ -6373,7 +7072,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Rate the app - Értékelje az alkalmazást + Alkalmazás értékelése No comment provided by engineer. @@ -6383,7 +7082,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. React… - Reagálj… + Reagálás… chat item menu @@ -6396,24 +7095,14 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Tudjon meg többet No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - További információ a [Használati útmutatóban](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + További információ a Használati útmutatóban. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - További információ a [Használati útmutatóban](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - További információ a [Használati útmutatóban](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - További információ a [GitHub-tárolónkban](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + További információ a GitHub-tárolónkban. No comment provided by engineer. @@ -6436,11 +7125,6 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Fogadva: %@ copied message info - - Received file event - Fogadott fájlesemény - notification - Received message Fogadott üzenetbuborék színe @@ -6565,7 +7249,7 @@ swipe action Reject (sender NOT notified) - Elutasítás (a kérés küldője NEM fog értesítést kapni) + Elutasítás (a kérés küldője NEM lesz értesítve) No comment provided by engineer. @@ -6578,20 +7262,60 @@ swipe action Elutasítja a tagot? alert title + + Relay + Átjátszó + No comment provided by engineer. + + + Relay address + Átjátszó címe + alert title + + + Relay connection failed + Nem sikerült kapcsolódni az átjátszóhoz + alert title + + + Relay link + Átjátszóhivatkozás + No comment provided by engineer. + + + Relay results: + Átjátszóeredmények: + alert message + Relay server is only used if necessary. Another party can observe your IP address. - A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. + Az átjátszó csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. No comment provided by engineer. Relay server protects your IP address, but it can observe the duration of the call. - A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. + Az átjátszó megvédi az IP-címét, de megfigyelheti a hívás időtartamát. + No comment provided by engineer. + + + Relay test failed! + Nem sikerült tesztelni az átjátszót! + No comment provided by engineer. + + + Reliability: many relays per channel. + Megbízhatóság: több átjátszó is használható csatornánként. No comment provided by engineer. Remove Eltávolítás - No comment provided by engineer. + alert action + + + Remove and delete messages + Eltávolítás és az üzeneteinek törlése + alert action Remove archive? @@ -6616,13 +7340,23 @@ swipe action Remove member? Eltávolítja a tagot? - No comment provided by engineer. + alert title Remove passphrase from keychain? Eltávolítja a jelmondatot a kulcstartóból? No comment provided by engineer. + + Remove subscriber + Feliratkozó eltávolítása + No comment provided by engineer. + + + Remove subscriber? + Eltávolítja a feliratkozót? + alert title + Removes messages and blocks members. Üzenetek eltávolítása és a tagok tiltása. @@ -6735,7 +7469,7 @@ swipe action Reset all statistics - Az összes statisztika visszaállítása + Összes statisztika visszaállítása No comment provided by engineer. @@ -6858,6 +7592,11 @@ swipe action SOCKS proxy No comment provided by engineer. + + Safe web links + Biztonságos webhivatkozások + No comment provided by engineer. + Safely receive files Fájlok biztonságos fogadása @@ -6884,6 +7623,11 @@ chat item action Mentés (és a tagok értesítése) alert button + + Save (and notify subscribers) + Mentés (és a feliratkozók értesítése) + alert button + Save admission settings? Menti a befogadási beállításokat? @@ -6899,6 +7643,11 @@ chat item action Mentés és a csoporttagok értesítése No comment provided by engineer. + + Save and notify subscribers + Mentés és a feliratkozók értesítése + No comment provided by engineer. + Save and reconnect Mentés és újrakapcsolódás @@ -6909,6 +7658,16 @@ chat item action Mentés és a csoportprofil frissítése No comment provided by engineer. + + Save channel profile + Csatornaprofil mentése + No comment provided by engineer. + + + Save channel profile? + Menti a csatornaprofilt? + alert title + Save group profile Csoportprofil mentése @@ -7034,9 +7793,34 @@ chat item action A keresősáv elfogadja a meghívási hivatkozásokat. No comment provided by engineer. + + Search files + Fájlok keresése + No comment provided by engineer. + + + Search images + Képek keresése + No comment provided by engineer. + + + Search links + Hivatkozások keresése + No comment provided by engineer. + Search or paste SimpleX link - Keresés vagy SimpleX-hivatkozás beillesztése + Keressen vagy adjon meg egy SimpleX-hivatkozást + No comment provided by engineer. + + + Search videos + Videók keresése + No comment provided by engineer. + + + Search voice messages + Hangüzenetek keresése No comment provided by engineer. @@ -7056,7 +7840,7 @@ chat item action Security assessment - Biztonsági kiértékelés + Biztonsági felmérés No comment provided by engineer. @@ -7064,24 +7848,29 @@ chat item action Biztonsági kód No comment provided by engineer. + + Security: owners hold channel keys. + Biztonság: a csatornák kulcsait a tulajdonosok őrzik. + No comment provided by engineer. + Select - Kijelölés + Kiválasztás chat item action Select chat profile - Csevegési profil kijelölése + Csevegési profil kiválasztása No comment provided by engineer. Selected %lld - %lld kijelölve + %lld kiválasztva No comment provided by engineer. Selected chat preferences prohibit this message. - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -7194,6 +7983,11 @@ chat item action Kérés küldése üzenet nélkül No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + Küldje el a hivatkozást bármilyen üzenetváltó alkalmazáson keresztül – ez egy biztonságos módszer – és kérje meg a partnerét, hogy illessze be a SimpleX alkalmazásba. + No comment provided by engineer. + Send them from gallery or custom keyboards. Küldje el őket a galériából vagy az egyéni billentyűzetekről. @@ -7204,6 +7998,11 @@ chat item action Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + Legfeljebb az utolsó 100 üzenet elküldése az új feliratkozók számára. + No comment provided by engineer. + Send your private feedback to groups. Küldjön privát visszajelzést a csoportoknak. @@ -7219,6 +8018,11 @@ chat item action A kérés küldője törölhette a kapcsolódási kérést. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + A hivatkozáselőnézet küldése felfedheti az Ön IP-címét a weboldal számára. Ezt később módosíthatja az adatvédelmi beállításokban. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. @@ -7236,7 +8040,7 @@ chat item action Sending receipts is disabled for %lld contacts - A kézbesítési jelentések le vannak tiltva %lld partnernél + A kézbesítési jelentések le vannak tiltva %lld partner számára No comment provided by engineer. @@ -7246,7 +8050,7 @@ chat item action Sending receipts is enabled for %lld contacts - A kézbesítési jelentések engedélyezve vannak %lld partnernél + A kézbesítési jelentések engedélyezve vannak %lld partner számára No comment provided by engineer. @@ -7274,11 +8078,6 @@ chat item action Közvetlenül küldött No comment provided by engineer. - - Sent file event - Elküldött fájlesemény - notification - Sent message Üzenetbuborék színe @@ -7349,14 +8148,19 @@ chat item action A kiszolgálóprotokoll módosult. alert title + + Server requires authorization to connect to relay, check password. + A kiszolgáló hitelesítést igényel az átjátszóhoz való kapcsolódáshoz, ellenőrizze a jelszavát. + relay test error + Server requires authorization to create queues, check password. - A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát + A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát. server test error Server requires authorization to upload, check password. - A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. server test error @@ -7396,7 +8200,7 @@ chat item action Session code - Munkamenet kód + Munkamenet kódja No comment provided by engineer. @@ -7456,7 +8260,7 @@ chat item action Set profile bio and welcome message. - Névjegy és üdvözlőüzenet beállítása a profilokhoz. + Életrajz és üdvözlőüzenet beállítása a profilokhoz. No comment provided by engineer. @@ -7479,6 +8283,16 @@ chat item action A beállítások módosultak. alert message + + Setup notifications + Értesítések beállítása + No comment provided by engineer. + + + Setup routers + Útválasztók beállítása + No comment provided by engineer. + Shape profile images Profilkép alakzata @@ -7515,11 +8329,16 @@ chat item action Cím nyilvános megosztása No comment provided by engineer. - - Share address with contacts? - Megosztja a címet a partnereivel? + + Share address with SimpleX contacts? + Megosztja a címet a SimpleX partnereivel? alert title + + Share channel + Csatorna megosztása + No comment provided by engineer. + Share from other apps. Megosztás más alkalmazásokból. @@ -7537,7 +8356,7 @@ chat item action Share old link - Teljes hivatkozás megosztása + Régi (hosszú) hivatkozás megosztása alert button @@ -7545,6 +8364,11 @@ chat item action Profil megosztása No comment provided by engineer. + + Share relay address + Átjátszó címének megosztása + No comment provided by engineer. + Share this 1-time invite link Ennek az egyszer használható meghívónak a megosztása @@ -7555,9 +8379,14 @@ chat item action Megosztás a SimpleXben No comment provided by engineer. - - Share with contacts - Megosztás a partnerekkel + + Share via chat + Megosztás egy csevegésen keresztül + No comment provided by engineer. + + + Share with SimpleX contacts + Megosztás a SimpleX partnerekkel No comment provided by engineer. @@ -7672,7 +8501,7 @@ chat item action SimpleX address and 1-time links are safe to share via any messenger. - A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül. + A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó alkalmazáson keresztül. No comment provided by engineer. @@ -7682,7 +8511,7 @@ chat item action SimpleX address settings - Beállítások automatikus elfogadása + SimpleX-címbeállítások alert title @@ -7730,9 +8559,9 @@ chat item action A SimpleX protokollokat a Trail of Bits auditálta. No comment provided by engineer. - - SimpleX relay link - SimpleX továbbítókiszolgáló-hivatkozás + + SimpleX relay address + SimpleX-átjátszó címe simplex link type @@ -7757,7 +8586,7 @@ chat item action Small groups (max 20) - Kis csoportok (max. 20 tag) + Kis csoportok (legfeljebb 20 tag) No comment provided by engineer. @@ -7808,9 +8637,14 @@ report reason Négyzet, kör vagy bármi a kettő között. No comment provided by engineer. + + Star on GitHub + Csillagozás a GitHubon + No comment provided by engineer. + Start chat - Csevegés indítása + Csevegés elindítása No comment provided by engineer. @@ -7908,6 +8742,78 @@ report reason Feliratkozva No comment provided by engineer. + + Subscriber + Feliratkozó + No comment provided by engineer. + + + Subscriber reports + Feliratkozók jelentései + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + A feliratkozó el lesz távolítva a csatornából – ez a művelet nem vonható vissza! + alert message + + + Subscribers + Feliratkozók + No comment provided by engineer. + + + Subscribers can add message reactions. + A feliratkozók reakciókat adhatnak hozzá az üzenetekhez. + No comment provided by engineer. + + + Subscribers can chat with admins. + A feliratkozók cseveghetnek az adminisztrátorokkal. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + A feliratkozók véglegesen törölhetik az elküldött üzeneteiket. (24 óra) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + A feliratkozók jelenthetik az üzeneteket a moderátorok felé. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + A feliratkozók küldhetnek SimpleX-hivatkozásokat. + No comment provided by engineer. + + + Subscribers can send direct messages. + A feliratkozók küldhetnek egymásnak közvetlen üzeneteket. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + A feliratkozók küldhetnek eltűnő üzeneteket. + No comment provided by engineer. + + + Subscribers can send files and media. + A feliratkozók küldhetnek fájlokat és médiatartalmakat. + No comment provided by engineer. + + + Subscribers can send voice messages. + A feliratkozók küldhetnek hangüzeneteket. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + A feliratkozók az átjátszó hivatkozását használják a csatornához való kapcsolódáshoz. +Az átjátszó címe ennek az átjátszónak a beállítására szolgált a csatornához. + No comment provided by engineer. + Subscription errors Feliratkozási hibák @@ -7988,6 +8894,11 @@ report reason Kép készítése No comment provided by engineer. + + Talk to someone + Beszélgessen valakivel + No comment provided by engineer. + Tap Connect to chat Koppintson a „Kapcsolódás” gombra a csevegéshez @@ -8003,9 +8914,9 @@ report reason Koppintson a „Kapcsolódás” gombra a bot használatához No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. + + Tap Join channel + Koppintson a „Csatlakozás a csatornához” gombra No comment provided by engineer. @@ -8038,6 +8949,11 @@ report reason Koppintson ide az inkognitóban való kapcsolódáshoz No comment provided by engineer. + + Tap to open + Koppintson ide a megnyitáshoz + No comment provided by engineer. + Tap to paste link Koppintson ide a hivatkozás beillesztéséhez @@ -8056,13 +8972,19 @@ report reason Test failed at step %@. A teszt a(z) %@ lépésnél sikertelen volt. - server test failure + relay test failure +server test failure Test notifications Értesítések tesztelése No comment provided by engineer. + + Test relay + Átjátszó tesztelése + No comment provided by engineer. + Test server Kiszolgáló tesztelése @@ -8115,6 +9037,11 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + Az alkalmazás %lld sikertelen letöltési kísérlet után eltávolította ezt az üzenetet. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról (kivéve .onion) történő letöltések megerősítését. @@ -8130,6 +9057,11 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. A beolvasott QR-kód nem egy SimpleX-hivatkozás. No comment provided by engineer. + + The connection reached the limit of undelivered messages + A kapcsolat elérte a kézbesítetlen üzenetek korlátját + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van. @@ -8155,9 +9087,11 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet! No comment provided by engineer. - - The future of messaging - Az üzenetváltás jövője + + The first network where you own +your contacts and groups. + Az első hálózat, ahol Ön birtokolja +a saját kapcsolatait és csoportjait. No comment provided by engineer. @@ -8195,6 +9129,11 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. A régi adatbázis nem lett eltávolítva az átköltöztetéskor, ezért törölhető. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + A legrégebbi emberi szabadság - beszélgetni az emberekkel, anélkül, hogy mások megfigyelnének - olyan infrastruktúrán alapul, amely nem tudja elárulni. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető számára is: **%@**. @@ -8207,12 +9146,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The second tick we missed! ✅ - A második jelölés, amit kihagytunk! ✅ + A második pipa, ami már nagyon hiányzott! ✅ No comment provided by engineer. The sender will NOT be notified - A kérés küldője NEM fog értesítést kapni + A kérés küldője NEM lesz értesítve alert message @@ -8240,6 +9179,16 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Témák No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + Aztán felléptünk az internetre, és minden platform kért belőlünk egy darabot - nevet, telefonszámot, baráti kapcsolatokat. Elfogadtuk, hogy a kommunikáció ára az, hogy mások megtudják, hogy kivel beszélünk. Minden generáció, az emberek és a technológia is eddig így működött - telefon, e-mail, üzenetküldő programok, közösségi média. Úgy tűnt, ez az egyetlen lehetséges mód. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + De van egy másik lehetőség is. Egy hálózat, amelyben nincsenek telefonszámok. Nincsenek felhasználónevek. Nincsenek fiókok. Nincsenek semmiféle felhasználói azonosítók. Egy hálózat, amely összeköti az embereket és titkosított üzeneteket továbbít, anélkül, hogy tudná, ki csatlakozik hozzá. + No comment provided by engineer. + These conditions will also apply for: **%@**. Ezek a feltételek lesznek elfogadva a következő számára is: **%@**. @@ -8262,12 +9211,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. No comment provided by engineer. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. - Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. alert message @@ -8305,6 +9254,16 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Ez a csoport már nem létezik. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + Ez egy csevegési átjátszó címe, nem használható kapcsolódásra. + alert message + + + This is your link for channel %@! + Ez a saját hivatkozása a(z) %@ nevű csatornához! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől. @@ -8355,6 +9314,11 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Kéretlen üzenetek elrejtése. No comment provided by engineer. + + To make SimpleX Network last. + A SimpleX hálózat hosszú távú működésének biztosítása érdekében. + No comment provided by engineer. + To make a new connection Új kapcsolat létrehozásához @@ -8424,7 +9388,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To support instant push notifications the chat database has to be migrated. - Az azonnali push-értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges. + Az azonnali leküldéses értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges. No comment provided by engineer. @@ -8439,12 +9403,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To verify end-to-end encryption with your contact compare (or scan) the code on your devices. - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. - No comment provided by engineer. - - - Toggle chat list: - Csevegési lista ki/be: + A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. No comment provided by engineer. @@ -8462,6 +9421,11 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Eszköztár átlátszatlansága No comment provided by engineer. + + Top bar + Felső sáv + No comment provided by engineer. + Total Összes kapcsolat @@ -8477,15 +9441,10 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Munkamenetek átvitele No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. + + Trying to connect to the server used to receive messages from this connection. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - No comment provided by engineer. + subscription status explanation Turkish interface @@ -8532,6 +9491,11 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Feloldja a tag letiltását? No comment provided by engineer. + + Unblock subscriber for all? + Az összes feliratkozó számára feloldja a feliratkozó letiltását? + No comment provided by engineer. + Undelivered messages Kézbesítetlen üzenetek @@ -8601,7 +9565,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Unlink - Szétkapcsolás + Leválasztás No comment provided by engineer. @@ -8632,13 +9596,18 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Unsupported connection link Nem támogatott kapcsolattartási hivatkozás - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + Legfeljebb az utolsó 100 üzenet lesz elküldve az új feliratkozók számára. + No comment provided by engineer. + Update Frissítés @@ -8646,7 +9615,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Update database passphrase - Az adatbázis jelmondatának módosítása + Adatbázis jelmondatának módosítása No comment provided by engineer. @@ -8761,12 +9730,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Use TCP port 443 for preset servers only. - A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz. - No comment provided by engineer. - - - Use chat - SimpleX Chat használata + A 443-as TCP-port használata kizárólag az előre beállított kiszolgálókhoz. No comment provided by engineer. @@ -8784,6 +9748,11 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Használat az üzenetekhez No comment provided by engineer. + + Use for new channels + Használat új csatornákhoz + No comment provided by engineer. + Use for new connections Használat új kapcsolatokhoz @@ -8821,7 +9790,12 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Use private routing with unknown servers. - Privát útválasztás használata ismeretlen kiszolgálókkal. + Privát útválasztás használata az ismeretlen kiszolgálókhoz. + No comment provided by engineer. + + + Use relay + Átjátszó használata No comment provided by engineer. @@ -8844,6 +9818,11 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Alkalmazás egy kézzel való használata. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + Használja ezt a címet a közösségi oldalakon használt profiljaiban, weboldalakon vagy az e-mail aláírásában. + No comment provided by engineer. + Use web port Webport használata @@ -8851,7 +9830,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso User selection - Felhasználó kijelölése + Felhasználó kiválasztása No comment provided by engineer. @@ -8864,39 +9843,44 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso SimpleX Chat kiszolgálók használatban. No comment provided by engineer. + + Verify + Ellenőrzés + relay test step + Verify code with desktop - Kód hitelesítése a számítógépen + Kód ellenőrzése a számítógépen No comment provided by engineer. Verify connection - Kapcsolat hitelesítése + Kapcsolat ellenőrzése No comment provided by engineer. Verify connection security - Biztonságos kapcsolat hitelesítése + Biztonságos kapcsolat ellenőrzése No comment provided by engineer. Verify connections - Kapcsolatok hitelesítése + Kapcsolatok ellenőrzése No comment provided by engineer. Verify database passphrase - Az adatbázis jelmondatának hitelesítése + Adatbázis jelmondatának ellenőrzése No comment provided by engineer. Verify passphrase - Jelmondat hitelesítése + Jelmondat ellenőrzése No comment provided by engineer. Verify security code - Biztonsági kód hitelesítése + Biztonsági kód ellenőrzése No comment provided by engineer. @@ -8924,6 +9908,11 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! No comment provided by engineer. + + Videos + Videók + No comment provided by engineer. + Videos and files up to 1gb Videók és fájlok legfeljebb 1GB méretig @@ -8979,6 +9968,21 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Hangüzenet… No comment provided by engineer. + + Wait + Várakozás + alert action + + + Wait response + Várakozás a válaszra + relay test step + + + Waiting for channel owner to add relays. + Várakozás a csatorna tulajdonosára az átjátszók hozzáadásához. + No comment provided by engineer. + Waiting for desktop... Várakozás a számítógép-alkalmazásra… @@ -9019,6 +10023,11 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Figyelmeztetés: néhány adat elveszhet! No comment provided by engineer. + + We made connecting simpler for new users. + Az új felhasználók számára egyszerűbbé tettük a kapcsolatok létrehozását. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICE-kiszolgálók @@ -9069,6 +10078,11 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Ha egy inkognitóprofilt oszt meg valamelyik partnerével, a rendszer ezt az inkognitóprofilt fogja használni azokban a csoportokban, ahová az adott partnere meghívja Önt. No comment provided by engineer. + + Why SimpleX is built. + Miért jött létre a SimpleX? + No comment provided by engineer. + WiFi Wi-Fi @@ -9106,7 +10120,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Without Tor or VPN, your IP address will be visible to these XFTP relays: %@. - Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára: %@. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-átjátszók számára: %@. alert message @@ -9196,16 +10210,21 @@ Repeat join request? Megismétli a csatlakozási kérést? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. + subscription status explanation You are invited to group Ön meghívást kapott a csoportba No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál. @@ -9276,6 +10295,11 @@ Megismétli a csatlakozási kérést? A lezárási képernyő értesítési előnézetét az „Értesítések” menüben állíthatja be. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + Megoszthat egy hivatkozást vagy egy QR-kódot – bárki képes lesz csatlakozni a csatornához. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoporthivatkozást később törli, akkor nem fogja elveszíteni a csoport meglévő tagjait. @@ -9288,7 +10312,7 @@ Megismétli a csatlakozási kérést? You can start chat via app Settings / Database or by restarting the app - A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el + A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával No comment provided by engineer. @@ -9318,17 +10342,26 @@ Megismétli a csatlakozási kérést? You can't send messages! - Nem lehet üzeneteket küldeni! + Ön nem tud üzeneteket küldeni! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + Ön kijelenti, hogy: +- nyilvános csoportokban kizárólag megengedett tartalmakat oszt meg +- tiszteletben tartja a többi felhasználót – nem küld senkinek kéretlen tartalmat + No comment provided by engineer. + + + You connected to the channel via this relay link. + Ön ezen az átjátszóhivatkozáson keresztül kapcsolódott a csatornához. + No comment provided by engineer. + You could not be verified; please try again. - Nem sikerült hitelesíteni; próbálja meg újra. - No comment provided by engineer. - - - You decide who can connect. - Ön dönti el, hogy kivel beszélget. + Nem sikerült ellenőrizni; próbálja meg újra. No comment provided by engineer. @@ -9398,6 +10431,11 @@ Megismétli a kapcsolódási kérést? Ön megkapja az értesítéseket. token info + + You were born without an account + Fiók nélkül születtünk. + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. Csak azután tud üzeneteket küldeni, **miután a kérését elfogadták**. @@ -9433,14 +10471,19 @@ Megismétli a kapcsolódási kérést? Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + Ön nem fog több üzenetet kapni ebből a csatornából. A csevegési előzmények megmaradnak. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. - Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. + Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. No comment provided by engineer. You will stop receiving messages from this group. Chat history will be preserved. - Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. + Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak. No comment provided by engineer. @@ -9478,6 +10521,11 @@ Megismétli a kapcsolódási kérést? Hívások No comment provided by engineer. + + Your channel + Saját csatorna + No comment provided by engineer. + Your chat database Csevegési adatbázis @@ -9515,7 +10563,7 @@ Megismétli a kapcsolódási kérést? Your contact sent a file that is larger than currently supported maximum size (%@). - A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött. + A partnere a jelenleg támogatott legnagyobb (%@) fájlméretnél nagyobbat küldött. No comment provided by engineer. @@ -9525,12 +10573,17 @@ Megismétli a kapcsolódási kérést? Your contacts will remain connected. - A partnerei továbbra is kapcsolódva maradnak. + A partnereivel továbbra is kapcsolatban marad. + No comment provided by engineer. + + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + A beszélgetései Önhöz tartoznak, ahogy az internet megjelenése előtt is mindig így volt. A hálózat nem egy hely, amelyet meglátogat. Ez egy olyan hely, amelyet Ön hoz létre saját magának. És senki sem veheti el Öntől, függetlenül attól, hogy privát vagy nyilvános. No comment provided by engineer. Your credentials may be sent unencrypted. - A hitelesítési adati titkosítatlanul is elküldhetők. + A hitelesítési adatai titkosítatlanul is elküldhetők. No comment provided by engineer. @@ -9548,6 +10601,11 @@ Megismétli a kapcsolódási kérést? Saját csoport No comment provided by engineer. + + Your network + Saját hálózat + No comment provided by engineer. + Your preferences Beállítások @@ -9560,7 +10618,14 @@ Megismétli a kapcsolódási kérést? Your profile - Profil + Saját profil + No comment provided by engineer. + + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + A(z) **%@** nevű profilja meg lesz osztva a csatorna átjátszóival és feliratkozóival. +Az átjátszók hozzáférhetnek a csatornaüzenetekhez. No comment provided by engineer. @@ -9583,14 +10648,29 @@ Megismétli a kapcsolódási kérést? A profilja módosult. Ha menti, akkor a profilfrissítés el lesz küldve a partnerei számára. alert message + + Your public address + Saját nyilvános cím + No comment provided by engineer. + Your random profile Véletlenszerű profil No comment provided by engineer. + + Your relay address + Saját átjátszó címe + No comment provided by engineer. + + + Your relay name + Saját átjátszó neve + No comment provided by engineer. + Your server address - Saját SMP-kiszolgálójának címe + Saját SMP-kiszolgáló címe No comment provided by engineer. @@ -9603,21 +10683,11 @@ Megismétli a kapcsolódási kérést? Beállítások No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Közreműködés](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Küldjön nekünk e-mailt](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Csillagozás a GitHubon](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_dőlt_ @@ -9633,6 +10703,11 @@ Megismétli a kapcsolódási kérést? gombra fent, majd válassza ki: No comment provided by engineer. + + accepted + elfogadva + No comment provided by engineer. + accepted %@ befogadta őt: %@ @@ -9653,6 +10728,11 @@ Megismétli a kapcsolódási kérést? befogadta Önt rcv group event chat item + + active + aktív + No comment provided by engineer. + admin adminisztrátor @@ -9705,7 +10785,7 @@ Megismétli a kapcsolódási kérést? audio call (not e2e encrypted) - hanghívás (nem e2e titkosított) + hanghívás (végpontok között NEM titkosított) No comment provided by engineer. @@ -9764,6 +10844,11 @@ marked deleted chat item preview text hívás… call status + + can't broadcast + nem lehet közvetíteni + No comment provided by engineer. + can't send messages nem lehet üzeneteket küldeni @@ -9799,6 +10884,16 @@ marked deleted chat item preview text cím módosítása… chat item text + + channel + csatorna + shown as sender role for channel messages + + + channel profile updated + csatornaprofil frissítve + snd group event chat item + colored színezett @@ -9806,7 +10901,7 @@ marked deleted chat item preview text complete - befejezett + kész No comment provided by engineer. @@ -9846,7 +10941,7 @@ marked deleted chat item preview text connecting call… - kapcsolódási hívás… + hívás kapcsolása… call status @@ -9881,12 +10976,12 @@ marked deleted chat item preview text contact has e2e encryption - a partner e2e titkosítással rendelkezik + a partner végpontok közötti titkosítással rendelkezik No comment provided by engineer. contact has no e2e encryption - a partner nem rendelkezik e2e titkosítással + a partner nem rendelkezik végpontok közötti titkosítással No comment provided by engineer. @@ -9945,6 +11040,11 @@ pref value törölve deleted chat item + + deleted channel + törölt csatorna + rcv group event chat item + deleted contact törölt partner @@ -9982,7 +11082,7 @@ pref value e2e encrypted - e2e titkosított + végpontok között titkosított No comment provided by engineer. @@ -10042,12 +11142,12 @@ pref value ended - befejeződött + hívás vége No comment provided by engineer. ended call %@ - %@ hívása befejeződött + %@ hívása véget ért call status @@ -10055,11 +11155,21 @@ pref value hiba No comment provided by engineer. + + error: %@ + hiba: %@ + receive error chat item + expired lejárt No comment provided by engineer. + + failed + sikertelen + No comment provided by engineer. + forwarded továbbított @@ -10092,12 +11202,12 @@ pref value iOS Keychain is used to securely store passphrase - it allows receiving push notifications. - Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a leküldéses értesítések fogadását. No comment provided by engineer. iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. - Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a leküldéses értesítések fogadását. No comment provided by engineer. @@ -10180,6 +11290,11 @@ pref value elhagyta a csoportot rcv group event chat item + + link + hivatkozás + No comment provided by engineer. + marked deleted törlésre jelölve @@ -10250,6 +11365,11 @@ pref value soha delete after time + + new + új + No comment provided by engineer. + new message új üzenet @@ -10262,7 +11382,12 @@ pref value no e2e encryption - nincs e2e titkosítás + nincs végpontok közötti titkosítás + No comment provided by engineer. + + + no subscription + nincs feliratkozás No comment provided by engineer. @@ -10350,12 +11475,12 @@ time to disappear received answer… - válasz fogadása… + válasz érkezett… No comment provided by engineer. received confirmation… - visszaigazolás fogadása… + visszaigazolás érkezett… No comment provided by engineer. @@ -10368,6 +11493,11 @@ time to disappear elutasított hívás call status + + relay + átjátszó + member role + removed eltávolítva @@ -10378,6 +11508,16 @@ time to disappear eltávolította őt: %@ rcv group event chat item + + removed (%d attempts) + eltávolítva (%d kísérlet) + receive error chat item + + + removed by operator + az üzemeltető eltávolította + No comment provided by engineer. + removed contact address eltávolította a kapcsolattartási címet @@ -10494,7 +11634,7 @@ utoljára fogadott üzenet: %2$@ starting… - indítás… + hívás indítása… No comment provided by engineer. @@ -10519,7 +11659,7 @@ utoljára fogadott üzenet: %2$@ unknown servers - ismeretlen átjátszók + ismeretlen kiszolgálók No comment provided by engineer. @@ -10532,6 +11672,11 @@ utoljára fogadott üzenet: %2$@ nem védett No comment provided by engineer. + + updated channel profile + frissített csatornaprofil + rcv group event chat item + updated group profile frissítette a csoportprofilt @@ -10552,6 +11697,11 @@ utoljára fogadott üzenet: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + a következőn keresztül: %@ + relay hostname + via contact address link a kapcsolattartási címhivatkozáson keresztül @@ -10569,7 +11719,7 @@ utoljára fogadott üzenet: %2$@ via relay - továbbítókiszolgálón keresztül + átjátszón keresztül No comment provided by engineer. @@ -10579,7 +11729,7 @@ utoljára fogadott üzenet: %2$@ video call (not e2e encrypted) - videóhívás (nem e2e titkosított) + videóhívás (végpontok között NEM titkosított) No comment provided by engineer. @@ -10627,6 +11777,11 @@ utoljára fogadott üzenet: %2$@ Ön megfigyelő No comment provided by engineer. + + you are subscriber + Ön feliratkozó + No comment provided by engineer. + you blocked %@ Ön letiltotta őt: %@ @@ -10687,6 +11842,11 @@ utoljára fogadott üzenet: %2$@ \~áthúzott~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + ⚠️ Nem sikerült ellenőrizni az aláírást: %@. + owner verification + @@ -10701,7 +11861,7 @@ utoljára fogadott üzenet: %2$@ SimpleX needs camera access to scan QR codes to connect to other users and for video calls. - A SimpleXnek kamera-hozzáférésre van szüksége a QR-kódok beolvasásához, hogy kapcsolódhasson más felhasználókhoz és videohívásokhoz. + A SimpleXnek hozzáférésre van szüksége a kamerához a QR-kódok beolvasásához, hogy kapcsolódhasson más felhasználókhoz és videohívásokhoz. Privacy - Camera Usage Description @@ -10716,7 +11876,7 @@ utoljára fogadott üzenet: %2$@ SimpleX needs microphone access for audio and video calls, and to record voice messages. - A SimpleXnek mikrofon-hozzáférésre van szüksége hang- és videohívásokhoz, valamint hangüzenetek rögzítéséhez. + A SimpleXnek hozzáférésre van szüksége a mikrofonhoz a hang- és videohívásokhoz, valamint hangüzenetek rögzítéséhez. Privacy - Microphone Usage Description @@ -10834,12 +11994,12 @@ utoljára fogadott üzenet: %2$@ Comment - Hozzászólás + Megjegyzés No comment provided by engineer. Currently maximum supported file size is %@. - Jelenleg támogatott legnagyobb fájl méret: %@. + Jelenleg támogatott legnagyobb fájlméret: %@. No comment provided by engineer. @@ -10944,7 +12104,7 @@ utoljára fogadott üzenet: %2$@ Selected chat preferences prohibit this message. - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 6ddcf7d6d1..4d9fce7b4f 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -185,6 +185,24 @@ %d mesi time interval + + %d relays failed + %d relay falliti + channel relay bar +channel subscriber relay bar + + + %d relays not active + %d relay non attivi + channel relay bar +channel subscriber relay bar + + + %d relays removed + %d relay rimossi + channel relay bar +channel subscriber relay bar + %d sec %d sec @@ -200,11 +218,63 @@ %d messaggio/i saltato/i integrity error chat item + + %d subscriber + %d iscritto + channel subscriber count + + + %d subscribers + %d iscritti + channel subscriber count + %d weeks %d settimane time interval + + %1$d/%2$d relays active + %1$d/%2$d relay attivo/i + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + %1$d/%2$d relay attivi, %3$d errori + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d relay attivo/i, %3$d fallito/i + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + %1$d/%2$d relay attivi, %3$d rimossi + channel relay bar + + + %1$d/%2$d relays connected + %1$d/%2$d relay connesso/i + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d relay connesso/i, %3$d errori + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d relay connessi, %3$d falliti + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d relay connessi, %3$d rimossi + channel subscriber relay bar + %lld %lld @@ -215,6 +285,11 @@ %lld %@ No comment provided by engineer. + + %lld channel events + %lld eventi del canale + No comment provided by engineer. + %lld contact(s) selected %lld contatto/i selezionato/i @@ -315,11 +390,21 @@ %u messaggi saltati. No comment provided by engineer. + + (from owner) + (dal proprietario) + chat link info line + (new) (nuovo) No comment provided by engineer. + + (signed) + (firmato) + chat link info line + (this device v%@) (questo dispositivo v%@) @@ -365,6 +450,11 @@ **Scansiona / Incolla link**: per connetterti tramite un link che hai ricevuto. No comment provided by engineer. + + **Test relay** to retrieve its name. + **Prova il relay** per recuperare il suo nome. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Attenzione**: le notifiche push istantanee richiedono una password salvata nel portachiavi. @@ -408,6 +498,15 @@ - e altro ancora! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + - scegli se inviare anteprime dei link. +- previeni il phishing dei collegamenti ipertestuali. +- rimuovi il tracciamento dei link. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +605,11 @@ time interval Qualche altra cosa No comment provided by engineer. + + A link for one person to connect + Un link per una persona da connettere + No comment provided by engineer. + A new contact Un contatto nuovo @@ -632,9 +736,9 @@ swipe action Connessioni attive No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Aggiungi l'indirizzo al tuo profilo, in modo che i tuoi contatti possano condividerlo con altre persone. L'aggiornamento del profilo verrà inviato ai tuoi contatti. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. + Aggiungi l'indirizzo al tuo profilo, in modo che i tuoi contatti di SimpleX possano condividerlo con altre persone. L'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX. No comment provided by engineer. @@ -702,6 +806,11 @@ swipe action Server dei messaggi aggiunti No comment provided by engineer. + + Adding relays will be supported later. + L'aggiunta di relay verrà supportata prossimamente. + No comment provided by engineer. + Additional accent Principale aggiuntivo @@ -792,6 +901,11 @@ swipe action Tutti i membri del gruppo resteranno connessi. No comment provided by engineer. + + All messages + Tutti i messaggi + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Tutti i messaggi e i file vengono inviati **crittografati end-to-end**, con sicurezza resistenti alla quantistica nei messaggi diretti. @@ -817,6 +931,16 @@ swipe action Tutti gli profili profile dropdown + + All relays failed + Tutti i relay falliti + No comment provided by engineer. + + + All relays removed + Tutti i relay rimossi + No comment provided by engineer. + All reports will be archived for you. Tutte le segnalazioni verranno archiviate per te. @@ -877,6 +1001,11 @@ swipe action Consenti l'eliminazione irreversibile dei messaggi solo se il contatto la consente a te. (24 ore) No comment provided by engineer. + + Allow members to chat with admins. + Consenti ai membri di chattare con gli amministratori. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Consenti reazioni ai messaggi solo se il tuo contatto le consente. @@ -892,6 +1021,11 @@ swipe action Permetti l'invio di messaggi diretti ai membri. No comment provided by engineer. + + Allow sending direct messages to subscribers. + Permetti l'invio di messaggi diretti agli iscritti. + No comment provided by engineer. + Allow sending disappearing messages. Permetti l'invio di messaggi a tempo. @@ -902,6 +1036,11 @@ swipe action Consenti la condivisione No comment provided by engineer. + + Allow subscribers to chat with admins. + Consenti agli iscritti di chattare con gli amministratori. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Permetti di eliminare irreversibilmente i messaggi inviati. (24 ore) @@ -1007,11 +1146,6 @@ swipe action Rispondi alla chiamata No comment provided by engineer. - - Anybody can host servers. - Chiunque può installare i server. - No comment provided by engineer. - App build: %@ Build dell'app: %@ @@ -1142,6 +1276,11 @@ swipe action Chiamate audio e video No comment provided by engineer. + + Audio call + Chiamata audio + No comment provided by engineer. + Audio/video calls Chiamate audio/video @@ -1184,7 +1323,7 @@ swipe action Auto-accept images - Auto-accetta le immagini + Accetta automaticamente le immagini No comment provided by engineer. @@ -1212,6 +1351,23 @@ swipe action Hash del messaggio errato No comment provided by engineer. + + Be free +in your network + Vivi libero +nella tua rete + No comment provided by engineer. + + + Be free in your network. + Vivi libero nella tua rete. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Perché abbiamo distrutto il potere di sapere chi sei. In modo che il tuo potere non possa mai esserti sottratto. + No comment provided by engineer. + Better calls Chiamate migliorate @@ -1307,6 +1463,11 @@ swipe action Bloccare il membro? No comment provided by engineer. + + Block subscriber for all? + Bloccare l'iscritto per tutti? + No comment provided by engineer. + Blocked by admin Bloccato dall'amministratore @@ -1357,6 +1518,16 @@ swipe action Sia tu che il tuo contatto potete inviare messaggi vocali. No comment provided by engineer. + + Bottom bar + Barra inferiore + No comment provided by engineer. + + + Broadcast + Trasmetti + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bulgaro, finlandese, tailandese e ucraino - grazie agli utenti e a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1365,7 +1536,7 @@ swipe action Business address Indirizzo di lavoro - No comment provided by engineer. + chat link info line Business chats @@ -1387,15 +1558,6 @@ swipe action Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - Usando SimpleX Chat accetti di: -- inviare solo contenuto legale nei gruppi pubblici. -- rispettare gli altri utenti - niente spam. - No comment provided by engineer. - Call already ended! Chiamata già terminata! @@ -1544,6 +1706,82 @@ new chat action authentication reason set passcode view + + Channel + Canale + No comment provided by engineer. + + + Channel display name + Nome da mostrare del canale + No comment provided by engineer. + + + Channel full name (optional) + Nome completo del canale (facoltativo) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + Il canale non ha relay attivi. Prova a iscriverti più tardi. + alert message +alert subtitle + + + Channel image + Immagine del canale + No comment provided by engineer. + + + Channel link + Link del canale + chat link info line + + + Channel preferences + Preferenze del canale + No comment provided by engineer. + + + Channel profile + Profilo del canale + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + Il profilo del canale è memorizzato sui dispositivi degli iscritti e sui relay di chat. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + Il profilo del canale è stato cambiato. Se lo salvi, il profilo aggiornato verrà inviato agli iscritti di canale. + alert message + + + Channel temporarily unavailable + Canale non disponibile temporaneamente + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + Il canale verrà eliminato per tutti gli iscritti, non è reversibile! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + Il canale verrà eliminato per te, non è reversibile! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + Il canale sarà operativo con %1$d di %2$d relay. Procedere? + alert message + + + Channels + Canali + No comment provided by engineer. + Chat Chat @@ -1629,6 +1867,26 @@ set passcode view Profilo utente No comment provided by engineer. + + Chat relay + Relay di chat + No comment provided by engineer. + + + Chat relays + Relay di chat + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + I relay di chat inoltrano i messaggi nei canali che crei. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + I relay di chat inoltrano i messaggi agli iscritti del canale. + No comment provided by engineer. + Chat theme Tema della chat @@ -1647,7 +1905,8 @@ set passcode view Chat with admins Chat con amministratori - chat toolbar + chat feature +chat toolbar Chat with member @@ -1664,11 +1923,26 @@ set passcode view Chat No comment provided by engineer. + + Chats with admins are prohibited. + Le chat con gli amministratori sono vietate. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + Le chat con amministratori in canali pubblici non hanno crittografia E2E: usale solo con relay di chat fidati. + alert message + Chats with members Chat con membri No comment provided by engineer. + + Chats with members are disabled + Le chat con i membri sono disattivate + No comment provided by engineer. + Check messages every 20 min. Controlla i messaggi ogni 20 min. @@ -1679,6 +1953,16 @@ set passcode view Controlla i messaggi quando consentito. No comment provided by engineer. + + Check relay address and try again. + Controlla l'indirizzo del relay e riprova. + alert message + + + Check relay name and try again. + Controlla il nome del relay e riprova. + alert message + Check server address and try again. Controlla l'indirizzo del server e riprova. @@ -1802,7 +2086,7 @@ set passcode view Conditions of use Condizioni d'uso - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1824,9 +2108,9 @@ set passcode view Configura server ICE No comment provided by engineer. - - Configure server operators - Configura gli operatori dei server + + Configure relays + Configura i relay No comment provided by engineer. @@ -1887,7 +2171,8 @@ set passcode view Connect Connetti - server test step + relay test step +server test step Connect automatically @@ -1933,6 +2218,11 @@ Questo è il tuo link una tantum! Connetti via link new chat sheet title + + Connect via link or QR code + Connetti via link o codice QR + No comment provided by engineer. + Connect via one-time link Connetti via link una tantum @@ -2011,6 +2301,11 @@ Questo è il tuo link una tantum! Connection error (AUTH) Errore di connessione (AUTH) + conn error description + + + Connection failed + Connessione fallita No comment provided by engineer. @@ -2065,6 +2360,11 @@ Questo è il tuo link una tantum! Connessioni No comment provided by engineer. + + Contact address + Indirizzo di contatto + chat link info line + Contact allows Il contatto lo consente @@ -2135,6 +2435,11 @@ Questo è il tuo link una tantum! Continua No comment provided by engineer. + + Contribute + Contribuisci + No comment provided by engineer. + Conversation deleted! Conversazione eliminata! @@ -2163,12 +2468,7 @@ Questo è il tuo link una tantum! Correct name to %@? Correggere il nome a %@? - No comment provided by engineer. - - - Create - Crea - No comment provided by engineer. + alert message Create 1-time link @@ -2220,6 +2520,16 @@ Questo è il tuo link una tantum! Crea profilo No comment provided by engineer. + + Create public channel + Crea canale pubblico + No comment provided by engineer. + + + Create public channel (BETA) + Crea canale pubblico (BETA) + No comment provided by engineer. + Create queue Crea coda @@ -2230,11 +2540,21 @@ Questo è il tuo link una tantum! Crea il tuo indirizzo No comment provided by engineer. + + Create your link + Connettiti con qualcuno + No comment provided by engineer. + Create your profile Crea il tuo profilo No comment provided by engineer. + + Create your public address + Crea il tuo indirizzo pubblico + No comment provided by engineer. + Created Creato @@ -2255,6 +2575,11 @@ Questo è il tuo link una tantum! Creazione link dell'archivio No comment provided by engineer. + + Creating channel + Creazione canale + No comment provided by engineer. + Creating link… Creazione link… @@ -2413,10 +2738,10 @@ Questo è il tuo link una tantum! Debug della consegna No comment provided by engineer. - - Decentralized - Decentralizzato - No comment provided by engineer. + + Decode link + Decodifica il link + relay test step Decryption error @@ -2464,6 +2789,16 @@ swipe action Elimina e avvisa il contatto No comment provided by engineer. + + Delete channel + Elimina canale + No comment provided by engineer. + + + Delete channel? + Eliminare il canale? + No comment provided by engineer. + Delete chat Elimina chat @@ -2579,6 +2914,16 @@ swipe action Eliminare il messaggio del membro? No comment provided by engineer. + + Delete member messages + Elimina i messaggi del membro + No comment provided by engineer. + + + Delete member messages? + Eliminare i messaggi del membro? + alert title + Delete message? Eliminare il messaggio? @@ -2587,7 +2932,8 @@ swipe action Delete messages Elimina messaggi - alert button + alert action +alert button Delete messages after @@ -2624,6 +2970,11 @@ swipe action Elimina coda server test step + + Delete relay + Elimina relay + No comment provided by engineer. + Delete report Elimina la segnalazione @@ -2789,6 +3140,16 @@ swipe action I messaggi diretti tra i membri sono vietati in questo gruppo. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + I messaggi diretti tra gli iscritti sono vietati. + No comment provided by engineer. + + + Disable + Disattiva + alert button + Disable (keep overrides) Disattiva (mantieni sostituzioni) @@ -2894,6 +3255,11 @@ swipe action Non inviare la cronologia ai nuovi membri. No comment provided by engineer. + + Do not send history to new subscribers. + Non inviare la cronologia ai nuovi iscritti. + No comment provided by engineer. + Do not use credentials with proxy. Non usare credenziali con proxy. @@ -2995,11 +3361,21 @@ chat item action Notifiche crittografate E2E. No comment provided by engineer. + + Easier to invite your friends 👋 + È più facile invitare i tuoi amici 👋 + No comment provided by engineer. + Edit Modifica chat item action + + Edit channel profile + Modifica profilo canale + No comment provided by engineer. + Edit group profile Modifica il profilo del gruppo @@ -3013,7 +3389,7 @@ chat item action Enable Attiva - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3035,6 +3411,11 @@ chat item action Attiva il keep-alive TCP No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + Attiva almeno un relay di chat in "Rete e server". + channel creation warning + Enable automatic message deletion? Attivare l'eliminazione automatica dei messaggi? @@ -3045,6 +3426,11 @@ chat item action Attiva l'accesso alla fotocamera No comment provided by engineer. + + Enable chats with admins? + Attivare le chat con gli amministratori? + alert title + Enable disappearing messages by default. Attiva i messaggi a tempo in modo predefinito. @@ -3065,16 +3451,16 @@ chat item action Attivare le notifiche istantanee? No comment provided by engineer. + + Enable link previews? + Attivare le anteprime dei link? + alert title + Enable lock Attiva blocco No comment provided by engineer. - - Enable notifications - Attiva le notifiche - No comment provided by engineer. - Enable periodic notifications? Attivare le notifiche periodiche? @@ -3180,6 +3566,11 @@ chat item action Inserisci il codice di accesso No comment provided by engineer. + + Enter channel name… + Inserisci il nome del canale… + No comment provided by engineer. + Enter correct passphrase. Inserisci la password giusta. @@ -3205,6 +3596,16 @@ chat item action Inserisci la password sopra per mostrare! No comment provided by engineer. + + Enter profile name... + Inserisci nome profilo... + No comment provided by engineer. + + + Enter relay name… + Inserisci il nome del relay… + No comment provided by engineer. + Enter server manually Inserisci il server a mano @@ -3233,7 +3634,7 @@ chat item action Error Errore - No comment provided by engineer. + conn error description Error aborting address change @@ -3260,6 +3661,11 @@ chat item action Errore di aggiunta membro/i No comment provided by engineer. + + Error adding relay + Errore di aggiunta del relay + alert title + Error adding server Errore di aggiunta del server @@ -3310,11 +3716,21 @@ chat item action Errore di connessione al server di inoltro %@. Riprova più tardi. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Errore di connessione al server usato per ricevere messaggi da questa connessione: %@ + subscription status explanation + Error creating address Errore nella creazione dell'indirizzo No comment provided by engineer. + + Error creating channel + Errore di creazione del canale + alert title + Error creating group Errore nella creazione del gruppo @@ -3450,11 +3866,6 @@ chat item action Errore di apertura della chat No comment provided by engineer. - - Error opening group - Errore di preparazione del gruppo - No comment provided by engineer. - Error receiving file Errore nella ricezione del file @@ -3500,6 +3911,11 @@ chat item action Errore nel salvataggio dei server ICE No comment provided by engineer. + + Error saving channel profile + Errore di salvataggio del profilo del canale + No comment provided by engineer. + Error saving chat list Errore nel salvataggio dell'elenco di chat @@ -3565,6 +3981,11 @@ chat item action Errore nell'impostazione delle ricevute di consegna! No comment provided by engineer. + + Error sharing channel + Errore nella condivisione del canale + alert title + Error starting chat Errore di avvio della chat @@ -3644,7 +4065,9 @@ snd error text Error: %@. - server test error + Errore: %@. + relay test error +server test error Error: URL is invalid @@ -3845,6 +4268,11 @@ snd error text File e contenuti multimediali vietati! No comment provided by engineer. + + Filter + Filtro + No comment provided by engineer. + Filter unread and favorite chats. Filtra le chat non lette e preferite. @@ -3872,19 +4300,23 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server di destinazione non corrisponde al certificato: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server di inoltro non corrisponde al certificato: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Probabilmente l'impronta del certificato nell'indirizzo del server è sbagliata - server test error + L'impronta digitale nell'indirizzo del server non corrisponde al certificato. + relay test error +server test error Fingerprint in server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server non corrisponde al certificato: %@. No comment provided by engineer. @@ -3922,10 +4354,16 @@ snd error text Per tutti i moderatori No comment provided by engineer. + + For anyone to reach you + Per chiunque debba raggiungerti + No comment provided by engineer. + For chat profile %@: Per il profilo di chat %@: - servers error + servers error +servers warning For console @@ -4066,11 +4504,21 @@ Errore: %2$@ GIF e adesivi No comment provided by engineer. + + Get link + Ottieni link + relay test step + Get notified when mentioned. Ricevi una notifica quando menzionato. No comment provided by engineer. + + Get started + Cominciamo + No comment provided by engineer. + Good afternoon! Buon pomeriggio! @@ -4129,7 +4577,7 @@ Errore: %2$@ Group link Link del gruppo - No comment provided by engineer. + chat link info line Group links @@ -4241,6 +4689,11 @@ Errore: %2$@ La cronologia non viene inviata ai nuovi membri. No comment provided by engineer. + + History is not sent to new subscribers. + La cronologia non viene inviata ai nuovi iscritti. + No comment provided by engineer. + How SimpleX works Come funziona SimpleX @@ -4306,6 +4759,11 @@ Errore: %2$@ Se inserisci il tuo codice di autodistruzione mentre apri l'app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Se devi usare la chat adesso, tocca **Fallo più tardi** qui sotto (ti verrà offerto di migrare il database quando riavvii l'app). @@ -4326,16 +4784,16 @@ Errore: %2$@ L'immagine verrà ricevuta quando il tuo contatto sarà in linea, aspetta o controlla più tardi! No comment provided by engineer. + + Images + Immagini + No comment provided by engineer. + Immediately Immediatamente No comment provided by engineer. - - Immune to spam - Immune a spam e abusi - No comment provided by engineer. - Import Importa @@ -4478,9 +4936,9 @@ Altri miglioramenti sono in arrivo! Ruolo iniziale No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Installa [Simplex Chat per terminale](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Installa Simplex Chat per terminale No comment provided by engineer. @@ -4538,7 +4996,7 @@ Altri miglioramenti sono in arrivo! Invalid connection link Link di connessione non valido - No comment provided by engineer. + conn error description Invalid display name! @@ -4558,7 +5016,17 @@ Altri miglioramenti sono in arrivo! Invalid name! Nome non valido! - No comment provided by engineer. + alert title + + + Invalid relay address! + Indirizzo del relay non valido! + alert title + + + Invalid relay name! + Nome del relay non valido! + alert title Invalid response @@ -4585,11 +5053,21 @@ Altri miglioramenti sono in arrivo! Invita amici No comment provided by engineer. + + Invite member + Invita membro + No comment provided by engineer. + Invite members Invita membri No comment provided by engineer. + + Invite someone privately + Invita qualcuno in modo privato + No comment provided by engineer. + Invite to chat Invita in chat @@ -4666,6 +5144,11 @@ Altri miglioramenti sono in arrivo! entra come %@ No comment provided by engineer. + + Join channel + Iscriviti al canale + No comment provided by engineer. + Join group Entra nel gruppo @@ -4753,6 +5236,16 @@ Questo è il tuo link per il gruppo %@! Esci swipe action + + Leave channel + Esci dal canale + No comment provided by engineer. + + + Leave channel? + Uscire dal canale? + No comment provided by engineer. + Leave chat Esci dalla chat @@ -4778,6 +5271,11 @@ Questo è il tuo link per il gruppo %@! Meno traffico sulle reti mobili. No comment provided by engineer. + + Let someone connect to you + Lascia che qualcuno si connetta a te + No comment provided by engineer. + Let's talk in SimpleX Chat Parliamo in SimpleX Chat @@ -4798,6 +5296,11 @@ Questo è il tuo link per il gruppo %@! Collega le app mobile e desktop! 🔗 No comment provided by engineer. + + Link signature verified. + Firma del link verificata. + owner verification + Linked desktop options Opzioni del desktop collegato @@ -4808,6 +5311,11 @@ Questo è il tuo link per il gruppo %@! Desktop collegati No comment provided by engineer. + + Links + Link + No comment provided by engineer. + List Elenco @@ -4933,6 +5441,11 @@ Questo è il tuo link per il gruppo %@! Il membro è eliminato - impossibile accettare la richiesta No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + I messaggi del membro verranno eliminati. Non è reversibile! + alert message + Member reports Segnalazioni dei membri @@ -4956,12 +5469,12 @@ Questo è il tuo link per il gruppo %@! Member will be removed from chat - this cannot be undone! Il membro verrà rimosso dalla chat, non è reversibile! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Il membro verrà rimosso dal gruppo, non è reversibile! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4973,6 +5486,11 @@ Questo è il tuo link per il gruppo %@! I membri del gruppo possono aggiungere reazioni ai messaggi. No comment provided by engineer. + + Members can chat with admins. + I membri possono chattare con gli amministratori. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) I membri del gruppo possono eliminare irreversibilmente i messaggi inviati. (24 ore) @@ -5038,6 +5556,11 @@ Questo è il tuo link per il gruppo %@! Bozza del messaggio No comment provided by engineer. + + Message error + Errore del messaggio + No comment provided by engineer. + Message forwarded Messaggio inoltrato @@ -5133,6 +5656,16 @@ Questo è il tuo link per il gruppo %@! I messaggi da %@ verranno mostrati! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + I messaggi in questo canale **non sono crittografati end-to-end**. I relay di chat possono vedere questi messaggi. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + I messaggi in questo canale non sono crittografati end-to-end. I relay di chat possono vedere questi messaggi. + E2EE info chat item + Messages in this chat will never be deleted. I messaggi in questa chat non verranno mai eliminati. @@ -5163,16 +5696,16 @@ Questo è il tuo link per il gruppo %@! I messaggi, i file e le chiamate sono protetti da **crittografia e2e resistente alla quantistica** con perfect forward secrecy, ripudio e recupero da intrusione. No comment provided by engineer. + + Migrate + Migra + No comment provided by engineer. + Migrate device Migra dispositivo No comment provided by engineer. - - Migrate from another device - Migra da un altro dispositivo - No comment provided by engineer. - Migrate here Migra qui @@ -5293,6 +5826,11 @@ Questo è il tuo link per il gruppo %@! Rete e server No comment provided by engineer. + + Network commitments + Impegni sulla rete + No comment provided by engineer. + Network connection Connessione di rete @@ -5303,6 +5841,11 @@ Questo è il tuo link per il gruppo %@! Decentralizzazione della rete No comment provided by engineer. + + Network error + Errore di rete + conn error description + Network issues - message expired after many attempts to send it. Problemi di rete - messaggio scaduto dopo molti tentativi di inviarlo. @@ -5318,6 +5861,13 @@ Questo è il tuo link per il gruppo %@! Operatore di rete No comment provided by engineer. + + Network routers cannot know +who talks to whom + Gli instradatori di rete non possono +sapere chi parla con chi + No comment provided by engineer. + Network settings Impostazioni di rete @@ -5326,13 +5876,18 @@ Questo è il tuo link per il gruppo %@! Network status Stato della rete - No comment provided by engineer. + alert title New Nuovo token status text + + New 1-time link + Nuovo link una tantum + No comment provided by engineer. + New Passcode Nuovo codice di accesso @@ -5358,6 +5913,11 @@ Questo è il tuo link per il gruppo %@! Una nuova esperienza di chat 🎉 No comment provided by engineer. + + New chat relay + Nuovo relay di chat + No comment provided by engineer. + New contact request Nuova richiesta di contatto @@ -5428,11 +5988,33 @@ Questo è il tuo link per il gruppo %@! No No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + Nessun account. Nessun telefono. Nessuna email. Nessun identificatore. +La crittografia più sicura. + No comment provided by engineer. + + + No active relays + Nessun relay attivo + No comment provided by engineer. + No app password Nessuna password dell'app Authentication unavailable + + No chat relays + Nessun relay di chat + No comment provided by engineer. + + + No chat relays enabled. + Nessun relay di chat attivato. + servers warning + No chats Nessuna chat @@ -5578,11 +6160,26 @@ Questo è il tuo link per il gruppo %@! Nessuna chat non letta No comment provided by engineer. - - No user identifiers. - Nessun identificatore utente. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + Nessuno monitorava le tue conversazioni. Nessuno disegnava una mappa delle tue posizioni. La privacy non era mai stata una caratteristica, era uno stile di vita. No comment provided by engineer. + + Non-profit governance + Organizzazione non a scopo di lucro + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + Non una serratura migliore sulla porta di qualcun altro. Non un padrone di casa più gentile che rispetta la tua privacy, ma che continua a tenere traccia di tutti i visitatori. Non sei un ospite. Sei a casa tua. Nessun re può entrarvi: sei tu il sovrano. + No comment provided by engineer. + + + Not all relays connected + Non tutti i relay sono connessi + alert title + Not compatible! Non compatibile! @@ -5640,7 +6237,7 @@ Questo è il tuo link per il gruppo %@! OK OK - No comment provided by engineer. + alert button Off @@ -5659,11 +6256,21 @@ new chat action Database vecchio No comment provided by engineer. + + On your phone, not on servers. + Sul tuo telefono, non sui server. + No comment provided by engineer. + One-time invitation link Link di invito una tantum No comment provided by engineer. + + One-time link + Link una tantum + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5683,6 +6290,11 @@ Richiede l'attivazione della VPN. Gli host Onion non verranno usati. No comment provided by engineer. + + Only channel owners can change channel preferences. + Solo i proprietari del canale possono modificarne le preferenze. + No comment provided by engineer. + Only chat owners can change preferences. Solo i proprietari della chat possono modificarne le preferenze. @@ -5786,7 +6398,8 @@ Richiede l'attivazione della VPN. Open Apri - alert action + alert action +alert button Open Settings @@ -5798,6 +6411,11 @@ Richiede l'attivazione della VPN. Apri le modifiche No comment provided by engineer. + + Open channel + Apri canale + new chat action + Open chat Apri chat @@ -5818,6 +6436,11 @@ Richiede l'attivazione della VPN. Apri le condizioni No comment provided by engineer. + + Open external link? + Aprire il link esterno? + alert title + Open full link Apri link completo @@ -5838,6 +6461,11 @@ Richiede l'attivazione della VPN. Apri migrazione ad un altro dispositivo authentication reason + + Open new channel + Apri un canale nuovo + new chat action + Open new chat Apri una chat nuova @@ -5845,7 +6473,7 @@ Richiede l'attivazione della VPN. Open new group - Apri un gruppo nuovo + Apri il nuovo gruppo new chat action @@ -5883,14 +6511,25 @@ Richiede l'attivazione della VPN. Server dell'operatore alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + Gli operatori si impegnano a: +- Essere indipendenti +- Minimizzare l'uso di metadati +- Eseguire codice open source verificato + No comment provided by engineer. + Or import archive file - O importa file archivio + O importa un file dell'archivio No comment provided by engineer. Or paste archive link - O incolla il link dell'archivio + O incolla un link dell'archivio No comment provided by engineer. @@ -5903,6 +6542,11 @@ Richiede l'attivazione della VPN. O condividi in modo sicuro questo link del file No comment provided by engineer. + + Or show QR in person or via video call. + O mostra il QR di persona o via videochiamata. + No comment provided by engineer. + Or show this code O mostra questo codice @@ -5913,6 +6557,11 @@ Richiede l'attivazione della VPN. O per condividere in modo privato No comment provided by engineer. + + Or use this QR - print or show online. + O usa questo QR: stampalo o mostralo online. + No comment provided by engineer. + Organize chats into lists Organizza le chat in elenchi @@ -5930,6 +6579,21 @@ Richiede l'attivazione della VPN. %@ alert message + + Owner + Proprietario + No comment provided by engineer. + + + Owners + Proprietari + No comment provided by engineer. + + + Ownership: you can run your own relays. + Proprietà: puoi gestire i tuoi relay personali. + No comment provided by engineer. + PING count Conteggio PING @@ -5985,6 +6649,11 @@ Richiede l'attivazione della VPN. Incolla immagine No comment provided by engineer. + + Paste link / Scan + Incolla link / Scansiona + No comment provided by engineer. + Paste link to connect! Incolla un link per connettere! @@ -6139,6 +6808,16 @@ Errore: %@ Conserva la bozza dell'ultimo messaggio, con gli allegati. No comment provided by engineer. + + Preset relay address + Indirizzo relay preimpostato + No comment provided by engineer. + + + Preset relay name + Nome relay preimpostato + No comment provided by engineer. + Preset server address Indirizzo server preimpostato @@ -6174,14 +6853,14 @@ Errore: %@ Informativa sulla privacy e condizioni d'uso. No comment provided by engineer. - - Privacy redefined - Privacy ridefinita + + Privacy: for owners and subscribers. + Privacy: per i proprietari e gli iscritti. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server. + + Private and secure messaging. + Messaggistica privata e sicura. No comment provided by engineer. @@ -6224,6 +6903,11 @@ Errore: %@ Scadenza dell'instradamento privato alert title + + Proceed + Procedi + alert action + Profile and server connections Profilo e connessioni al server @@ -6249,9 +6933,9 @@ Errore: %@ Tema del profilo No comment provided by engineer. - - Profile update will be sent to your contacts. - L'aggiornamento del profilo verrà inviato ai tuoi contatti. + + Profile update will be sent to your SimpleX contacts. + L'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX. alert message @@ -6259,6 +6943,11 @@ Errore: %@ Proibisci le chiamate audio/video. No comment provided by engineer. + + Prohibit chats with admins. + Vieta le chat con gli amministratori. + No comment provided by engineer. + Prohibit irreversible message deletion. Proibisci l'eliminazione irreversibile dei messaggi. @@ -6289,6 +6978,11 @@ Errore: %@ Proibisci l'invio di messaggi diretti ai membri. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + Proibisci l'invio di messaggi diretti agli iscritti. + No comment provided by engineer. + Prohibit sending disappearing messages. Proibisci l'invio di messaggi a tempo. @@ -6356,6 +7050,11 @@ Attivalo nelle impostazioni *Rete e server*. Il proxy richiede una password No comment provided by engineer. + + Public channels - speak freely 🚀 + Canali pubblici - parla liberamente 🚀 + No comment provided by engineer. + Push notifications Notifiche push @@ -6396,24 +7095,14 @@ Attivalo nelle impostazioni *Rete e server*. Leggi tutto No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Leggi di più nella [Guida utente](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Leggi di più nella Guida utente. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Maggiori informazioni nella [Guida per l'utente](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Maggiori informazioni nella [Guida per l'utente](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Maggiori informazioni nel nostro [repository GitHub](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Maggiori informazioni nel nostro repository GitHub. No comment provided by engineer. @@ -6436,11 +7125,6 @@ Attivalo nelle impostazioni *Rete e server*. Ricevuto il: %@ copied message info - - Received file event - Evento file ricevuto - notification - Received message Messaggio ricevuto @@ -6578,6 +7262,31 @@ swipe action Rifiutare il membro? alert title + + Relay + Relay + No comment provided by engineer. + + + Relay address + Indirizzo del relay + alert title + + + Relay connection failed + Connessione del relay fallita + alert title + + + Relay link + Link del relay + No comment provided by engineer. + + + Relay results: + Risultati relay: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Il server relay viene usato solo se necessario. Un altro utente può osservare il tuo indirizzo IP. @@ -6588,10 +7297,25 @@ swipe action Il server relay protegge il tuo indirizzo IP, ma può osservare la durata della chiamata. No comment provided by engineer. + + Relay test failed! + Prova del relay fallita! + No comment provided by engineer. + + + Reliability: many relays per channel. + Affidabilità: relay multipli per canale. + No comment provided by engineer. + Remove Rimuovi - No comment provided by engineer. + alert action + + + Remove and delete messages + Rimuovi ed elimina i messaggi + alert action Remove archive? @@ -6616,13 +7340,23 @@ swipe action Remove member? Rimuovere il membro? - No comment provided by engineer. + alert title Remove passphrase from keychain? Rimuovere la password dal portachiavi? No comment provided by engineer. + + Remove subscriber + Rimuovi iscritto + No comment provided by engineer. + + + Remove subscriber? + Rimuovere l'iscritto? + alert title + Removes messages and blocks members. Rimuove i messaggi e blocca i membri. @@ -6858,6 +7592,11 @@ swipe action Proxy SOCKS No comment provided by engineer. + + Safe web links + Link web sicuri + No comment provided by engineer. + Safely receive files Ricevi i file in sicurezza @@ -6881,7 +7620,12 @@ chat item action Save (and notify members) - Salva (e informa i membri) + Salva (e avvisa i membri) + alert button + + + Save (and notify subscribers) + Salva (e avvisa gli iscritti) alert button @@ -6899,6 +7643,11 @@ chat item action Salva e avvisa i membri del gruppo No comment provided by engineer. + + Save and notify subscribers + Salva e avvisa gli iscritti + No comment provided by engineer. + Save and reconnect Salva e riconnetti @@ -6909,6 +7658,16 @@ chat item action Salva e aggiorna il profilo del gruppo No comment provided by engineer. + + Save channel profile + Salva il profilo del canale + No comment provided by engineer. + + + Save channel profile? + Salva il profilo del canale? + alert title + Save group profile Salva il profilo del gruppo @@ -7001,7 +7760,7 @@ chat item action Scan QR code - Scansiona codice QR + Scansiona un codice QR No comment provided by engineer. @@ -7034,11 +7793,36 @@ chat item action La barra di ricerca accetta i link di invito. No comment provided by engineer. + + Search files + Cerca file + No comment provided by engineer. + + + Search images + Cerca immagini + No comment provided by engineer. + + + Search links + Cerca link + No comment provided by engineer. + Search or paste SimpleX link Cerca o incolla un link SimpleX No comment provided by engineer. + + Search videos + Cerca video + No comment provided by engineer. + + + Search voice messages + Cerca messaggi vocali + No comment provided by engineer. + Secondary Secondario @@ -7064,6 +7848,11 @@ chat item action Codice di sicurezza No comment provided by engineer. + + Security: owners hold channel keys. + Sicurezza: solo i proprietari hanno le chiavi del canale. + No comment provided by engineer. + Select Seleziona @@ -7194,6 +7983,11 @@ chat item action Invia richiesta senza messaggio No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + Invia il link tramite qualsiasi messenger, è sicuro. Chiedi di incollarlo in SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Inviali dalla galleria o dalle tastiere personalizzate. @@ -7204,6 +7998,11 @@ chat item action Invia fino a 100 ultimi messaggi ai nuovi membri. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + Invia fino a 100 ultimi messaggi ai nuovi iscritti. + No comment provided by engineer. + Send your private feedback to groups. Invia i tuoi commenti privati ai gruppi. @@ -7219,6 +8018,11 @@ chat item action Il mittente potrebbe aver eliminato la richiesta di connessione. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + L'invio di un'anteprima del link può rivelare il tuo indirizzo IP al sito. Puoi modificarlo nelle impostazioni di Privacy più tardi. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. L'invio delle ricevute di consegna sarà attivo per tutti i contatti in tutti i profili di chat visibili. @@ -7274,11 +8078,6 @@ chat item action Inviato direttamente No comment provided by engineer. - - Sent file event - Evento file inviato - notification - Sent message Messaggio inviato @@ -7349,14 +8148,19 @@ chat item action Il protocollo del server è cambiato. alert title + + Server requires authorization to connect to relay, check password. + Il server richiede l'autorizzazione per connettersi al relay, controlla la password. + relay test error + Server requires authorization to create queues, check password. - Il server richiede l'autorizzazione di creare code, controlla la password + Il server richiede l'autorizzazione di creare code, controlla la password. server test error Server requires authorization to upload, check password. - Il server richiede l'autorizzazione per il caricamento, controllare la password + Il server richiede l'autorizzazione per l'invio, controlla la password. server test error @@ -7479,6 +8283,16 @@ chat item action Le impostazioni sono state cambiate. alert message + + Setup notifications + Configura le notifiche + No comment provided by engineer. + + + Setup routers + Configura gli instradatori + No comment provided by engineer. + Shape profile images Forma delle immagini del profilo @@ -7515,11 +8329,16 @@ chat item action Condividi indirizzo pubblicamente No comment provided by engineer. - - Share address with contacts? - Condividere l'indirizzo con i contatti? + + Share address with SimpleX contacts? + Condividere l'indirizzo con i contatti di SimpleX? alert title + + Share channel + Condividi canale + No comment provided by engineer. + Share from other apps. Condividi da altre app. @@ -7545,6 +8364,11 @@ chat item action Condividi il profilo No comment provided by engineer. + + Share relay address + Condividi l'indirizzo del relay + No comment provided by engineer. + Share this 1-time invite link Condividi questo link di invito una tantum @@ -7555,9 +8379,14 @@ chat item action Condividi in SimpleX No comment provided by engineer. - - Share with contacts - Condividi con i contatti + + Share via chat + Condividi via chat + No comment provided by engineer. + + + Share with SimpleX contacts + Condividi con i contatti di SimpleX No comment provided by engineer. @@ -7730,9 +8559,9 @@ chat item action Protocolli di SimpleX esaminati da Trail of Bits. No comment provided by engineer. - - SimpleX relay link - Link del relay SimpleX + + SimpleX relay address + Indirizzo del relay SimpleX simplex link type @@ -7808,6 +8637,11 @@ report reason Quadrata, circolare o qualsiasi forma tra le due. No comment provided by engineer. + + Star on GitHub + Dai una stella su GitHub + No comment provided by engineer. + Start chat Avvia chat @@ -7905,9 +8739,81 @@ report reason Subscribed + Iscritto/a + No comment provided by engineer. + + + Subscriber Iscritto No comment provided by engineer. + + Subscriber reports + Segnalazioni degli iscritti + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + L'iscritto verrà rimosso dal canale, non è reversibile! + alert message + + + Subscribers + Iscritti + No comment provided by engineer. + + + Subscribers can add message reactions. + Gli iscritti al canale possono aggiungere reazioni ai messaggi. + No comment provided by engineer. + + + Subscribers can chat with admins. + Gli iscritti possono chattare con gli amministratori. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + Gli iscritti al canale possono eliminare irreversibilmente i messaggi inviati. (24 ore) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + Gli iscritti possono segnalare messaggi ai moderatori. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + Gli iscritti al canale possono inviare link di Simplex. + No comment provided by engineer. + + + Subscribers can send direct messages. + Gli iscritti al canale possono inviare messaggi diretti. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + Gli iscritti al canale possono inviare messaggi a tempo. + No comment provided by engineer. + + + Subscribers can send files and media. + Gli iscritti al canale possono inviare file e contenuti multimediali. + No comment provided by engineer. + + + Subscribers can send voice messages. + Gli iscritti al canale possono inviare messaggi vocali. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + Gli iscritti usano il link del relay per connettersi al canale. +L'indirizzo del relay è stato usato per impostare questo relay per il canale. + No comment provided by engineer. + Subscription errors Errori di iscrizione @@ -7988,6 +8894,11 @@ report reason Scatta foto No comment provided by engineer. + + Talk to someone + Parla con qualcuno + No comment provided by engineer. + Tap Connect to chat Tocca Connetti per chattare @@ -8003,9 +8914,9 @@ report reason Tocca Connetti per usare il bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Tocca Crea indirizzo SimpleX nel menu per crearlo più tardi. + + Tap Join channel + Tocca Iscriviti al canale No comment provided by engineer. @@ -8038,6 +8949,11 @@ report reason Toccare per entrare in incognito No comment provided by engineer. + + Tap to open + Tocca per aprire + No comment provided by engineer. + Tap to paste link Tocca per incollare il link @@ -8056,13 +8972,19 @@ report reason Test failed at step %@. Test fallito al passo %@. - server test failure + relay test failure +server test failure Test notifications Prova le notifiche No comment provided by engineer. + + Test relay + Prova relay + No comment provided by engineer. + Test server Prova server @@ -8115,6 +9037,11 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.L'app protegge la tua privacy usando diversi operatori in ogni conversazione. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + L'app ha rimosso questo messaggio dopo %lld tentativi di riceverlo. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). L'app chiederà di confermare i download da server di file sconosciuti (eccetto .onion). @@ -8130,6 +9057,11 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Il codice che hai scansionato non è un codice QR di link SimpleX. No comment provided by engineer. + + The connection reached the limit of undelivered messages + La connessione ha raggiunto il limite di messaggi non consegnati + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. La connessione ha raggiunto il limite di messaggi non consegnati, il contatto potrebbe essere offline. @@ -8155,9 +9087,11 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.La crittografia funziona e il nuovo accordo sulla crittografia non è richiesto. Potrebbero verificarsi errori di connessione! No comment provided by engineer. - - The future of messaging - La nuova generazione di messaggistica privata + + The first network where you own +your contacts and groups. + La prima rete in cui possiedi +i tuoi contatti e i tuoi gruppi. No comment provided by engineer. @@ -8195,6 +9129,11 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Il database vecchio non è stato rimosso durante la migrazione, può essere eliminato. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + La più antica libertà umana, parlare con un'altra persona senza essere osservati, si basa su un'infrastruttura che non può tradirla. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Le stesse condizioni si applicheranno all'operatore **%@**. @@ -8240,6 +9179,16 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Temi No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + Poi ci siamo trasferiti online e ogni piattaforma ha chiesto un pezzo di noi: il nome, il numero, gli amici. Abbiamo accettato che il prezzo da pagare per comunicare con gli altri fosse quello di far sapere a qualcuno con chi parliamo. Ogni generazione, sia di persone che di tecnologia, ha funzionato così: telefono, email, messenger, social media. Sembrava l'unico modo possibile. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + C'è un'altra via. Una rete senza numeri di telefono. Senza nomi utente. Senza account. Senza identificatori utente di alcun tipo. Una rete che connette le persone e trasferisce messaggi crittografati senza sapere chi è connesso. + No comment provided by engineer. + These conditions will also apply for: **%@**. Queste condizioni si applicheranno anche per: **%@**. @@ -8305,6 +9254,16 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questo gruppo non esiste più. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + Questo è un indirizzo di relay di chat, non può essere usato per connettersi. + alert message + + + This is your link for channel %@! + Questo è il tuo link per il canale %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Questo link richiede una versione più recente dell'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile. @@ -8355,6 +9314,11 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Per nascondere messaggi indesiderati. No comment provided by engineer. + + To make SimpleX Network last. + Per la sostenibilità della rete di SimpleX. + No comment provided by engineer. + To make a new connection Per creare una nuova connessione @@ -8442,11 +9406,6 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Per verificare la crittografia end-to-end con il tuo contatto, confrontate (o scansionate) il codice sui vostri dispositivi. No comment provided by engineer. - - Toggle chat list: - Cambia l'elenco delle chat: - No comment provided by engineer. - Toggle incognito when connecting. Attiva/disattiva l'incognito quando ti colleghi. @@ -8462,6 +9421,11 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Opacità barra degli strumenti No comment provided by engineer. + + Top bar + Barra superiore + No comment provided by engineer. + Total Totale @@ -8477,15 +9441,10 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Sessioni di trasporto No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Tentativo di connessione al server usato per ricevere messaggi da questo contatto. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Tentativo di connessione al server usato per ricevere messaggi da questa connessione. + subscription status explanation Turkish interface @@ -8532,6 +9491,11 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Sbloccare il membro? No comment provided by engineer. + + Unblock subscriber for all? + Sbloccare l'iscritto per tutti? + No comment provided by engineer. + Undelivered messages Messaggi non consegnati @@ -8632,13 +9596,18 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Unsupported connection link Link di connessione non supportato - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Vengono inviati ai nuovi membri fino a 100 ultimi messaggi. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + Vengono inviati ai nuovi iscritti fino a 100 ultimi messaggi. + No comment provided by engineer. + Update Aggiorna @@ -8764,11 +9733,6 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa la porta TCP 443 solo per i server preimpostati. No comment provided by engineer. - - Use chat - Usa la chat - No comment provided by engineer. - Use current profile Usa il profilo attuale @@ -8784,6 +9748,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa per i messaggi No comment provided by engineer. + + Use for new channels + Usa per canali nuovi + No comment provided by engineer. + Use for new connections Usa per connessioni nuove @@ -8824,6 +9793,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa l'instradamento privato con server sconosciuti. No comment provided by engineer. + + Use relay + Usa relay + No comment provided by engineer. + Use server Usa il server @@ -8844,6 +9818,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa l'app con una mano sola. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + Usa questo indirizzo nel tuo profilo di social media, sito web o firma email. + No comment provided by engineer. + Use web port Usa porta web @@ -8864,6 +9843,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Utilizzo dei server SimpleX Chat. No comment provided by engineer. + + Verify + Verifica + relay test step + Verify code with desktop Verifica il codice con il desktop @@ -8924,6 +9908,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Il video verrà ricevuto quando il tuo contatto sarà in linea, attendi o controlla più tardi! No comment provided by engineer. + + Videos + Video + No comment provided by engineer. + Videos and files up to 1gb Video e file fino a 1 GB @@ -8979,6 +9968,21 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Messaggio vocale… No comment provided by engineer. + + Wait + Attendi + alert action + + + Wait response + Attendi risposta + relay test step + + + Waiting for channel owner to add relays. + In attesa che il proprietario del canale aggiunga dei relay. + No comment provided by engineer. + Waiting for desktop... In attesa del desktop... @@ -9019,6 +10023,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Attenzione: potresti perdere alcuni dati! No comment provided by engineer. + + We made connecting simpler for new users. + Abbiamo semplificato la connessione per i nuovi utenti. + No comment provided by engineer. + WebRTC ICE servers Server WebRTC ICE @@ -9069,6 +10078,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Quando condividi un profilo in incognito con qualcuno, questo profilo verrà utilizzato per i gruppi a cui ti invitano. No comment provided by engineer. + + Why SimpleX is built. + Perché costruiamo SimpleX. + No comment provided by engineer. + WiFi WiFi @@ -9196,16 +10210,21 @@ Repeat join request? Ripetere la richiesta di ingresso? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Sei connesso/a al server usato per ricevere messaggi da questo contatto. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Sei connesso/a al server usato per ricevere messaggi da questa connessione. + subscription status explanation You are invited to group Sei stato/a invitato/a al gruppo No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Non sei connesso/a a questi server. L'instradamento privato è usato per consegnare loro i messaggi. @@ -9276,6 +10295,11 @@ Ripetere la richiesta di ingresso? Puoi impostare l'anteprima della notifica nella schermata di blocco tramite le impostazioni. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + Puoi condividere un link o un codice QR, chiunque sarà in grado di iscriversi al canale. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Puoi condividere un link o un codice QR: chiunque potrà unirsi al gruppo. Non perderai i membri del gruppo se in seguito lo elimini. @@ -9321,16 +10345,25 @@ Ripetere la richiesta di ingresso? Non puoi inviare messaggi! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + Tu ti impegni a: +- Pubblicare solo contenuto legale nei gruppi pubblici +- Rispettare gli altri utenti. Niente spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + Ti sei connesso/a al canale attraverso questo link del relay. + No comment provided by engineer. + You could not be verified; please try again. Non è stato possibile verificarti, riprova. No comment provided by engineer. - - You decide who can connect. - Sei tu a decidere chi può connettersi. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9398,6 +10431,11 @@ Ripetere la richiesta di connessione? Dovresti ricevere le notifiche. token info + + You were born without an account + Sei nato senza un account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. Potrai inviare messaggi **solo dopo che la tua richiesta verrà accettata**. @@ -9433,6 +10471,11 @@ Ripetere la richiesta di connessione? Continuerai a ricevere chiamate e notifiche da profili silenziati quando sono attivi. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + Smetterai di ricevere messaggi da questo canale. La cronologia della chat sarà preservata. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. Non riceverai più messaggi da questa chat. La cronologia della chat verrà conservata. @@ -9478,6 +10521,11 @@ Ripetere la richiesta di connessione? Le tue chiamate No comment provided by engineer. + + Your channel + Il tuo canale + No comment provided by engineer. + Your chat database Il tuo database della chat @@ -9528,6 +10576,11 @@ Ripetere la richiesta di connessione? I tuoi contatti resteranno connessi. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + Le tue conversazioni appartengono a te, come è sempre stato prima dell'avvento di internet. La rete non è un luogo che visiti. È un luogo che crei e possiedi. E nessuno può portartelo via, che tu lo renda privato o pubblico. + No comment provided by engineer. + Your credentials may be sent unencrypted. Le credenziali potrebbero essere inviate in chiaro. @@ -9548,6 +10601,11 @@ Ripetere la richiesta di connessione? Il tuo gruppo No comment provided by engineer. + + Your network + La tua rete + No comment provided by engineer. + Your preferences Le tue preferenze @@ -9563,6 +10621,13 @@ Ripetere la richiesta di connessione? Il tuo profilo No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + Il tuo profilo **%@** verrà condiviso con i relay e gli iscritti. +I relay hanno accesso ai messaggi del canale. + No comment provided by engineer. + Your profile **%@** will be shared. Verrà condiviso il tuo profilo **%@**. @@ -9583,11 +10648,26 @@ Ripetere la richiesta di connessione? Il tuo profilo è stato cambiato. Se lo salvi, il profilo aggiornato verrà inviato a tutti i tuoi contatti. alert message + + Your public address + Il tuo indirizzo pubblico + No comment provided by engineer. + Your random profile Il tuo profilo casuale No comment provided by engineer. + + Your relay address + L'indirizzo del tuo relay + No comment provided by engineer. + + + Your relay name + Il nome del tuo relay + No comment provided by engineer. + Your server address L'indirizzo del tuo server @@ -9603,21 +10683,11 @@ Ripetere la richiesta di connessione? Le tue impostazioni No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Contribuisci](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Inviaci un'email](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Dai una stella su GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_corsivo_ @@ -9633,6 +10703,11 @@ Ripetere la richiesta di connessione? sopra, quindi scegli: No comment provided by engineer. + + accepted + accettato + No comment provided by engineer. + accepted %@ %@ accettato @@ -9653,6 +10728,11 @@ Ripetere la richiesta di connessione? ti ha accettato/a rcv group event chat item + + active + attivo + No comment provided by engineer. + admin amministratore @@ -9764,6 +10844,11 @@ marked deleted chat item preview text chiamata… call status + + can't broadcast + impossibile trasmettere + No comment provided by engineer. + can't send messages impossibile inviare messaggi @@ -9799,6 +10884,16 @@ marked deleted chat item preview text cambio indirizzo… chat item text + + channel + canale + shown as sender role for channel messages + + + channel profile updated + profilo del canale aggiornato + snd group event chat item + colored colorato @@ -9945,6 +11040,11 @@ pref value eliminato deleted chat item + + deleted channel + canale eliminato + rcv group event chat item + deleted contact contatto eliminato @@ -10055,11 +11155,21 @@ pref value errore No comment provided by engineer. + + error: %@ + errore: %@ + receive error chat item + expired scaduto No comment provided by engineer. + + failed + fallito + No comment provided by engineer. + forwarded inoltrato @@ -10180,6 +11290,11 @@ pref value è uscito/a rcv group event chat item + + link + link + No comment provided by engineer. + marked deleted contrassegnato eliminato @@ -10250,6 +11365,11 @@ pref value mai delete after time + + new + nuovo + No comment provided by engineer. + new message messaggio nuovo @@ -10265,6 +11385,11 @@ pref value nessuna crittografia e2e No comment provided by engineer. + + no subscription + nessuna iscrizione + No comment provided by engineer. + no text nessun testo @@ -10368,6 +11493,11 @@ time to disappear chiamata rifiutata call status + + relay + relay + member role + removed rimosso @@ -10378,6 +11508,16 @@ time to disappear ha rimosso %@ rcv group event chat item + + removed (%d attempts) + rimosso (%d tentativi) + receive error chat item + + + removed by operator + rimosso da un operatore + No comment provided by engineer. + removed contact address indirizzo di contatto rimosso @@ -10532,6 +11672,11 @@ ultimo msg ricevuto: %2$@ non protetto No comment provided by engineer. + + updated channel profile + profilo del canale aggiornato + rcv group event chat item + updated group profile ha aggiornato il profilo del gruppo @@ -10552,6 +11697,11 @@ ultimo msg ricevuto: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + via %@ + relay hostname + via contact address link via link indirizzo del contatto @@ -10627,6 +11777,11 @@ ultimo msg ricevuto: %2$@ sei un osservatore No comment provided by engineer. + + you are subscriber + sei iscritto/a + No comment provided by engineer. + you blocked %@ hai bloccato %@ @@ -10687,6 +11842,11 @@ ultimo msg ricevuto: %2$@ \~barrato~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + ⚠️ Verifica della firma fallita: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 0ca54bb3d9..0d3a7a9088 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -185,6 +185,21 @@ %d 月 time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d 秒 @@ -200,11 +215,53 @@ %d 件のスキップされたメッセージ integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d 週 time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld No comment provided by engineer. @@ -214,6 +271,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld 件の連絡先が選択されました @@ -314,11 +375,19 @@ %u 件のメッセージがスキップされました。 No comment provided by engineer. + + (from owner) + chat link info line + (new) (新規) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (このデバイス v%@) @@ -364,6 +433,10 @@ **QRスキャン / リンクの貼り付け**: 受け取ったリンクで接続する。 No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **警告**: 即時の プッシュ通知には、キーチェーンに保存されたパスフレーズが必要です。 @@ -407,6 +480,12 @@ - などなど! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -505,6 +584,10 @@ time interval その他 No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact 新しい連絡先 @@ -625,19 +708,21 @@ swipe action Active connections + アクティブな接続 No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - プロフィールにアドレスを追加し、連絡先があなたのアドレスを他の人と共有できるようにします。プロフィールの更新は連絡先に送信されます。 + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. Add friends + 友達を追加 No comment provided by engineer. Add list + リストを追加 No comment provided by engineer. @@ -661,6 +746,7 @@ swipe action Add team members + チームメンバーを追加 No comment provided by engineer. @@ -670,6 +756,7 @@ swipe action Add to list + リストに追加 No comment provided by engineer. @@ -691,6 +778,10 @@ swipe action 追加されたメッセージサーバー No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent No comment provided by engineer. @@ -719,6 +810,7 @@ swipe action Address settings + アドレス設定 No comment provided by engineer. @@ -742,6 +834,7 @@ swipe action All + すべて No comment provided by engineer. @@ -772,12 +865,17 @@ swipe action グループ全員の接続が継続します。 No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. All messages will be deleted - this cannot be undone! + すべてのメッセージが削除されます。この操作は元に戻せません! No comment provided by engineer. @@ -794,6 +892,14 @@ swipe action すべてのプロフィール profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. No comment provided by engineer. @@ -829,6 +935,7 @@ swipe action Allow calls? + 通話を許可しますか? No comment provided by engineer. @@ -838,6 +945,7 @@ swipe action Allow downgrade + ダウングレードを許可する No comment provided by engineer. @@ -849,6 +957,10 @@ swipe action 送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. 連絡先が許可している場合にのみ、メッセージへのリアクションを許可します。 @@ -864,6 +976,10 @@ swipe action メンバーへのダイレクトメッセージを許可する。 No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. 消えるメッセージの送信を許可する。 @@ -874,6 +990,10 @@ swipe action 共有を許可 No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) 送信済みメッセージの永久削除を許可する。(24時間) @@ -969,6 +1089,7 @@ swipe action Another reason + 他の理由 report reason @@ -976,11 +1097,6 @@ swipe action 通話に応答 No comment provided by engineer. - - Anybody can host servers. - プロトコル技術とコードはオープンソースで、どなたでもご自分のサーバを運用できます。 - No comment provided by engineer. - App build: %@ アプリのビルド: %@ @@ -1046,6 +1162,7 @@ swipe action Archive + アーカイブ No comment provided by engineer. @@ -1079,6 +1196,7 @@ swipe action Archived contacts + アーカイブされた連絡先 No comment provided by engineer. @@ -1100,6 +1218,10 @@ swipe action 音声通話とビデオ通話 No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls 音声/ビデオ通話 @@ -1168,6 +1290,19 @@ swipe action メッセージのハッシュ値問題 No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls No comment provided by engineer. @@ -1245,6 +1380,10 @@ swipe action Block member? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin No comment provided by engineer. @@ -1290,6 +1429,14 @@ swipe action あなたと連絡相手が音声メッセージを送信できます。 No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! ブルガリア語、フィンランド語、タイ語、ウクライナ語 - ユーザーと [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)に感謝します! @@ -1297,7 +1444,7 @@ swipe action Business address - No comment provided by engineer. + chat link info line Business chats @@ -1316,12 +1463,6 @@ swipe action チャット プロファイル経由 (デフォルト) または [接続経由](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - No comment provided by engineer. - Call already ended! 通話は既に終了してます! @@ -1350,6 +1491,7 @@ swipe action Can't change profile + プロフィールを変更できません alert title @@ -1375,6 +1517,7 @@ new chat action Cancel migration + 移行を中止する No comment provided by engineer. @@ -1384,6 +1527,7 @@ new chat action Cannot forward message + メッセージを転送できません No comment provided by engineer. @@ -1458,8 +1602,70 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat + チャット No comment provided by engineer. @@ -1514,6 +1720,7 @@ set passcode view Chat list + チャット一覧 No comment provided by engineer. @@ -1534,6 +1741,22 @@ set passcode view ユーザープロフィール No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme チャットテーマ @@ -1549,7 +1772,8 @@ set passcode view Chat with admins - chat toolbar + chat feature +chat toolbar Chat with member @@ -1564,18 +1788,39 @@ set passcode view チャット No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. + 20分おきにメッセージを確認する。 No comment provided by engineer. Check messages when allowed. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. サーバのアドレスを確認してから再度試してください。 @@ -1689,7 +1934,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1708,8 +1953,8 @@ set passcode view ICEサーバを設定 No comment provided by engineer. - - Configure server operators + + Configure relays No comment provided by engineer. @@ -1764,7 +2009,8 @@ set passcode view Connect 接続 - server test step + relay test step +server test step Connect automatically @@ -1803,6 +2049,10 @@ This is your own one-time link! リンク経由で接続 new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link ワンタイムリンクで接続 @@ -1879,6 +2129,10 @@ This is your own one-time link! Connection error (AUTH) 接続エラー (AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -1925,6 +2179,10 @@ This is your own one-time link! Connections No comment provided by engineer. + + Contact address + chat link info line + Contact allows 連絡先の許可 @@ -1990,6 +2248,11 @@ This is your own one-time link! 続ける No comment provided by engineer. + + Contribute + 貢献する + No comment provided by engineer. + Conversation deleted! No comment provided by engineer. @@ -2014,12 +2277,7 @@ This is your own one-time link! Correct name to %@? - No comment provided by engineer. - - - Create - 作成 - No comment provided by engineer. + alert message Create 1-time link @@ -2067,6 +2325,14 @@ This is your own one-time link! プロフィールを作成する No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue キューの作成 @@ -2076,11 +2342,19 @@ This is your own one-time link! Create your address No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile プロフィールを作成する No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created No comment provided by engineer. @@ -2097,6 +2371,10 @@ This is your own one-time link! Creating archive link No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… No comment provided by engineer. @@ -2251,10 +2529,9 @@ This is your own one-time link! 配信のデバッグ No comment provided by engineer. - - Decentralized - 分散型 - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2299,6 +2576,14 @@ swipe action Delete and notify contact No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat No comment provided by engineer. @@ -2407,6 +2692,14 @@ swipe action メンバーのメッセージを削除しますか? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? メッセージを削除しますか? @@ -2415,7 +2708,8 @@ swipe action Delete messages メッセージを削除 - alert button + alert action +alert button Delete messages after @@ -2451,6 +2745,10 @@ swipe action 待ち行列を削除 server test step + + Delete relay + No comment provided by engineer. + Delete report No comment provided by engineer. @@ -2600,6 +2898,14 @@ swipe action このグループではメンバー間のダイレクトメッセージが使用禁止です。 No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) 無効にする(設定の優先を維持) @@ -2697,6 +3003,10 @@ swipe action Do not send history to new members. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. No comment provided by engineer. @@ -2785,11 +3095,19 @@ chat item action E2E encrypted notifications. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit 編集する chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile グループのプロフィールを編集 @@ -2802,7 +3120,7 @@ chat item action Enable 有効 - No comment provided by engineer. + alert button Enable (keep overrides) @@ -2823,6 +3141,10 @@ chat item action TCP keep-aliveを有効にする No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? 自動メッセージ削除を有効にしますか? @@ -2832,6 +3154,10 @@ chat item action Enable camera access No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. No comment provided by engineer. @@ -2850,16 +3176,15 @@ chat item action 即時通知を有効にしますか? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock ロックモード No comment provided by engineer. - - Enable notifications - 通知を有効化 - No comment provided by engineer. - Enable periodic notifications? 定期的な通知を有効にしますか? @@ -2959,6 +3284,10 @@ chat item action パスコードを入力 No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. 正しいパスフレーズを入力してください。 @@ -2982,6 +3311,14 @@ chat item action 上にパスワードを入力すると表示されます! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually サーバを手動で入力 @@ -3008,7 +3345,7 @@ chat item action Error エラー - No comment provided by engineer. + conn error description Error aborting address change @@ -3033,6 +3370,10 @@ chat item action メンバー追加にエラー発生 No comment provided by engineer. + + Error adding relay + alert title + Error adding server alert title @@ -3076,11 +3417,19 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address アドレス作成にエラー発生 No comment provided by engineer. + + Error creating channel + alert title + Error creating group グループの作成エラー @@ -3206,10 +3555,6 @@ chat item action Error opening chat No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file ファイル受信にエラー発生 @@ -3249,6 +3594,10 @@ chat item action ICEサーバ保存にエラー発生 No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list alert title @@ -3308,6 +3657,10 @@ chat item action Error setting delivery receipts! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat チャット開始にエラー発生 @@ -3382,7 +3735,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3561,6 +3915,10 @@ snd error text ファイルとメディアは禁止されています! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. 未読とお気に入りをフィルターします。 @@ -3595,7 +3953,8 @@ snd error text Fingerprint in server address does not match certificate. サーバアドレスの証明証IDが正しくないかもしれません - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3635,9 +3994,14 @@ snd error text For all moderators No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: - servers error + servers error +servers warning For console @@ -3756,10 +4120,18 @@ Error: %2$@ GIFとステッカー No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! message preview @@ -3814,7 +4186,7 @@ Error: %2$@ Group link グループのリンク - No comment provided by engineer. + chat link info line Group links @@ -3922,6 +4294,10 @@ Error: %2$@ History is not sent to new members. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works SimpleX の仕組み @@ -3982,6 +4358,10 @@ Error: %2$@ アプリを開いているときに自己破壊パスコードを入力した場合: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). 今すぐチャットを使用する必要がある場合は、下の **後で実行する**をタップしてください (アプリを再起動すると、データベースを移行するよう求められます)。 @@ -4002,16 +4382,15 @@ Error: %2$@ 連絡先がオンラインになったら受信されます。しばらくお待ちください! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately 即座に No comment provided by engineer. - - Immune to spam - スパムや悪質送信を防止 - No comment provided by engineer. - Import 読み込む @@ -4142,9 +4521,9 @@ More improvements are coming soon! 初期の役割 No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - インストール [ターミナル用SimpleX Chat](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + インストール ターミナル用SimpleX Chat No comment provided by engineer. @@ -4195,7 +4574,7 @@ More improvements are coming soon! Invalid connection link 無効な接続リンク - No comment provided by engineer. + conn error description Invalid display name! @@ -4211,7 +4590,15 @@ More improvements are coming soon! Invalid name! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4237,11 +4624,19 @@ More improvements are coming soon! 友人を招待する No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members メンバーを招待する No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat No comment provided by engineer. @@ -4316,6 +4711,10 @@ More improvements are coming soon! %@ として参加 No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group グループに参加 @@ -4395,6 +4794,14 @@ This is your link for group %@! 脱退 swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat No comment provided by engineer. @@ -4417,6 +4824,10 @@ This is your link for group %@! Less traffic on mobile networks. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat SimpleXチャットで会話しよう @@ -4436,6 +4847,10 @@ This is your link for group %@! Link mobile and desktop apps! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options No comment provided by engineer. @@ -4444,6 +4859,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4559,6 +4978,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4579,12 +5002,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! メンバーをグループから除名する (※元に戻せません※)! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4595,6 +5018,10 @@ This is your link for group %@! グループメンバーはメッセージへのリアクションを追加できます。 No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) グループのメンバーがメッセージを完全削除することができます。(24時間) @@ -4654,6 +5081,10 @@ This is your link for group %@! メッセージの下書き No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded item status text @@ -4736,6 +5167,14 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. alert message @@ -4762,13 +5201,12 @@ This is your link for group %@! メッセージ、ファイル、通話は、前方秘匿性、否認可能性および侵入復元性を備えた**耐量子E2E暗号化**によって保護されます。 No comment provided by engineer. - - Migrate device + + Migrate No comment provided by engineer. - - Migrate from another device - 別の端末から移行 + + Migrate device No comment provided by engineer. @@ -4882,6 +5320,10 @@ This is your link for group %@! ネットワークとサーバ No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection No comment provided by engineer. @@ -4890,6 +5332,10 @@ This is your link for group %@! Network decentralization No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. snd error text @@ -4902,6 +5348,11 @@ This is your link for group %@! Network operator No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings ネットワーク設定 @@ -4910,12 +5361,16 @@ This is your link for group %@! Network status ネットワーク状況 - No comment provided by engineer. + alert title New token status text + + New 1-time link + No comment provided by engineer. + New Passcode 新しいパスコード @@ -4937,6 +5392,10 @@ This is your link for group %@! New chat experience 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request 新しい繋がりのリクエスト @@ -5002,11 +5461,28 @@ This is your link for group %@! いいえ No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password アプリのパスワードはありません Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats No comment provided by engineer. @@ -5133,11 +5609,22 @@ This is your link for group %@! No unread chats No comment provided by engineer. - - No user identifiers. - 世界初のユーザーIDのないプラットフォーム|設計も元からプライベート。 + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! No comment provided by engineer. @@ -5187,7 +5674,7 @@ This is your link for group %@! OK - No comment provided by engineer. + alert button Off @@ -5206,11 +5693,19 @@ new chat action 古いデータベース No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link 使い捨ての招待リンク No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5230,6 +5725,10 @@ VPN を有効にする必要があります。 オニオンのホストが使われません。 No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. No comment provided by engineer. @@ -5327,7 +5826,8 @@ VPN を有効にする必要があります。 Open 開く - alert action + alert action +alert button Open Settings @@ -5338,6 +5838,10 @@ VPN を有効にする必要があります。 Open changes No comment provided by engineer. + + Open channel + new chat action + Open chat チャットを開く @@ -5356,6 +5860,10 @@ VPN を有効にする必要があります。 Open conditions No comment provided by engineer. + + Open external link? + alert title + Open full link alert action @@ -5372,6 +5880,10 @@ VPN を有効にする必要があります。 Open migration to another device authentication reason + + Open new channel + new chat action + Open new chat new chat action @@ -5408,6 +5920,13 @@ VPN を有効にする必要があります。 Operator server alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file No comment provided by engineer. @@ -5424,6 +5943,10 @@ VPN を有効にする必要があります。 Or securely share this file link No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code No comment provided by engineer. @@ -5432,6 +5955,10 @@ VPN を有効にする必要があります。 Or to share privately No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists No comment provided by engineer. @@ -5445,6 +5972,18 @@ VPN を有効にする必要があります。 %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count PING回数 @@ -5498,6 +6037,10 @@ VPN を有効にする必要があります。 画像の貼り付け No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! No comment provided by engineer. @@ -5636,6 +6179,14 @@ Error: %@ 添付を含めて、下書きを保存する。 No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address プレセットサーバのアドレス @@ -5667,13 +6218,12 @@ Error: %@ Privacy policy and conditions of use. No comment provided by engineer. - - Privacy redefined - プライバシーの基準を新境地に + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. + + Private and secure messaging. No comment provided by engineer. @@ -5710,6 +6260,10 @@ Error: %@ Private routing timeout alert title + + Proceed + alert action + Profile and server connections プロフィールとサーバ接続 @@ -5733,9 +6287,8 @@ Error: %@ Profile theme No comment provided by engineer. - - Profile update will be sent to your contacts. - 連絡先にプロフィール更新のお知らせが届きます。 + + Profile update will be sent to your SimpleX contacts. alert message @@ -5743,6 +6296,10 @@ Error: %@ 音声/ビデオ通話を禁止する 。 No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. メッセージの完全削除を使用禁止にする。 @@ -5771,6 +6328,10 @@ Error: %@ メンバー間のダイレクトメッセージを使用禁止にする。 No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. 消えるメッセージを使用禁止にする。 @@ -5831,6 +6392,10 @@ Enable in *Network & servers* settings. Proxy requires password No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications プッシュ通知 @@ -5868,23 +6433,14 @@ Enable in *Network & servers* settings. 続きを読む No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + 詳しくはユーザーガイドをご覧ください。 No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - 詳しくは[ユーザーガイド](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)をご覧ください。 - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - 詳しくは[ユーザーガイド](https://simplex.chat/docs/guide/readme.html#connect-to-friends)をご覧ください。 - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - 詳しくは[GitHubリポジトリ](https://github.com/simplex-chat/simplex-chat#readme)をご覧ください。 + + Read more in our GitHub repository. + 詳しくはGitHubリポジトリをご覧ください。 No comment provided by engineer. @@ -5905,11 +6461,6 @@ Enable in *Network & servers* settings. 受信: %@ copied message info - - Received file event - ファイル受信イベント - notification - Received message 受信したメッセージ @@ -6033,6 +6584,26 @@ swipe action Reject member? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. 中継サーバーは必要な場合にのみ使用されます。 別の当事者があなたの IP アドレスを監視できます。 @@ -6043,10 +6614,22 @@ swipe action リレー サーバーは IP アドレスを保護しますが、通話時間は監視されます。 No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove 削除 - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6068,13 +6651,21 @@ swipe action Remove member? メンバーを除名しますか? - No comment provided by engineer. + alert title Remove passphrase from keychain? キーチェーンからパスフレーズを削除しますか? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. No comment provided by engineer. @@ -6283,6 +6874,10 @@ swipe action SOCKS proxy No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files No comment provided by engineer. @@ -6306,6 +6901,10 @@ chat item action Save (and notify members) alert button + + Save (and notify subscribers) + alert button + Save admission settings? alert title @@ -6320,6 +6919,10 @@ chat item action 保存して、グループのメンバーにに知らせる No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect No comment provided by engineer. @@ -6329,6 +6932,14 @@ chat item action グループプロファイルの保存と更新 No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile グループプロフィールの保存 @@ -6443,10 +7054,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -6470,6 +7101,10 @@ chat item action セキュリティコード No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select 選択 @@ -6587,6 +7222,10 @@ chat item action Send request without message No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. ギャラリーまたはカスタム キーボードから送信します。 @@ -6596,6 +7235,10 @@ chat item action Send up to 100 last messages to new members. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. No comment provided by engineer. @@ -6610,6 +7253,10 @@ chat item action 送信元が繋がりリクエストを削除したかもしれません。 No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. No comment provided by engineer. @@ -6658,11 +7305,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - 送信済みファイルイベント - notification - Sent message 送信 @@ -6721,6 +7363,10 @@ chat item action Server protocol changed. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. キューを作成するにはサーバーの認証が必要です。パスワードを確認してください @@ -6838,6 +7484,14 @@ chat item action Settings were changed. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images No comment provided by engineer. @@ -6870,11 +7524,14 @@ chat item action Share address publicly No comment provided by engineer. - - Share address with contacts? - アドレスを連絡先と共有しますか? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. No comment provided by engineer. @@ -6896,6 +7553,10 @@ chat item action Share profile No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link No comment provided by engineer. @@ -6904,9 +7565,12 @@ chat item action Share to SimpleX No comment provided by engineer. - - Share with contacts - 連絡先と共有する + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -7062,8 +7726,8 @@ chat item action SimpleX protocols reviewed by Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7130,6 +7794,11 @@ report reason Square, circle, or anything in between. No comment provided by engineer. + + Star on GitHub + GitHub でスターを付ける + No comment provided by engineer. + Start chat チャットを開始する @@ -7222,6 +7891,63 @@ report reason Subscribed No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors No comment provided by engineer. @@ -7294,6 +8020,10 @@ report reason 写真を撮影 No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat No comment provided by engineer. @@ -7306,8 +8036,8 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. + + Tap Join channel No comment provided by engineer. @@ -7338,6 +8068,10 @@ report reason タップしてシークレットモードで参加 No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link No comment provided by engineer. @@ -7353,12 +8087,17 @@ report reason Test failed at step %@. テストはステップ %@ で失敗しました。 - server test failure + relay test failure +server test failure Test notifications No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server テストサーバ @@ -7409,6 +8148,10 @@ It can happen because of some bug or when the connection is compromised.The app protects your privacy by using different operators in each conversation. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). No comment provided by engineer. @@ -7422,6 +8165,10 @@ It can happen because of some bug or when the connection is compromised.The code you scanned is not a SimpleX link QR code. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. No comment provided by engineer. @@ -7446,9 +8193,9 @@ It can happen because of some bug or when the connection is compromised.暗号化は機能しており、新しい暗号化への同意は必要ありません。接続エラーが発生する可能性があります! No comment provided by engineer. - - The future of messaging - 次世代のプライバシー・メッセンジャー + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -7483,6 +8230,10 @@ It can happen because of some bug or when the connection is compromised.古いデータベースは移行時に削除されなかったので、削除することができます。 No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7522,6 +8273,14 @@ It can happen because of some bug or when the connection is compromised.Themes No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. No comment provided by engineer. @@ -7580,6 +8339,14 @@ It can happen because of some bug or when the connection is compromised.このグループはもう存在しません。 No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. No comment provided by engineer. @@ -7623,6 +8390,10 @@ It can happen because of some bug or when the connection is compromised.To hide unwanted messages. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection 新規に接続する場合 @@ -7701,10 +8472,6 @@ You will be prompted to complete authentication before this feature is enabled.< エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 No comment provided by engineer. - - Toggle chat list: - No comment provided by engineer. - Toggle incognito when connecting. No comment provided by engineer. @@ -7717,6 +8484,10 @@ You will be prompted to complete authentication before this feature is enabled.< Toolbar opacity No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total No comment provided by engineer. @@ -7730,15 +8501,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - この連絡先からのメッセージの受信に使用されるサーバーに接続しようとしています (エラー: %@)。 - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - このコンタクトから受信するメッセージのサーバに接続しようとしてます。 - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -7779,6 +8544,10 @@ You will be prompted to complete authentication before this feature is enabled.< Unblock member? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages No comment provided by engineer. @@ -7874,12 +8643,16 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update 更新 @@ -7988,11 +8761,6 @@ To connect, please ask your contact to create another connection link and check Use TCP port 443 for preset servers only. No comment provided by engineer. - - Use chat - チャット - No comment provided by engineer. - Use current profile 現在のプロファイルを使用する @@ -8006,6 +8774,10 @@ To connect, please ask your contact to create another connection link and check Use for messages No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections 新しい接続に使う @@ -8041,6 +8813,10 @@ To connect, please ask your contact to create another connection link and check Use private routing with unknown servers. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server サーバを使う @@ -8058,6 +8834,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port No comment provided by engineer. @@ -8075,6 +8855,10 @@ To connect, please ask your contact to create another connection link and check SimpleX チャット サーバーを使用する。 No comment provided by engineer. + + Verify + relay test step + Verify code with desktop No comment provided by engineer. @@ -8129,6 +8913,10 @@ To connect, please ask your contact to create another connection link and check 動画は相手がオンラインになったら受信されます。しばらくお待ちください! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb 1GBまでのビデオとファイル @@ -8180,6 +8968,18 @@ To connect, please ask your contact to create another connection link and check 音声メッセージ… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... No comment provided by engineer. @@ -8216,6 +9016,10 @@ To connect, please ask your contact to create another connection link and check 警告: 一部のデータが失われる可能性があります! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICEサーバ @@ -8262,6 +9066,10 @@ To connect, please ask your contact to create another connection link and check 連絡相手にシークレットモードのプロフィールを共有すると、その連絡相手に招待されたグループでも同じプロフィールが使われます。 No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi No comment provided by engineer. @@ -8370,16 +9178,19 @@ To connect, please ask your contact to create another connection link and check Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - この連絡先から受信するメッセージのサーバに既に接続してます。 - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group グループ招待が届きました No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -8444,6 +9255,10 @@ Repeat join request? 設定からロック画面の通知プレビューを設定できます。 No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. リンク、またはQRコードを共有できます。誰でもグループに参加できます。後で削除しても、グループのメンバーがそのままのこります。 @@ -8486,16 +9301,21 @@ Repeat join request? メッセージを送信できませんでした! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. 確認できませんでした。 もう一度お試しください。 No comment provided by engineer. - - You decide who can connect. - あなたと繋がることができるのは、あなたからリンクを頂いた方のみです。 - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -8557,6 +9377,10 @@ Repeat connection request? You should receive notifications. token info + + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. No comment provided by engineer. @@ -8590,6 +9414,10 @@ Repeat connection request? ミュートされたプロフィールがアクティブな場合でも、そのプロフィールからの通話や通知は引き続き受信します。 No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. No comment provided by engineer. @@ -8633,6 +9461,10 @@ Repeat connection request? あなたの通話 No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database あなたのチャットデータベース @@ -8679,6 +9511,10 @@ Repeat connection request? 連絡先は接続されたままになります。 No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. No comment provided by engineer. @@ -8697,6 +9533,10 @@ Repeat connection request? Your group No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences あなたの設定 @@ -8711,6 +9551,11 @@ Repeat connection request? Your profile No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. あなたのプロファイル **%@** が共有されます。 @@ -8730,11 +9575,23 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message + + Your public address + No comment provided by engineer. + Your random profile あなたのランダム・プロフィール No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address あなたのサーバアドレス @@ -8749,21 +9606,11 @@ Repeat connection request? あなたの設定 No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [貢献する](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [メールを送信](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [GitHub でスターを付ける](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_斜体_ @@ -8779,6 +9626,10 @@ Repeat connection request? 上で選んでください: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ rcv group event chat item @@ -8796,6 +9647,10 @@ Repeat connection request? accepted you rcv group event chat item + + active + No comment provided by engineer. + admin 管理者 @@ -8896,6 +9751,10 @@ marked deleted chat item preview text 発信中… call status + + can't broadcast + No comment provided by engineer. + can't send messages No comment provided by engineer. @@ -8930,6 +9789,14 @@ marked deleted chat item preview text アドレスを変更しています… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored 色付き @@ -9070,6 +9937,10 @@ pref value 削除完了 deleted chat item + + deleted channel + rcv group event chat item + deleted contact rcv direct event chat item @@ -9178,10 +10049,18 @@ pref value エラー No comment provided by engineer. + + error: %@ + receive error chat item + expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. @@ -9297,6 +10176,10 @@ pref value 脱退 rcv group event chat item + + link + No comment provided by engineer. + marked deleted 削除済みとマーク @@ -9363,6 +10246,10 @@ pref value 一度も delete after time + + new + No comment provided by engineer. + new message 新しいメッセージ @@ -9378,6 +10265,10 @@ pref value エンドツーエンド暗号化がありません No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text テキストなし @@ -9472,6 +10363,10 @@ time to disappear 拒否した通話 call status + + relay + member role + removed 除名されました @@ -9482,6 +10377,14 @@ time to disappear %@ を除名されました rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address profile update event chat item @@ -9613,6 +10516,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile グループプロフィールを更新しました @@ -9631,6 +10538,10 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link 連絡先アドレスリンク経由 @@ -9702,6 +10613,10 @@ last received msg: %2$@ あなたはオブザーバーです No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ snd group event chat item @@ -9760,6 +10675,10 @@ last received msg: %2$@ \~取り消し線~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index a13e4cd80d..3bf4a6f197 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -185,6 +185,21 @@ %d maanden time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d sec @@ -200,11 +215,53 @@ %d overgeslagen bericht(en) integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d weken time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +272,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld contact(en) geselecteerd @@ -315,11 +376,19 @@ %u berichten zijn overgeslagen. No comment provided by engineer. + + (from owner) + chat link info line + (new) (nieuw) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (dit apparaat v%@) @@ -365,6 +434,10 @@ **Link scannen/plakken**: om verbinding te maken via een link die u hebt ontvangen. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Waarschuwing**: voor directe push meldingen is een wachtwoord vereist dat is opgeslagen in de Keychain. @@ -408,6 +481,12 @@ - en meer! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +585,10 @@ time interval Nog een paar dingen No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact Een nieuw contact @@ -631,9 +714,8 @@ swipe action Actieve verbindingen No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Voeg een adres toe aan uw profiel, zodat uw contacten het met andere mensen kunnen delen. Profiel update wordt naar uw contacten verzonden. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -700,6 +782,10 @@ swipe action Berichtservers toegevoegd No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent Extra accent @@ -790,6 +876,10 @@ swipe action Alle groepsleden blijven verbonden. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Alle berichten en bestanden worden **end-to-end versleuteld** verzonden, met post-quantumbeveiliging in directe berichten. @@ -815,6 +905,14 @@ swipe action Alle profielen profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. Alle rapporten worden voor u gearchiveerd. @@ -874,6 +972,10 @@ swipe action Sta het definitief verwijderen van berichten alleen toe als uw contact dit toestaat. (24 uur) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Sta bericht reacties alleen toe als uw contact dit toestaat. @@ -889,6 +991,10 @@ swipe action Sta het verzenden van directe berichten naar leden toe. No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Toestaan dat verdwijnende berichten worden verzonden. @@ -899,6 +1005,10 @@ swipe action Delen toestaan No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Sta toe om verzonden berichten definitief te verwijderen. (24 uur) @@ -1003,11 +1113,6 @@ swipe action Beantwoord oproep No comment provided by engineer. - - Anybody can host servers. - Iedereen kan servers hosten. - No comment provided by engineer. - App build: %@ App build: %@ @@ -1138,6 +1243,10 @@ swipe action Audio en video oproepen No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Audio/video oproepen @@ -1208,6 +1317,19 @@ swipe action Onjuiste bericht hash No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls Betere gesprekken @@ -1301,6 +1423,10 @@ swipe action Lid blokkeren? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin Geblokkeerd door beheerder @@ -1349,6 +1475,14 @@ swipe action Zowel jij als je contact kunnen spraak berichten verzenden. No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bulgaars, Fins, Thais en Oekraïens - dankzij de gebruikers en [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1357,7 +1491,7 @@ swipe action Business address Zakelijk adres - No comment provided by engineer. + chat link info line Business chats @@ -1378,15 +1512,6 @@ swipe action Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - Door SimpleX Chat te gebruiken, gaat u ermee akkoord: -- alleen legale content te versturen in openbare groepen. -- andere gebruikers te respecteren – geen spam. - No comment provided by engineer. - Call already ended! Oproep al beëindigd! @@ -1534,6 +1659,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat Chat @@ -1619,6 +1805,22 @@ set passcode view Gebruikers profiel No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme Chat thema @@ -1637,7 +1839,8 @@ set passcode view Chat with admins Chat met beheerders - chat toolbar + chat feature +chat toolbar Chat with member @@ -1653,11 +1856,23 @@ set passcode view Chats No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members Chats met leden No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. Controleer uw berichten elke 20 minuten. @@ -1668,6 +1883,14 @@ set passcode view Controleer berichten indien toegestaan. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. Controleer het server adres en probeer het opnieuw. @@ -1791,7 +2014,7 @@ set passcode view Conditions of use Gebruiksvoorwaarden - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1813,9 +2036,8 @@ set passcode view ICE servers configureren No comment provided by engineer. - - Configure server operators - Serveroperators configureren + + Configure relays No comment provided by engineer. @@ -1876,7 +2098,8 @@ set passcode view Connect Verbind - server test step + relay test step +server test step Connect automatically @@ -1921,6 +2144,10 @@ Dit is uw eigen eenmalige link! Maak verbinding via link new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Verbinden via een eenmalige link? @@ -1999,6 +2226,10 @@ Dit is uw eigen eenmalige link! Connection error (AUTH) Verbindingsfout (AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -2053,6 +2284,10 @@ Dit is uw eigen eenmalige link! Verbindingen No comment provided by engineer. + + Contact address + chat link info line + Contact allows Contact maakt het mogelijk @@ -2122,6 +2357,11 @@ Dit is uw eigen eenmalige link! Doorgaan No comment provided by engineer. + + Contribute + Bijdragen + No comment provided by engineer. + Conversation deleted! Gesprek verwijderd! @@ -2150,12 +2390,7 @@ Dit is uw eigen eenmalige link! Correct name to %@? Juiste naam voor %@? - No comment provided by engineer. - - - Create - Maak - No comment provided by engineer. + alert message Create 1-time link @@ -2207,6 +2442,14 @@ Dit is uw eigen eenmalige link! Maak een profiel aan No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue Maak een wachtrij @@ -2216,11 +2459,19 @@ Dit is uw eigen eenmalige link! Create your address No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile Maak je profiel aan No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created Gemaakt @@ -2241,6 +2492,10 @@ Dit is uw eigen eenmalige link! Archief link maken No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… Link maken… @@ -2399,10 +2654,9 @@ Dit is uw eigen eenmalige link! Foutopsporing bezorging No comment provided by engineer. - - Decentralized - Gedecentraliseerd - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2450,6 +2704,14 @@ swipe action Verwijderen en contact op de hoogte stellen No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat Chat verwijderen @@ -2565,6 +2827,14 @@ swipe action Bericht van lid verwijderen? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Verwijder bericht? @@ -2573,7 +2843,8 @@ swipe action Delete messages Verwijder berichten - alert button + alert action +alert button Delete messages after @@ -2610,6 +2881,10 @@ swipe action Wachtrij verwijderen server test step + + Delete relay + No comment provided by engineer. + Delete report Rapport verwijderen @@ -2773,6 +3048,14 @@ swipe action Directe berichten tussen leden zijn niet toegestaan. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) Uitschakelen (overschrijvingen behouden) @@ -2878,6 +3161,10 @@ swipe action Stuur geen geschiedenis naar nieuwe leden. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. Gebruik geen inloggegevens met proxy. @@ -2979,11 +3266,19 @@ chat item action E2E versleutelde meldingen. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit Bewerk chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile Groep profiel bewerken @@ -2996,7 +3291,7 @@ chat item action Enable Inschakelen - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3018,6 +3313,10 @@ chat item action Schakel TCP keep-alive in No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Automatisch verwijderen van berichten aanzetten? @@ -3028,6 +3327,10 @@ chat item action Schakel cameratoegang in No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. No comment provided by engineer. @@ -3047,16 +3350,15 @@ chat item action Onmiddellijke meldingen inschakelen? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock Vergrendeling inschakelen No comment provided by engineer. - - Enable notifications - Meldingen aanzetten - No comment provided by engineer. - Enable periodic notifications? Periodieke meldingen inschakelen? @@ -3162,6 +3464,10 @@ chat item action Voer toegangscode in No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Voer het juiste wachtwoord in. @@ -3187,6 +3493,14 @@ chat item action Voer hier boven het wachtwoord in om weer te geven! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually Voer de server handmatig in @@ -3215,7 +3529,7 @@ chat item action Error Fout - No comment provided by engineer. + conn error description Error aborting address change @@ -3242,6 +3556,10 @@ chat item action Fout bij het toevoegen van leden No comment provided by engineer. + + Error adding relay + alert title + Error adding server Fout bij toevoegen server @@ -3290,11 +3608,19 @@ chat item action Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Fout bij aanmaken van adres No comment provided by engineer. + + Error creating channel + alert title + Error creating group Fout bij maken van groep @@ -3430,10 +3756,6 @@ chat item action Fout bij het openen van de chat No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file Fout bij ontvangen van bestand @@ -3478,6 +3800,10 @@ chat item action Fout bij opslaan van ICE servers No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list Fout bij het opslaan van chatlijst @@ -3542,6 +3868,10 @@ chat item action Fout bij het instellen van ontvangst bevestiging! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat Fout bij het starten van de chat @@ -3621,7 +3951,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3821,6 +4152,10 @@ snd error text Bestanden en media niet toegestaan! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filter ongelezen en favoriete chats. @@ -3857,7 +4192,8 @@ snd error text Fingerprint in server address does not match certificate. Mogelijk is de certificaat vingerafdruk in het server adres onjuist - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3898,10 +4234,15 @@ snd error text Voor alle moderators No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: Voor chatprofiel %@: - servers error + servers error +servers warning For console @@ -4042,11 +4383,19 @@ Fout: %2$@ GIF's en stickers No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. Ontvang een melding als u vermeld wordt. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! Goedemiddag! @@ -4105,7 +4454,7 @@ Fout: %2$@ Group link Groep link - No comment provided by engineer. + chat link info line Group links @@ -4216,6 +4565,10 @@ Fout: %2$@ Geschiedenis wordt niet naar nieuwe leden gestuurd. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works Hoe SimpleX werkt @@ -4281,6 +4634,10 @@ Fout: %2$@ Als u uw zelfvernietigings wachtwoord invoert tijdens het openen van de app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Als u de chat nu wilt gebruiken, tikt u hieronder op **Doe het later** (u wordt aangeboden om de database te migreren wanneer u de app opnieuw start). @@ -4301,16 +4658,15 @@ Fout: %2$@ De afbeelding wordt ontvangen wanneer uw contact online is, even geduld a.u.b. of kijk later! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Onmiddellijk No comment provided by engineer. - - Immune to spam - Immuun voor spam en misbruik - No comment provided by engineer. - Import Importeren @@ -4453,9 +4809,9 @@ Binnenkort meer verbeteringen! Initiële rol No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Installeer [SimpleX Chat voor terminal](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Installeer SimpleX Chat voor terminal No comment provided by engineer. @@ -4513,7 +4869,7 @@ Binnenkort meer verbeteringen! Invalid connection link Ongeldige verbinding link - No comment provided by engineer. + conn error description Invalid display name! @@ -4533,7 +4889,15 @@ Binnenkort meer verbeteringen! Invalid name! Ongeldige naam! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4560,11 +4924,19 @@ Binnenkort meer verbeteringen! Nodig vrienden uit No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Nodig leden uit No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat Uitnodigen voor een chat @@ -4641,6 +5013,10 @@ Binnenkort meer verbeteringen! deelnemen als %@ No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group Word lid van groep @@ -4727,6 +5103,14 @@ Dit is jouw link voor groep %@! Verlaten swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat Chat verlaten @@ -4751,6 +5135,10 @@ Dit is jouw link voor groep %@! Less traffic on mobile networks. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat Laten we praten in SimpleX Chat @@ -4771,6 +5159,10 @@ Dit is jouw link voor groep %@! Koppel mobiele en desktop-apps! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options Gekoppelde desktop opties @@ -4781,6 +5173,10 @@ Dit is jouw link voor groep %@! Gelinkte desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List Lijst @@ -4903,6 +5299,10 @@ Dit is jouw link voor groep %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Ledenrapporten @@ -4926,12 +5326,12 @@ Dit is jouw link voor groep %@! Member will be removed from chat - this cannot be undone! Lid wordt verwijderd uit de chat - dit kan niet ongedaan worden gemaakt! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4943,6 +5343,10 @@ Dit is jouw link voor groep %@! Groepsleden kunnen bericht reacties toevoegen. No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Groepsleden kunnen verzonden berichten onherroepelijk verwijderen. (24 uur) @@ -5008,6 +5412,10 @@ Dit is jouw link voor groep %@! Concept bericht No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded Bericht doorgestuurd @@ -5101,6 +5509,14 @@ Dit is jouw link voor groep %@! Berichten van %@ worden getoond! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. Berichten in deze chat zullen nooit worden verwijderd. @@ -5131,16 +5547,15 @@ Dit is jouw link voor groep %@! Berichten, bestanden en oproepen worden beschermd door **kwantumbestendige e2e encryptie** met perfecte voorwaartse geheimhouding, afwijzing en inbraakherstel. No comment provided by engineer. + + Migrate + No comment provided by engineer. + Migrate device Apparaat migreren No comment provided by engineer. - - Migrate from another device - Migreer vanaf een ander apparaat - No comment provided by engineer. - Migrate here Migreer hierheen @@ -5261,6 +5676,10 @@ Dit is jouw link voor groep %@! Netwerk & servers No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection Netwerkverbinding @@ -5271,6 +5690,10 @@ Dit is jouw link voor groep %@! Netwerk decentralisatie No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. Netwerkproblemen - bericht is verlopen na vele pogingen om het te verzenden. @@ -5286,6 +5709,11 @@ Dit is jouw link voor groep %@! Netwerkbeheerder No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings Netwerk instellingen @@ -5294,13 +5722,17 @@ Dit is jouw link voor groep %@! Network status Netwerk status - No comment provided by engineer. + alert title New Nieuw token status text + + New 1-time link + No comment provided by engineer. + New Passcode Nieuwe toegangscode @@ -5326,6 +5758,10 @@ Dit is jouw link voor groep %@! Nieuwe chatervaring 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request Nieuw contactverzoek @@ -5395,11 +5831,28 @@ Dit is jouw link voor groep %@! Nee No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password Geen app wachtwoord Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats Geen chats @@ -5544,11 +5997,22 @@ Dit is jouw link voor groep %@! Geen ongelezen chats No comment provided by engineer. - - No user identifiers. - Geen gebruikers-ID's. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! Niet compatibel! @@ -5606,7 +6070,7 @@ Dit is jouw link voor groep %@! OK OK - No comment provided by engineer. + alert button Off @@ -5625,11 +6089,19 @@ new chat action Oude database No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link Eenmalige uitnodiging link No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5649,6 +6121,10 @@ Vereist het inschakelen van VPN. Onion hosts worden niet gebruikt. No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. Alleen chateigenaren kunnen voorkeuren wijzigen. @@ -5750,7 +6226,8 @@ Vereist het inschakelen van VPN. Open Open - alert action + alert action +alert button Open Settings @@ -5762,6 +6239,10 @@ Vereist het inschakelen van VPN. Wijzigingen openen No comment provided by engineer. + + Open channel + new chat action + Open chat Chat openen @@ -5781,6 +6262,10 @@ Vereist het inschakelen van VPN. Open voorwaarden No comment provided by engineer. + + Open external link? + alert title + Open full link alert action @@ -5800,6 +6285,10 @@ Vereist het inschakelen van VPN. Open de migratie naar een ander apparaat authentication reason + + Open new channel + new chat action + Open new chat new chat action @@ -5839,6 +6328,13 @@ Vereist het inschakelen van VPN. Operatorserver alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file Of importeer archiefbestand @@ -5859,6 +6355,10 @@ Vereist het inschakelen van VPN. Of deel deze bestands link veilig No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code Of laat deze code zien @@ -5869,6 +6369,10 @@ Vereist het inschakelen van VPN. Of om privé te delen No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists Organiseer chats in lijsten @@ -5886,6 +6390,18 @@ Vereist het inschakelen van VPN. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count PING count @@ -5941,6 +6457,10 @@ Vereist het inschakelen van VPN. Afbeelding plakken No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! Plak een link om te verbinden! @@ -6095,6 +6615,14 @@ Fout: %@ Bewaar het laatste berichtconcept, met bijlagen. No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address Vooraf ingesteld server adres @@ -6130,14 +6658,12 @@ Fout: %@ Privacybeleid en gebruiksvoorwaarden. No comment provided by engineer. - - Privacy redefined - Privacy opnieuw gedefinieerd + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders. + + Private and secure messaging. No comment provided by engineer. @@ -6179,6 +6705,10 @@ Fout: %@ Private routing timeout alert title + + Proceed + alert action + Profile and server connections Profiel- en serververbindingen @@ -6204,9 +6734,8 @@ Fout: %@ Profiel thema No comment provided by engineer. - - Profile update will be sent to your contacts. - Profiel update wordt naar uw contacten verzonden. + + Profile update will be sent to your SimpleX contacts. alert message @@ -6214,6 +6743,10 @@ Fout: %@ Audio/video gesprekken verbieden. No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Verbied het definitief verwijderen van berichten. @@ -6244,6 +6777,10 @@ Fout: %@ Verbied het sturen van directe berichten naar leden. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Verbied het verzenden van verdwijnende berichten. @@ -6310,6 +6847,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Proxy vereist wachtwoord No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Push meldingen @@ -6350,24 +6891,14 @@ Schakel dit in in *Netwerk en servers*-instellingen. Lees meer No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Lees meer in de [Gebruikershandleiding](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Lees meer in de Gebruikershandleiding. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Lees meer in de [Gebruikershandleiding](https://simplex.chat/docs/guide/app-settings.html#uw-simplex-contactadres). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Lees meer in de [Gebruikershandleiding](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Lees meer in onze [GitHub-repository](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Lees meer in onze GitHub-repository. No comment provided by engineer. @@ -6390,11 +6921,6 @@ Schakel dit in in *Netwerk en servers*-instellingen. Ontvangen op: %@ copied message info - - Received file event - Ontvangen bestandsgebeurtenis - notification - Received message Ontvangen bericht @@ -6532,6 +7058,26 @@ swipe action Lid afwijzen? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Relay server wordt alleen gebruikt als dat nodig is. Een andere partij kan uw IP-adres zien. @@ -6542,10 +7088,22 @@ swipe action Relay server beschermt uw IP-adres, maar kan de duur van het gesprek observeren. No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove Verwijderen - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6569,13 +7127,21 @@ swipe action Remove member? Lid verwijderen? - No comment provided by engineer. + alert title Remove passphrase from keychain? Wachtwoord van de keychain verwijderen? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. No comment provided by engineer. @@ -6809,6 +7375,10 @@ swipe action SOCKS proxy No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files Veilig bestanden ontvangen @@ -6834,6 +7404,10 @@ chat item action Save (and notify members) alert button + + Save (and notify subscribers) + alert button + Save admission settings? Toegangsinstellingen opslaan? @@ -6849,6 +7423,10 @@ chat item action Opslaan en groep leden melden No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect Opslaan en opnieuw verbinden @@ -6859,6 +7437,14 @@ chat item action Groep profiel opslaan en bijwerken No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile Groep profiel opslaan @@ -6983,11 +7569,31 @@ chat item action Zoekbalk accepteert uitnodigingslinks. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Zoeken of plak een SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Secundair @@ -7013,6 +7619,10 @@ chat item action Beveiligingscode No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select Selecteer @@ -7140,6 +7750,10 @@ chat item action Send request without message No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Stuur ze vanuit de galerij of aangepaste toetsenborden. @@ -7150,6 +7764,10 @@ chat item action Stuur tot 100 laatste berichten naar nieuwe leden. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. No comment provided by engineer. @@ -7164,6 +7782,10 @@ chat item action De afzender heeft mogelijk het verbindingsverzoek verwijderd. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Het verzenden van ontvangst bevestiging wordt ingeschakeld voor alle contacten in alle zichtbare chatprofielen. @@ -7219,11 +7841,6 @@ chat item action Direct verzonden No comment provided by engineer. - - Sent file event - Verzonden bestandsgebeurtenis - notification - Sent message Verzonden bericht @@ -7294,6 +7911,10 @@ chat item action Serverprotocol gewijzigd. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. Server vereist autorisatie om wachtrijen te maken, controleer wachtwoord @@ -7423,6 +8044,14 @@ chat item action Instellingen zijn gewijzigd. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images Vorm profiel afbeeldingen @@ -7459,11 +8088,14 @@ chat item action Adres openbaar delen No comment provided by engineer. - - Share address with contacts? - Adres delen met contacten? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. Delen vanuit andere apps. @@ -7487,6 +8119,10 @@ chat item action Profiel delen No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link Deel deze eenmalige uitnodigingslink @@ -7497,9 +8133,12 @@ chat item action Delen op SimpleX No comment provided by engineer. - - Share with contacts - Delen met contacten + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -7669,8 +8308,8 @@ chat item action SimpleX-protocollen beoordeeld door Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7746,6 +8385,11 @@ report reason Vierkant, cirkel of iets daartussenin. No comment provided by engineer. + + Star on GitHub + Star on GitHub + No comment provided by engineer. + Start chat Begin gesprek @@ -7846,6 +8490,63 @@ report reason Subscribed No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors Subscription fouten @@ -7925,6 +8626,10 @@ report reason Foto nemen No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat No comment provided by engineer. @@ -7937,9 +8642,8 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Tik op SimpleX-adres maken in het menu om het later te maken. + + Tap Join channel No comment provided by engineer. @@ -7971,6 +8675,10 @@ report reason Tik hier om incognito lid te worden No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link Tik hier om de link te plakken @@ -7989,13 +8697,18 @@ report reason Test failed at step %@. Test mislukt bij stap %@. - server test failure + relay test failure +server test failure Test notifications Testmeldingen No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server Server test @@ -8047,6 +8760,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. De app beschermt uw privacy door in elk gesprek andere operatoren te gebruiken. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). De app vraagt om downloads van onbekende bestandsservers (behalve .onion) te bevestigen. @@ -8062,6 +8779,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. De code die u heeft gescand is geen SimpleX link QR-code. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. De verbinding heeft de limiet van niet-afgeleverde berichten bereikt. Uw contactpersoon is mogelijk offline. @@ -8087,9 +8808,9 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. De versleuteling werkt en de nieuwe versleutelingsovereenkomst is niet vereist. Dit kan leiden tot verbindingsfouten! No comment provided by engineer. - - The future of messaging - De volgende generatie privéberichten + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -8126,6 +8847,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. De oude database is niet verwijderd tijdens de migratie, deze kan worden verwijderd. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Dezelfde voorwaarden gelden voor operator **%@**. @@ -8171,6 +8896,14 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Thema's No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. Deze voorwaarden zijn ook van toepassing op: **%@**. @@ -8236,6 +8969,14 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze groep bestaat niet meer. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Voor deze link is een nieuwere app-versie vereist. Werk de app bij of vraag je contactpersoon om een compatibele link te sturen. @@ -8284,6 +9025,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Om ongewenste berichten te verbergen. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection Om een nieuwe verbinding te maken @@ -8369,11 +9114,6 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Vergelijk (of scan) de code op uw apparaten om end-to-end-codering met uw contact te verifiëren. No comment provided by engineer. - - Toggle chat list: - Chatlijst wisselen: - No comment provided by engineer. - Toggle incognito when connecting. Schakel incognito in tijdens het verbinden. @@ -8389,6 +9129,10 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc De transparantie van de werkbalk No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total Totaal @@ -8404,15 +9148,9 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Transportsessies No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8459,6 +9197,10 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Lid deblokkeren? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages Niet afgeleverde berichten @@ -8559,13 +9301,17 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Unsupported connection link Niet-ondersteunde verbindingslink - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Er worden maximaal 100 laatste berichten naar nieuwe leden verzonden. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Update @@ -8685,11 +9431,6 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik TCP-poort 443 alleen voor vooraf ingestelde servers. No comment provided by engineer. - - Use chat - Gebruik chat - No comment provided by engineer. - Use current profile Gebruik het huidige profiel @@ -8705,6 +9446,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik voor berichten No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections Gebruik voor nieuwe verbindingen @@ -8744,6 +9489,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik privéroutering met onbekende servers. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server Gebruik server @@ -8764,6 +9513,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik de app met één hand. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port Gebruik een webpoort @@ -8784,6 +9537,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik SimpleX Chat servers. No comment provided by engineer. + + Verify + relay test step + Verify code with desktop Code verifiëren met desktop @@ -8844,6 +9601,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak De video wordt ontvangen wanneer uw contact online is, even geduld a.u.b. of kijk later! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Video's en bestanden tot 1 GB @@ -8899,6 +9660,18 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Spraakbericht… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... Wachten op desktop... @@ -8939,6 +9712,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Waarschuwing: u kunt sommige gegevens verliezen! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICE servers @@ -8988,6 +9765,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Wanneer je een incognito profiel met iemand deelt, wordt dit profiel gebruikt voor de groepen waarvoor ze je uitnodigen. No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi Wifi @@ -9115,16 +9896,19 @@ Repeat join request? Deelnameverzoek herhalen? new chat sheet title - - You are connected to the server used to receive messages from this contact. - U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Je bent uitgenodigd voor de groep No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. U bent niet verbonden met deze servers. Privéroutering wordt gebruikt om berichten bij hen af te leveren. @@ -9195,6 +9979,10 @@ Deelnameverzoek herhalen? U kunt een voorbeeld van een melding op het vergrendeld scherm instellen via instellingen. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. U kunt een link of een QR-code delen. Iedereen kan lid worden van de groep. U verliest geen leden van de groep als u deze later verwijdert. @@ -9240,16 +10028,21 @@ Deelnameverzoek herhalen? Je kunt geen berichten versturen! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. U kon niet worden geverifieerd; probeer het opnieuw. No comment provided by engineer. - - You decide who can connect. - Jij bepaalt wie er verbinding mag maken. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9317,6 +10110,10 @@ Verbindingsverzoek herhalen? U zou meldingen moeten ontvangen. token info + + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. No comment provided by engineer. @@ -9351,6 +10148,10 @@ Verbindingsverzoek herhalen? U ontvangt nog steeds oproepen en meldingen van gedempte profielen wanneer deze actief zijn. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. U ontvangt geen berichten meer van deze chat. De chatgeschiedenis blijft bewaard. @@ -9395,6 +10196,10 @@ Verbindingsverzoek herhalen? Uw oproepen No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database Uw chat database @@ -9443,6 +10248,10 @@ Verbindingsverzoek herhalen? Uw contacten blijven verbonden. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. Uw inloggegevens worden mogelijk niet-versleuteld verzonden. @@ -9462,6 +10271,10 @@ Verbindingsverzoek herhalen? Your group No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences Jouw voorkeuren @@ -9477,6 +10290,11 @@ Verbindingsverzoek herhalen? Jouw profiel No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Uw profiel **%@** wordt gedeeld. @@ -9497,11 +10315,23 @@ Verbindingsverzoek herhalen? Je profiel is gewijzigd. Als je het opslaat, wordt het bijgewerkte profiel naar al je contacten verzonden. alert message + + Your public address + No comment provided by engineer. + Your random profile Je willekeurige profiel No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address Uw server adres @@ -9517,21 +10347,11 @@ Verbindingsverzoek herhalen? Uw instellingen No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Bijdragen](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Stuur ons een e-mail](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_cursief_ @@ -9547,6 +10367,10 @@ Verbindingsverzoek herhalen? hier boven, kies dan: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ geaccepteerd %@ @@ -9567,6 +10391,10 @@ Verbindingsverzoek herhalen? heb je geaccepteerd rcv group event chat item + + active + No comment provided by engineer. + admin Beheerder @@ -9678,6 +10506,10 @@ marked deleted chat item preview text bellen… call status + + can't broadcast + No comment provided by engineer. + can't send messages kan geen berichten versturen @@ -9713,6 +10545,14 @@ marked deleted chat item preview text adres wijzigen… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored gekleurd @@ -9858,6 +10698,10 @@ pref value verwijderd deleted chat item + + deleted channel + rcv group event chat item + deleted contact verwijderd contact @@ -9968,11 +10812,19 @@ pref value fout No comment provided by engineer. + + error: %@ + receive error chat item + expired verlopen No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded doorgestuurd @@ -10092,6 +10944,10 @@ pref value is vertrokken rcv group event chat item + + link + No comment provided by engineer. + marked deleted gemarkeerd als verwijderd @@ -10162,6 +11018,10 @@ pref value nooit delete after time + + new + No comment provided by engineer. + new message nieuw bericht @@ -10177,6 +11037,10 @@ pref value geen e2e versleuteling No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text geen tekst @@ -10280,6 +11144,10 @@ time to disappear geweigerde oproep call status + + relay + member role + removed verwijderd @@ -10290,6 +11158,14 @@ time to disappear verwijderd %@ rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address contactadres verwijderd @@ -10441,6 +11317,10 @@ laatst ontvangen bericht: %2$@ onbeschermd No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile bijgewerkt groep profiel @@ -10461,6 +11341,10 @@ laatst ontvangen bericht: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link via contact adres link @@ -10536,6 +11420,10 @@ laatst ontvangen bericht: %2$@ je bent waarnemer No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ je hebt %@ geblokkeerd @@ -10596,6 +11484,10 @@ laatst ontvangen bericht: %2$@ \~staking~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index d08a7d86e5..b232aa84af 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -185,6 +185,21 @@ %d miesięcy time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d sek @@ -200,11 +215,53 @@ %d pominięte wiadomość(i) integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d tygodni time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +272,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld wybrany(e) kontakt(y) @@ -315,11 +376,19 @@ %u pominiętych wiadomości. No comment provided by engineer. + + (from owner) + chat link info line + (new) (nowy) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (to urządzenie v%@) @@ -365,6 +434,10 @@ **Zeskanuj / Wklej link**: aby połączyć się za pomocą otrzymanego linku. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Uwaga**: Natychmiastowe powiadomienia push wymagają zapisania kodu dostępu w Keychain. @@ -408,6 +481,12 @@ - i więcej! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -498,7 +577,7 @@ time interval <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> <p>Cześć!</p> -<p><a href="%@">Połącz się ze mną poprzez SimpleX Chat.</a></p> +<p><a href="%@">Połącz się ze mną poprzez SimpleX Chat</a></p> email text @@ -506,6 +585,10 @@ time interval Jeszcze kilka rzeczy No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact Nowy kontakt @@ -568,10 +651,12 @@ swipe action Accept as member + Zaakceptuj jako członka alert action Accept as observer + Zaakceptuj jako obserwatora alert action @@ -586,6 +671,7 @@ swipe action Accept contact request + Zaakceptuj prośby o kontakt alert title @@ -601,6 +687,7 @@ swipe action Accept member + Zaakceptuj członka alert title @@ -628,9 +715,8 @@ swipe action Aktywne połączenia No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -645,6 +731,7 @@ swipe action Add message + Dodaj wiadomość placeholder for sending contact request @@ -697,6 +784,10 @@ swipe action Dodano serwery wiadomości No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent Dodatkowy akcent @@ -787,6 +878,11 @@ swipe action Wszyscy członkowie grupy pozostaną połączeni. No comment provided by engineer. + + All messages + Wszystkie wiadomości + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich. @@ -812,6 +908,14 @@ swipe action Wszystkie profile profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. Wszystkie raporty zostaną dla Ciebie zarchiwizowane. @@ -819,6 +923,7 @@ swipe action All servers + Wszystkie serwery No comment provided by engineer. @@ -863,6 +968,7 @@ swipe action Allow files and media only if your contact allows them. + Zezwalaj na pliki i media tylko wtedy, gdy Twój kontakt na to pozwala. No comment provided by engineer. @@ -870,6 +976,10 @@ swipe action Zezwalaj na nieodwracalne usuwanie wiadomości tylko wtedy, gdy Twój kontakt Ci na to pozwoli. (24 godziny) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Zezwalaj na reakcje wiadomości tylko wtedy, gdy zezwala na to Twój kontakt. @@ -885,6 +995,10 @@ swipe action Zezwalaj na wysyłanie bezpośrednich wiadomości do członków. No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Zezwól na wysyłanie znikających wiadomości. @@ -895,6 +1009,10 @@ swipe action Zezwól na udostępnianie No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Zezwól na nieodwracalne usunięcie wysłanych wiadomości. (24 godziny) @@ -952,6 +1070,7 @@ swipe action Allow your contacts to send files and media. + Pozwól kontaktom wysyłać pliki i media. No comment provided by engineer. @@ -999,11 +1118,6 @@ swipe action Odbierz połączenie No comment provided by engineer. - - Anybody can host servers. - Każdy może hostować serwery. - No comment provided by engineer. - App build: %@ Kompilacja aplikacji: %@ @@ -1134,6 +1248,11 @@ swipe action Połączenia audio i wideo No comment provided by engineer. + + Audio call + Połączenie audio + No comment provided by engineer. + Audio/video calls Połączenia audio/wideo @@ -1204,6 +1323,21 @@ swipe action Zły hash wiadomości No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + Ciesz się swobodą w swojej sieci. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Ponieważ zniszczyliśmy moc pozwalającą poznać, kim jesteś. Więc twoja moc nigdy nie będzie Ci odebrana. + No comment provided by engineer. + Better calls Lepsze połączenia @@ -1216,6 +1350,7 @@ swipe action Better groups performance + Lepsze działanie grup No comment provided by engineer. @@ -1240,6 +1375,7 @@ swipe action Better privacy and security + Lepsza prywatność i bezpieczeństwo No comment provided by engineer. @@ -1254,10 +1390,12 @@ swipe action Bio + Bio No comment provided by engineer. Bio too large + Bio jest za długie alert title @@ -1295,6 +1433,10 @@ swipe action Zablokować członka? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin Zablokowany przez admina @@ -1312,6 +1454,7 @@ swipe action Bot + Bot No comment provided by engineer. @@ -1336,6 +1479,7 @@ swipe action Both you and your contact can send files and media. + Zarówno Ty, jak i Twój kontakt możecie wysyłać pliki i media. No comment provided by engineer. @@ -1343,6 +1487,14 @@ swipe action Zarówno Ty, jak i Twój kontakt możecie wysyłać wiadomości głosowe. No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bułgarski, fiński, tajski i ukraiński – dzięki użytkownikom i [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1351,7 +1503,7 @@ swipe action Business address Adres firmowy - No comment provided by engineer. + chat link info line Business chats @@ -1360,6 +1512,7 @@ swipe action Business connection + Kontakty biznesowe No comment provided by engineer. @@ -1372,12 +1525,6 @@ swipe action Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - No comment provided by engineer. - Call already ended! Połączenie już zakończone! @@ -1410,6 +1557,7 @@ swipe action Can't change profile + Nie można zmienić profilu alert title @@ -1471,6 +1619,7 @@ new chat action Change automatic message deletion? + Zmienić automatyczne usuwanie wiadomości? alert title @@ -1524,6 +1673,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat Czat @@ -1609,6 +1819,22 @@ set passcode view Profil użytkownika No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme Motyw czatu @@ -1626,14 +1852,18 @@ set passcode view Chat with admins - chat toolbar + Czatuj z administratorami + chat feature +chat toolbar Chat with member + Czatuj z członkiem No comment provided by engineer. Chat with members before they join. + Porozmawiaj z członkami, zanim dołączą. No comment provided by engineer. @@ -1641,8 +1871,21 @@ set passcode view Czaty No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members + Czaty z członkami + No comment provided by engineer. + + + Chats with members are disabled No comment provided by engineer. @@ -1655,6 +1898,14 @@ set passcode view Sprawdź wiadomości, gdy będzie to dopuszczone. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. Sprawdź adres serwera i spróbuj ponownie. @@ -1712,10 +1963,12 @@ set passcode view Clear group? + Wyczyścić grupę? No comment provided by engineer. Clear or delete group? + Wyczyścić lub usunąć grupę? No comment provided by engineer. @@ -1740,6 +1993,7 @@ set passcode view Community guidelines violation + Naruszenie zasad społeczności report reason @@ -1775,18 +2029,21 @@ set passcode view Conditions of use Warunki użytkowania - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. + Warunki zostaną zaakceptowane dla operatora(-ów): **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Warunki zostaną zaakceptowane w dniu: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Warunki zostaną automatycznie zaakceptowane dla aktywnych operatorów w dniu: %@. No comment provided by engineer. @@ -1794,8 +2051,8 @@ set passcode view Skonfiguruj serwery ICE No comment provided by engineer. - - Configure server operators + + Configure relays No comment provided by engineer. @@ -1850,12 +2107,14 @@ set passcode view Confirmed + Potwierdzony token status text Connect Połącz - server test step + relay test step +server test step Connect automatically @@ -1864,6 +2123,7 @@ set passcode view Connect faster! 🚀 + Połącz się szybciej! 🚀 No comment provided by engineer. @@ -1900,6 +2160,10 @@ To jest twój jednorazowy link! Połącz się przez link new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Połącz przez jednorazowy link @@ -1967,6 +2231,7 @@ To jest twój jednorazowy link! Connection blocked + Połączenie zablokowane No comment provided by engineer. @@ -1977,15 +2242,23 @@ To jest twój jednorazowy link! Connection error (AUTH) Błąd połączenia (UWIERZYTELNIANIE) + conn error description + + + Connection failed + Połączenie nie powiodło się No comment provided by engineer. Connection is blocked by server operator: %@ + Połączenie zostało zablokowane przez operatora serwera: +%@ No comment provided by engineer. Connection not ready. + Połączenie nie jest gotowe. No comment provided by engineer. @@ -2000,10 +2273,12 @@ To jest twój jednorazowy link! Connection requires encryption renegotiation. + Połączenie wymaga renegocjacji szyfrowania. No comment provided by engineer. Connection security + Bezpieczeństwo połączenia No comment provided by engineer. @@ -2026,6 +2301,10 @@ To jest twój jednorazowy link! Połączenia No comment provided by engineer. + + Contact address + chat link info line + Contact allows Kontakt pozwala @@ -2068,6 +2347,7 @@ To jest twój jednorazowy link! Contact requests from groups + Prośby o kontakt od grup No comment provided by engineer. @@ -2087,6 +2367,7 @@ To jest twój jednorazowy link! Content violates conditions of use + Treść narusza warunki użytkowania blocking reason @@ -2094,6 +2375,11 @@ To jest twój jednorazowy link! Kontynuuj No comment provided by engineer. + + Contribute + Przyczyń się + No comment provided by engineer. + Conversation deleted! Rozmowa usunięta! @@ -2122,15 +2408,11 @@ To jest twój jednorazowy link! Correct name to %@? Poprawić imię na %@? - No comment provided by engineer. - - - Create - Utwórz - No comment provided by engineer. + alert message Create 1-time link + Utwórz jednorazowy link No comment provided by engineer. @@ -2165,6 +2447,7 @@ To jest twój jednorazowy link! Create list + Utwórz listę No comment provided by engineer. @@ -2177,6 +2460,14 @@ To jest twój jednorazowy link! Utwórz profil No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue Utwórz kolejkę @@ -2184,6 +2475,11 @@ To jest twój jednorazowy link! Create your address + Utwórz swój adres + No comment provided by engineer. + + + Create your link No comment provided by engineer. @@ -2191,6 +2487,10 @@ To jest twój jednorazowy link! Utwórz swój profil No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created Utworzono @@ -2211,6 +2511,10 @@ To jest twój jednorazowy link! Tworzenie linku archiwum No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… Tworzenie linku… @@ -2223,6 +2527,7 @@ To jest twój jednorazowy link! Current conditions text couldn't be loaded, you can review conditions via this link: + Nie można załadować tekstu dotyczącego aktualnych warunków. Możesz zapoznać się z warunkami, klikając ten link: No comment provided by engineer. @@ -2247,6 +2552,7 @@ To jest twój jednorazowy link! Customizable message shape. + Konfigurowalny kształt wiadomości. No comment provided by engineer. @@ -2367,10 +2673,9 @@ To jest twój jednorazowy link! Dostarczenie debugowania No comment provided by engineer. - - Decentralized - Zdecentralizowane - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2418,12 +2723,22 @@ swipe action Usuń i powiadom kontakt No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat + Usuń czat No comment provided by engineer. Delete chat messages from your device. + Usuń wiadomości czatu ze swojego urządzenia. No comment provided by engineer. @@ -2438,10 +2753,12 @@ swipe action Delete chat with member? + Usunąć czat z członkiem? alert title Delete chat? + Usunąć czat? No comment provided by engineer. @@ -2521,6 +2838,7 @@ swipe action Delete list? + Usunąć listę? alert title @@ -2528,6 +2846,16 @@ swipe action Usunąć wiadomość członka? No comment provided by engineer. + + Delete member messages + Usuń wiadomości członków + No comment provided by engineer. + + + Delete member messages? + Usunąć wiadomości członków? + alert title + Delete message? Usunąć wiadomość? @@ -2536,7 +2864,8 @@ swipe action Delete messages Usuń wiadomości - alert button + alert action +alert button Delete messages after @@ -2555,6 +2884,7 @@ swipe action Delete or moderate up to 200 messages. + Usuń lub moderuj do 200 wiadomości. No comment provided by engineer. @@ -2572,8 +2902,13 @@ swipe action Usuń kolejkę server test step + + Delete relay + No comment provided by engineer. + Delete report + Usuń raport No comment provided by engineer. @@ -2613,6 +2948,7 @@ swipe action Delivered even when Apple drops them. + Dostarczane nawet wtedy, gdy Apple je wycofa. No comment provided by engineer. @@ -2632,6 +2968,7 @@ swipe action Deprecated options + Opcje wycofane No comment provided by engineer. @@ -2641,6 +2978,7 @@ swipe action Description too large + Opis jest zbyt długi alert title @@ -2725,6 +3063,7 @@ swipe action Direct messages between members are prohibited in this chat. + W tym czacie zabronione jest wysyłanie bezpośrednich wiadomości między członkami. No comment provided by engineer. @@ -2732,6 +3071,14 @@ swipe action Bezpośrednie wiadomości między członkami są zabronione w tej grupie. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) Wyłącz (zachowaj nadpisania) @@ -2744,10 +3091,12 @@ swipe action Disable automatic message deletion? + Wyłączyć automatyczne usuwanie wiadomości? alert title Disable delete messages + Wyłącz usuwanie wiadomości alert button @@ -2835,6 +3184,10 @@ swipe action Nie wysyłaj historii do nowych członków. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. Nie używaj danych logowania do proxy. @@ -2842,6 +3195,7 @@ swipe action Documents: + Dokumenty: No comment provided by engineer. @@ -2856,6 +3210,7 @@ swipe action Don't miss important messages. + Nie przegap ważnych wiadomości. No comment provided by engineer. @@ -2865,6 +3220,7 @@ swipe action Done + Gotowe No comment provided by engineer. @@ -2930,6 +3286,11 @@ chat item action E2E encrypted notifications. + Powiadomienia szyfrowane E2E. + No comment provided by engineer. + + + Easier to invite your friends 👋 No comment provided by engineer. @@ -2937,6 +3298,10 @@ chat item action Edytuj chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile Edytuj profil grupy @@ -2944,12 +3309,13 @@ chat item action Empty message! + Pusta wiadomość! No comment provided by engineer. Enable Włącz - No comment provided by engineer. + alert button Enable (keep overrides) @@ -2958,6 +3324,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Włącz opcję Flux w ustawieniach sieci i serwerów, aby zapewnić lepszą prywatność metadanych. No comment provided by engineer. @@ -2970,6 +3337,10 @@ chat item action Włącz utrzymywanie aktywności TCP No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Czy włączyć automatyczne usuwanie wiadomości? @@ -2980,8 +3351,13 @@ chat item action Włącz dostęp do kamery No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. + Włącz domyślnie znikające wiadomości. No comment provided by engineer. @@ -2999,16 +3375,15 @@ chat item action Włączyć natychmiastowe powiadomienia? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock Włącz blokadę No comment provided by engineer. - - Enable notifications - Włącz powiadomienia - No comment provided by engineer. - Enable periodic notifications? Włączyć okresowe powiadomienia? @@ -3106,6 +3481,7 @@ chat item action Encryption renegotiation in progress. + Trwa renegocjacja szyfrowania. No comment provided by engineer. @@ -3113,6 +3489,10 @@ chat item action Wprowadź Pin No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Wprowadź poprawne hasło. @@ -3138,6 +3518,14 @@ chat item action Wprowadź hasło powyżej, aby pokazać! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually Wprowadź serwer ręcznie @@ -3166,7 +3554,7 @@ chat item action Error Błąd - No comment provided by engineer. + conn error description Error aborting address change @@ -3175,6 +3563,7 @@ chat item action Error accepting conditions + Błąd podczas akceptacji warunków alert title @@ -3184,6 +3573,7 @@ chat item action Error accepting member + Błąd podczas akceptacji członka alert title @@ -3191,12 +3581,18 @@ chat item action Błąd dodawania członka(ów) No comment provided by engineer. + + Error adding relay + alert title + Error adding server + Błąd podczas dodawania serwera alert title Error adding short link + Błąd dodawania krótkiego linku No comment provided by engineer. @@ -3206,6 +3602,7 @@ chat item action Error changing chat profile + Błąd zmiany profilu czatu alert title @@ -3230,6 +3627,7 @@ chat item action Error checking token status + Błąd sprawdzania statusu tokenu No comment provided by engineer. @@ -3237,11 +3635,20 @@ chat item action Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Błąd połączenia z serwerem używanym do odbierania wiadomości z tego połączenia: %@ + subscription status explanation + Error creating address Błąd tworzenia adresu No comment provided by engineer. + + Error creating channel + alert title + Error creating group Błąd tworzenia grupy @@ -3254,6 +3661,7 @@ chat item action Error creating list + Błąd tworzenia listy alert title @@ -3273,6 +3681,7 @@ chat item action Error creating report + Błąd tworzenia raportu No comment provided by engineer. @@ -3282,6 +3691,7 @@ chat item action Error deleting chat + Błąd usuwania czatu alert title @@ -3361,6 +3771,7 @@ chat item action Error loading servers + Błąd ładowania serwerów alert title @@ -3373,10 +3784,6 @@ chat item action Błąd otwierania czatu No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file Błąd odbioru pliku @@ -3394,10 +3801,12 @@ chat item action Error registering for notifications + Błąd rejestracji powiadomień alert title Error rejecting contact request + Błąd odrzucenia prośby o kontakt alert title @@ -3407,6 +3816,7 @@ chat item action Error reordering lists + Błąd ponownego porządkowania list alert title @@ -3419,8 +3829,13 @@ chat item action Błąd zapisu serwerów ICE No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list + Błąd zapisywania listy czatów alert title @@ -3440,6 +3855,7 @@ chat item action Error saving servers + Błąd zapisywania serwerów alert title @@ -3474,6 +3890,7 @@ chat item action Error setting auto-accept + Błąd ustawiania automatycznego akceptowania No comment provided by engineer. @@ -3481,6 +3898,10 @@ chat item action Błąd ustawiania potwierdzeń dostawy! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat Błąd uruchamiania czatu @@ -3508,6 +3929,7 @@ chat item action Error testing server connection + Błąd testowania połączenia z serwerem No comment provided by engineer. @@ -3522,6 +3944,7 @@ chat item action Error updating server + Błąd aktualizacji serwera alert title @@ -3558,7 +3981,9 @@ snd error text Error: %@. - server test error + Błąd: %@. + relay test error +server test error Error: URL is invalid @@ -3577,6 +4002,7 @@ snd error text Errors in servers configuration. + Błędy w konfiguracji serwerów. servers error @@ -3596,6 +4022,7 @@ snd error text Expired + Wygasło token status text @@ -3640,6 +4067,7 @@ snd error text Faster deletion of groups. + Szybsze usuwanie grup. No comment provided by engineer. @@ -3649,6 +4077,7 @@ snd error text Faster sending messages. + Szybsze wysyłanie wiadomości. No comment provided by engineer. @@ -3658,6 +4087,7 @@ snd error text Favorites + Ulubione No comment provided by engineer. @@ -3675,6 +4105,8 @@ snd error text File is blocked by server operator: %@. + Plik jest zablokowany przez operatora serwera: +%@. file error text @@ -3734,6 +4166,7 @@ snd error text Files and media are prohibited in this chat. + W tym czacie nie wolno przesyłać plików ani multimediów. No comment provided by engineer. @@ -3751,6 +4184,11 @@ snd error text Pliki i media zabronione! No comment provided by engineer. + + Filter + Filtr + No comment provided by engineer. + Filter unread and favorite chats. Filtruj nieprzeczytane i ulubione czaty. @@ -3778,19 +4216,23 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + Odcisk palca w adresie serwera docelowego nie zgadza się z certyfikatem: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + Odcisk palca w adresie serwera przekazującego nie zgadza się z certyfikatem: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy - server test error + Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy. + relay test error +server test error Fingerprint in server address does not match certificate: %@. + Odcisk palca w adresie serwera nie zgadza się z certyfikatem: %@. No comment provided by engineer. @@ -3825,11 +4267,18 @@ snd error text For all moderators + Dla wszystkich moderatorów + No comment provided by engineer. + + + For anyone to reach you No comment provided by engineer. For chat profile %@: - servers error + Dla profilu czatu %@: + servers error +servers warning For console @@ -3838,18 +4287,22 @@ snd error text For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Na przykład, jeśli Twój kontakt odbiera wiadomości za pośrednictwem serwera SimpleX Chat, Twoja aplikacja będzie je dostarczać za pośrednictwem serwera Flux. No comment provided by engineer. For me + Dla mnie No comment provided by engineer. For private routing + Dla prywatnego routingu No comment provided by engineer. For social media + Dla mediów społecznościowych No comment provided by engineer. @@ -3879,6 +4332,7 @@ snd error text Forward up to 20 messages at once. + Przekaż jednocześnie do 20 wiadomości. No comment provided by engineer. @@ -3965,8 +4419,17 @@ Błąd: %2$@ GIF-y i naklejki No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. + Otrzymuj powiadomienia, gdy ktoś wspomni o Tobie. + No comment provided by engineer. + + + Get started No comment provided by engineer. @@ -4027,7 +4490,7 @@ Błąd: %2$@ Group link Link do grupy - No comment provided by engineer. + chat link info line Group links @@ -4061,6 +4524,7 @@ Błąd: %2$@ Group profile was changed. If you save it, the updated profile will be sent to group members. + Profil grupy został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do członków grupy. alert message @@ -4080,6 +4544,7 @@ Błąd: %2$@ Groups + Grupy No comment provided by engineer. @@ -4089,6 +4554,7 @@ Błąd: %2$@ Help admins moderating their groups. + Pomóż administratorom moderować ich grupy. No comment provided by engineer. @@ -4136,6 +4602,10 @@ Błąd: %2$@ Historia nie jest wysyłana do nowych członków. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works Jak działa SimpleX @@ -4143,14 +4613,17 @@ Błąd: %2$@ How it affects privacy + Jak to wpływa na prywatność No comment provided by engineer. How it helps privacy + Jak to pomaga chronić prywatność No comment provided by engineer. How it works + Jak to działa alert button @@ -4198,6 +4671,11 @@ Błąd: %2$@ Jeśli wpiszesz swój pin samodestrukcji podczas otwierania aplikacji: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + Jeśli dołączyłeś do kanałów lub je utworzyłeś, przestaną one działać na stałe. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Jeśli potrzebujesz użyć czatu teraz, dotknij **Zrób to później** poniżej (zostanie Ci zaproponowana migracja bazy danych po ponownym uruchomieniu aplikacji). @@ -4218,16 +4696,16 @@ Błąd: %2$@ Obraz zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później! No comment provided by engineer. + + Images + Zdjęcia + No comment provided by engineer. + Immediately Natychmiast No comment provided by engineer. - - Immune to spam - Odporność na spam i nadużycia - No comment provided by engineer. - Import Importuj @@ -4261,6 +4739,8 @@ Błąd: %2$@ Improved delivery, reduced traffic usage. More improvements are coming soon! + Ulepszona dostawa, mniejsze zużycie ruchu. +Wkrótce pojawią się kolejne ulepszenia! No comment provided by engineer. @@ -4295,10 +4775,12 @@ More improvements are coming soon! Inappropriate content + Nieodpowiednia treść report reason Inappropriate profile + Nieodpowiedni profil report reason @@ -4366,9 +4848,9 @@ More improvements are coming soon! Rola początkowa No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Zainstaluj [SimpleX Chat na terminal](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Zainstaluj SimpleX Chat na terminal No comment provided by engineer. @@ -4395,22 +4877,27 @@ More improvements are coming soon! Invalid + Nieprawidłowy token status text Invalid (bad token) + Nieprawidłowy (zły token) token status text Invalid (expired) + Nieważny (wygasły) token status text Invalid (unregistered) + Nieprawidłowy (niezarejestrowany) token status text Invalid (wrong topic) + Nieprawidłowy (niewłaściwy temat) token status text @@ -4421,7 +4908,7 @@ More improvements are coming soon! Invalid connection link Nieprawidłowy link połączenia - No comment provided by engineer. + conn error description Invalid display name! @@ -4441,7 +4928,15 @@ More improvements are coming soon! Invalid name! Nieprawidłowa nazwa! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4468,13 +4963,23 @@ More improvements are coming soon! Zaproś znajomych No comment provided by engineer. + + Invite member + Zaproś członka + No comment provided by engineer. + Invite members Zaproś członków No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat + Zaproś do czatu No comment provided by engineer. @@ -4548,6 +5053,10 @@ More improvements are coming soon! dołącz jako %@ No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group Dołącz do grupy @@ -4597,6 +5106,7 @@ To jest twój link do grupy %@! Keep your chats clean + Utrzymuj czystość swoich czatów No comment provided by engineer. @@ -4634,12 +5144,22 @@ To jest twój link do grupy %@! Opuść swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat + Opuść czat No comment provided by engineer. Leave chat? + Opuścić czat? No comment provided by engineer. @@ -4654,6 +5174,11 @@ To jest twój link do grupy %@! Less traffic on mobile networks. + Mniejszy ruch w sieciach komórkowych. + No comment provided by engineer. + + + Let someone connect to you No comment provided by engineer. @@ -4676,6 +5201,10 @@ To jest twój link do grupy %@! Połącz mobile i komputerowe aplikacje! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options Połączone opcje komputera @@ -4686,16 +5215,24 @@ To jest twój link do grupy %@! Połączone komputery No comment provided by engineer. + + Links + Linki + No comment provided by engineer. + List + Lista swipe action List name and emoji should be different for all lists. + Nazwa listy i emoji powinny być różne dla wszystkich list. No comment provided by engineer. List name... + Nazwa listy... No comment provided by engineer. @@ -4710,6 +5247,7 @@ To jest twój link do grupy %@! Loading profile… + Ładowanie profilu… in progress text @@ -4789,10 +5327,12 @@ To jest twój link do grupy %@! Member %@ + Członek %@ past/unknown group member Member admission + Przyjmowanie członków No comment provided by engineer. @@ -4802,14 +5342,22 @@ To jest twój link do grupy %@! Member is deleted - can't accept request + Członek został usunięty – nie można zaakceptować prośby No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Wiadomości członków zostaną usunięte – nie można tego cofnąć! + alert message + Member reports + Raporty członków chat feature Member role will be changed to "%@". All chat members will be notified. + Rola członka zostanie zmieniona na "%@". Wszyscy członkowie czatu zostaną o tym poinformowani. No comment provided by engineer. @@ -4824,15 +5372,17 @@ To jest twój link do grupy %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + Członek zostanie usunięty z czatu – nie można tego cofnąć! + alert message Member will be removed from group - this cannot be undone! Członek zostanie usunięty z grupy - nie można tego cofnąć! - No comment provided by engineer. + alert message Member will join the group, accept member? + Członek dołączy do grupy, zaakceptować członka? alert message @@ -4840,6 +5390,10 @@ To jest twój link do grupy %@! Członkowie grupy mogą dodawać reakcje wiadomości. No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) @@ -4847,6 +5401,7 @@ To jest twój link do grupy %@! Members can report messsages to moderators. + Członkowie mogą zgłaszać wiadomości moderatorom. No comment provided by engineer. @@ -4876,6 +5431,7 @@ To jest twój link do grupy %@! Mention members 👋 + Wspomnij członków 👋 No comment provided by engineer. @@ -4903,6 +5459,10 @@ To jest twój link do grupy %@! Wersja robocza wiadomości No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded Wiadomość przekazana @@ -4910,6 +5470,7 @@ To jest twój link do grupy %@! Message instantly once you tap Connect. + Wysyłaj wiadomości natychmiast po dotknięciu przycisku „Połącz”. No comment provided by engineer. @@ -4989,6 +5550,7 @@ To jest twój link do grupy %@! Messages are protected by **end-to-end encryption**. + Wiadomości są chronione przez **szyfrowanie typu end-to-end**. No comment provided by engineer. @@ -4996,8 +5558,17 @@ To jest twój link do grupy %@! Wiadomości od %@ zostaną pokazane! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. + Wiadomości na tym czacie nigdy nie zostaną usunięte. alert message @@ -5025,16 +5596,15 @@ To jest twój link do grupy %@! Wiadomości, pliki i połączenia są chronione przez **kwantowo odporne szyfrowanie end-to-end** z doskonałym utajnianiem z wyprzedzeniem i odzyskiem po złamaniu. No comment provided by engineer. + + Migrate + No comment provided by engineer. + Migrate device Zmigruj urządzenie No comment provided by engineer. - - Migrate from another device - Zmigruj z innego urządzenia - No comment provided by engineer. - Migrate here Zmigruj tutaj @@ -5102,6 +5672,7 @@ To jest twój link do grupy %@! More + Więcej swipe action @@ -5116,6 +5687,7 @@ To jest twój link do grupy %@! More reliable notifications + Bardziej niezawodne powiadomienia No comment provided by engineer. @@ -5135,6 +5707,7 @@ To jest twój link do grupy %@! Mute all + Wycisz wszystko notification label action @@ -5152,6 +5725,10 @@ To jest twój link do grupy %@! Sieć i serwery No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection Połączenie z siecią @@ -5159,8 +5736,13 @@ To jest twój link do grupy %@! Network decentralization + Decentralizacja sieci No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. Błąd sieciowy - wiadomość wygasła po wielu próbach wysłania jej. @@ -5173,6 +5755,12 @@ To jest twój link do grupy %@! Network operator + Operator sieci + No comment provided by engineer. + + + Network routers cannot know +who talks to whom No comment provided by engineer. @@ -5183,12 +5771,17 @@ To jest twój link do grupy %@! Network status Status sieci - No comment provided by engineer. + alert title New + Nowy token status text + + New 1-time link + No comment provided by engineer. + New Passcode Nowy Pin @@ -5214,6 +5807,10 @@ To jest twój link do grupy %@! Nowe możliwości czatu 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request Nowa prośba o kontakt @@ -5236,10 +5833,12 @@ To jest twój link do grupy %@! New events + Nowe wydarzenia notification New group role: Moderator + Nowa rola w grupie: Moderator No comment provided by engineer. @@ -5259,6 +5858,7 @@ To jest twój link do grupy %@! New member wants to join the group. + Nowy członek chce dołączyć do grupy. rcv group event chat item @@ -5273,6 +5873,7 @@ To jest twój link do grupy %@! New server + Nowy serwer No comment provided by engineer. @@ -5280,25 +5881,46 @@ To jest twój link do grupy %@! Nie No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password Brak hasła aplikacji Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats + Żadnych czatów No comment provided by engineer. No chats found + Nie znaleziono żadnych czatów No comment provided by engineer. No chats in list %@ + Brak czatów na liście %@ No comment provided by engineer. No chats with members + Żadnych rozmów z członkami No comment provided by engineer. @@ -5348,14 +5970,17 @@ To jest twój link do grupy %@! No media & file servers. + Brak mediów i serwerów plików multimedialnych. servers error No message + Brak wiadomości No comment provided by engineer. No message servers. + Brak serwerów wiadomości. servers error @@ -5380,6 +6005,7 @@ To jest twój link do grupy %@! No private routing session + Brak prywatnej sesji routingu alert title @@ -5394,33 +6020,52 @@ To jest twój link do grupy %@! No servers for private message routing. + Brak serwerów prywatnej sesji routingu. servers error No servers to receive files. + Brak serwerów do otrzymania plików. servers error No servers to receive messages. + Brak serwerów aby otrzymać wiadomości. servers error No servers to send files. + Brak serwerów do wysyłania plików. servers error No token! + Brak tokenu! alert title No unread chats + Brak nieprzeczytanych czatów No comment provided by engineer. - - No user identifiers. - Brak identyfikatorów użytkownika. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + Nikt nie śledził twoich rozmów. Nikt nie rysował mapy miejsc, w których byłeś. Prywatność nigdy nie była funkcją - była sposobem na życie. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + Nie chodzi o lepszy zamek w drzwiach kogoś innego. Nie chodzi o milszego właściciela, który szanuje twoją prywatność, ale nadal prowadzi rejestr wszystkich odwiedzających. Nie jesteś gościem. Jesteś w domu. Żaden król nie może do niego wejść - jesteś suwerenem. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! Nie kompatybilny! @@ -5428,6 +6073,7 @@ To jest twój link do grupy %@! Notes + Notatki No comment provided by engineer. @@ -5452,14 +6098,17 @@ To jest twój link do grupy %@! Notifications error + Błąd powiadomień alert title Notifications privacy + Prywatność powiadomień No comment provided by engineer. Notifications status + Stan powiadomień alert title @@ -5474,7 +6123,7 @@ To jest twój link do grupy %@! OK OK - No comment provided by engineer. + alert button Off @@ -5493,11 +6142,19 @@ new chat action Stara baza danych No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link Jednorazowy link zaproszenia No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5517,8 +6174,13 @@ Wymaga włączenia VPN. Hosty onion nie będą używane. No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. + Tylko właściciele czatu mogą zmieniać preferencje. No comment provided by engineer. @@ -5548,10 +6210,12 @@ Wymaga włączenia VPN. Only sender and moderators see it + Widzą to tylko nadawca i moderatorzy No comment provided by engineer. Only you and moderators see it + Widzisz to tylko Ty i moderatorzy No comment provided by engineer. @@ -5576,6 +6240,7 @@ Wymaga włączenia VPN. Only you can send files and media. + Tylko Ty możesz wysyłać pliki i multimedia. No comment provided by engineer. @@ -5605,6 +6270,7 @@ Wymaga włączenia VPN. Only your contact can send files and media. + Tylko Twój kontakt może wysyłać pliki i multimedia. No comment provided by engineer. @@ -5615,7 +6281,8 @@ Wymaga włączenia VPN. Open Otwórz - alert action + alert action +alert button Open Settings @@ -5624,8 +6291,13 @@ Wymaga włączenia VPN. Open changes + Otwórz zmiany No comment provided by engineer. + + Open channel + new chat action + Open chat Otwórz czat @@ -5638,14 +6310,21 @@ Wymaga włączenia VPN. Open clean link + Otwórz czysty link alert action Open conditions + Otwórz warunki No comment provided by engineer. + + Open external link? + alert title + Open full link + Otwórz pełny link alert action @@ -5655,6 +6334,7 @@ Wymaga włączenia VPN. Open link? + Otworzyć link? alert title @@ -5662,28 +6342,38 @@ Wymaga włączenia VPN. Otwórz migrację na innym urządzeniu authentication reason + + Open new channel + new chat action + Open new chat + Otwórz nowy czat new chat action Open new group + Otwórz nową grupę new chat action Open to accept + Otwórz by zaakceptować No comment provided by engineer. Open to connect + Otwórz aby się połączyć No comment provided by engineer. Open to join + Otwórz aby dołączyć No comment provided by engineer. Open to use bot + Otwórz aby skorzystać z bota No comment provided by engineer. @@ -5693,14 +6383,24 @@ Wymaga włączenia VPN. Operator + Operator No comment provided by engineer. Operator server + Serwer Operatora alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file + Lub zaimportuj plik archiwalny No comment provided by engineer. @@ -5718,6 +6418,10 @@ Wymaga włączenia VPN. Lub bezpiecznie udostępnij ten link pliku No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code Lub pokaż ten kod @@ -5725,10 +6429,16 @@ Wymaga włączenia VPN. Or to share privately + Lub udostępnij prywatnie + No comment provided by engineer. + + + Or use this QR - print or show online. No comment provided by engineer. Organize chats into lists + Organizuj czaty jako listy No comment provided by engineer. @@ -5743,6 +6453,18 @@ Wymaga włączenia VPN. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count Liczba PINGÓW @@ -5798,6 +6520,10 @@ Wymaga włączenia VPN. Wklej obraz No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! Wklej link, aby połączyć! @@ -5919,18 +6645,22 @@ Błąd: %@ Please try to disable and re-enable notfications. + Spróbuj wyłączyć, a następnie ponownie włączyć powiadomienia. token info Please wait for group moderators to review your request to join the group. + Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy. snd group event chat item Please wait for token activation to complete. + Proszę poczekać na zakończenie aktywacji tokenu. token info Please wait for token to be registered. + Proszę poczekać na zarejestrowanie tokenu. token info @@ -5948,6 +6678,14 @@ Błąd: %@ Zachowaj ostatnią wersję roboczą wiadomości wraz z załącznikami. No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address Wstępnie ustawiony adres serwera @@ -5955,6 +6693,7 @@ Błąd: %@ Preset servers + Domyślne serwery No comment provided by engineer. @@ -5974,19 +6713,20 @@ Błąd: %@ Privacy for your customers. + Prywatność dla Twoich klientów. No comment provided by engineer. Privacy policy and conditions of use. + Polityka prywatności i warunki korzystania. No comment provided by engineer. - - Privacy redefined - Redefinicja prywatności + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. + + Private and secure messaging. No comment provided by engineer. @@ -5996,6 +6736,7 @@ Błąd: %@ Private media file names. + Nazwy prywatnych plików multimedialnych. No comment provided by engineer. @@ -6025,8 +6766,13 @@ Błąd: %@ Private routing timeout + Limit czasu routingu prywatnego alert title + + Proceed + alert action + Profile and server connections Profil i połączenia z serwerem @@ -6052,9 +6798,8 @@ Błąd: %@ Motyw profilu No comment provided by engineer. - - Profile update will be sent to your contacts. - Aktualizacja profilu zostanie wysłana do Twoich kontaktów. + + Profile update will be sent to your SimpleX contacts. alert message @@ -6062,6 +6807,10 @@ Błąd: %@ Zabroń połączeń audio/wideo. No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Zabroń nieodwracalnego usuwania wiadomości. @@ -6079,6 +6828,7 @@ Błąd: %@ Prohibit reporting messages to moderators. + Zabroń raportowania wiadomości moderatorom. No comment provided by engineer. @@ -6091,6 +6841,10 @@ Błąd: %@ Zabroń wysyłania bezpośrednich wiadomości do członków. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Zabroń wysyłania znikających wiadomości. @@ -6130,6 +6884,7 @@ Włącz w ustawianiach *Sieć i serwery* . Protocol background timeout + Limit czasu protokołu w tle No comment provided by engineer. @@ -6157,6 +6912,10 @@ Włącz w ustawianiach *Sieć i serwery* . Proxy wymaga hasła No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Powiadomienia push @@ -6197,24 +6956,14 @@ Włącz w ustawianiach *Sieć i serwery* . Przeczytaj więcej No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Przeczytaj więcej w [Poradniku Użytkownika](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Przeczytaj więcej w Poradniku Użytkownika. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Przeczytaj więcej w [Podręczniku Użytkownika](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Przeczytaj więcej w [Podręczniku Użytkownika](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Przeczytaj więcej na naszym [repozytorium GitHub](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Przeczytaj więcej na naszym repozytorium GitHub. No comment provided by engineer. @@ -6237,11 +6986,6 @@ Włącz w ustawianiach *Sieć i serwery* . Otrzymane o: %@ copied message info - - Received file event - Otrzymano zdarzenie pliku - notification - Received message Otrzymano wiadomość @@ -6344,14 +7088,17 @@ Włącz w ustawianiach *Sieć i serwery* . Register + Zarejestruj No comment provided by engineer. Register notification token? + Zarejestrować token powiadomień? token info Registered + Zarejestrowany token status text @@ -6373,8 +7120,29 @@ swipe action Reject member? + Odrzucić członka? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Serwer przekaźnikowy jest używany tylko w razie potrzeby. Inna strona może obserwować Twój adres IP. @@ -6385,10 +7153,23 @@ swipe action Serwer przekaźnikowy chroni Twój adres IP, ale może obserwować czas trwania połączenia. No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove Usuń - No comment provided by engineer. + alert action + + + Remove and delete messages + Usuń i skasuj wiadomości + alert action Remove archive? @@ -6402,6 +7183,7 @@ swipe action Remove link tracking + Usuń śledzenie linków No comment provided by engineer. @@ -6412,15 +7194,24 @@ swipe action Remove member? Usunąć członka? - No comment provided by engineer. + alert title Remove passphrase from keychain? Usunąć hasło z pęku kluczy? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. + Usuwa wiadomości i blokuje członków. No comment provided by engineer. @@ -6460,46 +7251,57 @@ swipe action Report + Zgłoś chat item action Report content: only group moderators will see it. + Zgłoś treść: zobaczą ją tylko moderatorzy grupy. report reason Report member profile: only group moderators will see it. + Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy. report reason Report other: only group moderators will see it. + Zgłoś inne: zobaczą to tylko moderatorzy grupy. report reason Report reason? + Jaki jest powód zgłoszenia? No comment provided by engineer. Report sent to moderators + Zgłoszenia wysłane do moderatorów alert title Report spam: only group moderators will see it. + Zgłoś spam: tylko moderatorzy grupy będą to widzieć. report reason Report violation: only group moderators will see it. + Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy. report reason Report: %@ + Zgłoszenie: %@ report in notification Reporting messages to moderators is prohibited. + Zgłaszanie wiadomości moderatorom jest zabronione. No comment provided by engineer. Reports + Zgłoszenia No comment provided by engineer. @@ -6589,18 +7391,22 @@ swipe action Review conditions + Przejrzyj warunki No comment provided by engineer. Review group members + Przejrzyj członków grupy No comment provided by engineer. Review members + Przejrzyj członków admission stage Review members before admitting ("knocking"). + Przejrzyj członków przed dopuszczeniem ("zapukaj"). admission stage description @@ -6638,6 +7444,10 @@ swipe action Proxy SOCKS No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files Bezpiecznie otrzymuj pliki @@ -6661,10 +7471,16 @@ chat item action Save (and notify members) + Zapisz (i powiadom członków) + alert button + + + Save (and notify subscribers) alert button Save admission settings? + Zapisać ustawienia wstępu? alert title @@ -6677,6 +7493,10 @@ chat item action Zapisz i powiadom członków grupy No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect Zapisz i połącz ponownie @@ -6687,6 +7507,14 @@ chat item action Zapisz i zaktualizuj profil grupowy No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile Zapisz profil grupy @@ -6694,10 +7522,12 @@ chat item action Save group profile? + Zapisać profil grupy? alert title Save list + Zapisz listę No comment provided by engineer. @@ -6810,11 +7640,36 @@ chat item action Pasek wyszukiwania akceptuje linki zaproszenia. No comment provided by engineer. + + Search files + Szukaj plików + No comment provided by engineer. + + + Search images + Szukaj zdjęć + No comment provided by engineer. + + + Search links + Szukaj linków + No comment provided by engineer. + Search or paste SimpleX link Wyszukaj lub wklej link SimpleX No comment provided by engineer. + + Search videos + Szukaj wideo + No comment provided by engineer. + + + Search voice messages + Szukaj wiadomości głosowych + No comment provided by engineer. + Secondary Drugorzędny @@ -6840,6 +7695,10 @@ chat item action Kod bezpieczeństwa No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select Wybierz @@ -6892,6 +7751,7 @@ chat item action Send contact request? + Wysłać prośbę o kontakt? No comment provided by engineer. @@ -6946,6 +7806,7 @@ chat item action Send private reports + Wyślij prywatne zgłoszenia No comment provided by engineer. @@ -6960,10 +7821,16 @@ chat item action Send request + Wyślij prośbę No comment provided by engineer. Send request without message + Wyślij prośbę bez wiadomości + No comment provided by engineer. + + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. No comment provided by engineer. @@ -6976,8 +7843,13 @@ chat item action Wysyłaj do 100 ostatnich wiadomości do nowych członków. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. + Wyślij swoją prywatną opinię do grup. No comment provided by engineer. @@ -6990,6 +7862,10 @@ chat item action Nadawca mógł usunąć prośbę o połączenie. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Wysyłanie potwierdzeń dostawy zostanie włączone dla wszystkich kontaktów we wszystkich widocznych profilach czatu. @@ -7045,11 +7921,6 @@ chat item action Wysłano bezpośrednio No comment provided by engineer. - - Sent file event - Wyślij zdarzenie pliku - notification - Sent message Wyślij wiadomość @@ -7087,6 +7958,7 @@ chat item action Server added to operator %@. + Serwer został dodany do operatora %@. alert message @@ -7106,24 +7978,31 @@ chat item action Server operator changed. + Operator serwera został zmieniony. alert title Server operators + Operatorzy serwera No comment provided by engineer. Server protocol changed. + Protokół serwera zmieniony. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. - Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło + Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło. server test error Server requires authorization to upload, check password. - Serwer wymaga autoryzacji do przesłania, sprawdź hasło + Serwer wymaga autoryzacji do przesłania, sprawdź hasło. server test error @@ -7173,6 +8052,7 @@ chat item action Set chat name… + Ustaw nazwę czatu… No comment provided by engineer. @@ -7197,10 +8077,12 @@ chat item action Set member admission + Ustaw przyjmowanie członków No comment provided by engineer. Set message expiration in chats. + Ustaw datę wygaśnięcia wiadomości na czatach. No comment provided by engineer. @@ -7220,6 +8102,7 @@ chat item action Set profile bio and welcome message. + Ustaw biografię profilu i wiadomość powitalną. No comment provided by engineer. @@ -7242,6 +8125,14 @@ chat item action Ustawienia zostały zmienione. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images Kształtuj obrazy profilowe @@ -7260,10 +8151,12 @@ chat item action Share 1-time link with a friend + Udostępnij jednorazowy link znajomemu No comment provided by engineer. Share SimpleX address on social media. + Udostępnij adres SimpleX w mediach społecznościowych. No comment provided by engineer. @@ -7273,13 +8166,17 @@ chat item action Share address publicly + Udostępnij adres publicznie No comment provided by engineer. - - Share address with contacts? - Udostępnić adres kontaktom? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. Udostępnij z innych aplikacji. @@ -7292,10 +8189,12 @@ chat item action Share old address + Udostępnij stary adres alert button Share old link + Udostępnij stary link alert button @@ -7303,6 +8202,10 @@ chat item action Udostępnij profil No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link Udostępnij ten jednorazowy link @@ -7313,25 +8216,32 @@ chat item action Udostępnij do SimpleX No comment provided by engineer. - - Share with contacts - Udostępnij kontaktom + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. Share your address + Udostępnij swój adres No comment provided by engineer. Short SimpleX address + Krótki adres SimpleX No comment provided by engineer. Short description + Krótki opis No comment provided by engineer. Short link + Krótki link No comment provided by engineer. @@ -7391,6 +8301,7 @@ chat item action SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux. No comment provided by engineer. @@ -7425,10 +8336,12 @@ chat item action SimpleX address and 1-time links are safe to share via any messenger. + Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator. No comment provided by engineer. SimpleX address or 1-time link? + Adres SimpleX czy link jednorazowy? No comment provided by engineer. @@ -7438,6 +8351,7 @@ chat item action SimpleX channel link + Link do kanału na SimpleX simplex link type @@ -7477,10 +8391,11 @@ chat item action SimpleX protocols reviewed by Trail of Bits. + Protokoły SimpleX sprawdzone przez Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7536,6 +8451,8 @@ chat item action Some servers failed the test: %@ + Niektóre serwery nie przeszły testu: +%@ alert message @@ -7545,6 +8462,7 @@ chat item action Spam + Spam blocking reason report reason @@ -7553,6 +8471,11 @@ report reason Kwadrat, okrąg lub cokolwiek pomiędzy. No comment provided by engineer. + + Star on GitHub + Daj gwiazdkę na GitHub + No comment provided by engineer. + Start chat Rozpocznij czat @@ -7635,6 +8558,7 @@ report reason Storage + Magazyn No comment provided by engineer. @@ -7652,6 +8576,63 @@ report reason Zasubskrybowano No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors Błędy subskrypcji @@ -7669,10 +8650,12 @@ report reason Switch audio and video during the call. + Przełączanie audio i wideo podczas połączenia. No comment provided by engineer. Switch chat profile for 1-time invitations. + Przełącz profil czatu dla zaproszeń jednorazowych. No comment provided by engineer. @@ -7692,6 +8675,7 @@ report reason TCP connection bg timeout + Przekroczono limit czasu połączenia TCP No comment provided by engineer. @@ -7701,6 +8685,7 @@ report reason TCP port for messaging + Port TCP dla wiadomości No comment provided by engineer. @@ -7728,24 +8713,32 @@ report reason Zrób zdjęcie No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat + Dotknij Połącz aby rozpocząć czat No comment provided by engineer. Tap Connect to send request + Dotknij Połącz, aby wysłać prośbę No comment provided by engineer. Tap Connect to use bot + Dotknij Połącz aby użyć bota No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. + + Tap Join channel No comment provided by engineer. Tap Join group + Dotknij Dołącz do grupy No comment provided by engineer. @@ -7773,6 +8766,10 @@ report reason Dotnij, aby dołączyć w trybie incognito No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link Dotknij, aby wkleić link @@ -7791,10 +8788,16 @@ report reason Test failed at step %@. Test nie powiódł się na etapie %@. - server test failure + relay test failure +server test failure Test notifications + Powiadomienia testowe + No comment provided by engineer. + + + Test relay No comment provided by engineer. @@ -7836,6 +8839,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The address will be short, and your profile will be shared via the address. + Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu. alert message @@ -7845,6 +8849,11 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The app protects your privacy by using different operators in each conversation. + Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie. + No comment provided by engineer. + + + The app removed this message after %lld attempts to receive it. No comment provided by engineer. @@ -7862,8 +8871,13 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Kod, który zeskanowałeś nie jest kodem QR linku SimpleX. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. + Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline. No comment provided by engineer. @@ -7886,9 +8900,9 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Szyfrowanie działa, a nowe uzgodnienie szyfrowania nie jest wymagane. Może to spowodować błędy w połączeniu! No comment provided by engineer. - - The future of messaging - Następna generacja prywatnych wiadomości + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -7898,6 +8912,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The link will be short, and group profile will be shared via the link. + Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link. alert message @@ -7925,12 +8940,19 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Stara baza danych nie została usunięta podczas migracji, można ją usunąć. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + Najstarsza ludzka wolność - możliwość rozmowy z inną osobą bez bycia obserwowanym - opiera się na infrastrukturze, która nie może jej zdradzić. + No comment provided by engineer. + The same conditions will apply to operator **%@**. + Te same warunki będą miały zastosowanie do operatora **%@**. No comment provided by engineer. The second preset operator in the app! + Drugi predefiniowany operator w aplikacji! No comment provided by engineer. @@ -7950,6 +8972,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The servers for new files of your current chat profile **%@**. + Serwery dla nowych plików Twojego bieżącego profilu czatu **%@**. No comment provided by engineer. @@ -7967,8 +8990,19 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Motywy No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + Następnie przenieśliśmy się do sieci, a każda platforma prosiła o podanie danych osobowych - imienia i nazwiska, numeru telefonu, znajomych. Zaakceptowaliśmy fakt, że ceną za możliwość komunikowania się z innymi jest ujawnienie komuś, z kim rozmawiamy. Tak było w przypadku każdego pokolenia, ludzi i technologii - telefonu, poczty elektronicznej, komunikatorów, mediów społecznościowych. Wydawało się to jedyną możliwą opcją. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + Jest jeszcze inny sposób. Sieć bez numerów telefonów. Bez nazw użytkowników. Bez kont. Bez jakichkolwiek tożsamości użytkowników. Sieć, która łączy ludzi i przesyła zaszyfrowane wiadomości, nie wiedząc, kto jest podłączony. + No comment provided by engineer. + These conditions will also apply for: **%@**. + Warunki te będą miały również zastosowanie w przypadku: **%@**. No comment provided by engineer. @@ -7993,6 +9027,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte. alert message @@ -8030,8 +9065,17 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Ta grupa już nie istnieje. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza. No comment provided by engineer. @@ -8041,6 +9085,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This message was deleted or not received yet. + Ta wiadomość została usunięta lub jeszcze nie otrzymana. No comment provided by engineer. @@ -8050,10 +9095,12 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This setting is for your current profile **%@**. + To ustawienie jest dla Twojego obecnego profilu **%@**. No comment provided by engineer. Time to disappear is set only for new contacts. + Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. No comment provided by engineer. @@ -8076,6 +9123,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Aby ukryć niechciane wiadomości. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection Aby nawiązać nowe połączenie @@ -8083,6 +9134,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom To protect against your link being replaced, you can compare contact security codes. + Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu. No comment provided by engineer. @@ -8109,6 +9161,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To receive + Żeby odebrać No comment provided by engineer. @@ -8133,10 +9186,12 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To send + Żeby wysłać No comment provided by engineer. To send commands you must be connected. + Aby wysyłać polecenia, musisz być podłączony. alert message @@ -8146,10 +9201,12 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To use another profile after connection attempt, delete the chat and use the link again. + Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie. alert message To use the servers of **%@**, accept conditions of use. + Aby korzystać z serwerów **%@**, należy zaakceptować warunki użytkowania. No comment provided by engineer. @@ -8157,11 +9214,6 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach. No comment provided by engineer. - - Toggle chat list: - Przełącz listę czatów: - No comment provided by engineer. - Toggle incognito when connecting. Przełącz incognito przy połączeniu. @@ -8169,6 +9221,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Token status: %@. + Stan tokena: %@. token status @@ -8176,6 +9229,10 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Nieprzezroczystość paska narzędzi No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total Łącznie @@ -8191,15 +9248,10 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Sesje transportowe No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia. + subscription status explanation Turkish interface @@ -8246,8 +9298,13 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Odblokować członka? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages + Niedostarczone wiadomości No comment provided by engineer. @@ -8344,13 +9401,18 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Unsupported connection link - No comment provided by engineer. + Nieobsługiwane łącze połączenia + conn error description Up to 100 last messages are sent to new members. Do nowych członków wysyłanych jest do 100 ostatnich wiadomości. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Aktualizuj @@ -8373,6 +9435,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Updated conditions + Zaktualizowane warunki No comment provided by engineer. @@ -8382,14 +9445,17 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Upgrade + Zaktualizuj alert button Upgrade address + Uaktualnij adres No comment provided by engineer. Upgrade address? + Uaktualnić adres? alert message @@ -8399,14 +9465,17 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Upgrade group link? + Uaktualnić link do grupy? alert message Upgrade link + Uaktualnij link No comment provided by engineer. Upgrade your address + Zaktualizuj swój adres No comment provided by engineer. @@ -8441,6 +9510,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use %@ + Użyj %@ No comment provided by engineer. @@ -8460,15 +9530,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use TCP port %@ when no port is specified. + Jeśli nie podano portu, należy użyć portu TCP %@. No comment provided by engineer. Use TCP port 443 for preset servers only. - No comment provided by engineer. - - - Use chat - Użyj czatu + Używaj portu TCP 443 tylko dla domyślnych serwerów. No comment provided by engineer. @@ -8478,10 +9545,16 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use for files + Użyj dla plików No comment provided by engineer. Use for messages + Użyj dla wiadomości + No comment provided by engineer. + + + Use for new channels No comment provided by engineer. @@ -8501,6 +9574,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use incognito profile + Użyj profilu incognito No comment provided by engineer. @@ -8523,6 +9597,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Używaj prywatnego trasowania z nieznanymi serwerami. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server Użyj serwera @@ -8530,6 +9608,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use servers + Użyj serwerów No comment provided by engineer. @@ -8542,8 +9621,13 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Korzystaj z aplikacji jedną ręką. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port + Użyj portu internetowego No comment provided by engineer. @@ -8561,6 +9645,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Używanie serwerów SimpleX Chat. No comment provided by engineer. + + Verify + relay test step + Verify code with desktop Zweryfikuj kod z komputera @@ -8621,6 +9709,11 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Film zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później! No comment provided by engineer. + + Videos + Wideo + No comment provided by engineer. + Videos and files up to 1gb Filmy i pliki do 1gb @@ -8628,6 +9721,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc View conditions + Zobacz warunki No comment provided by engineer. @@ -8637,6 +9731,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc View updated conditions + Zobacz zaktualizowane warunki No comment provided by engineer. @@ -8674,6 +9769,18 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Wiadomość głosowa… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... Oczekiwanie na komputer... @@ -8714,6 +9821,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Uwaga: możesz stracić niektóre dane! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers Serwery WebRTC ICE @@ -8736,6 +9847,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Welcome your contacts 👋 + Powitaj swoje kontakty 👋 No comment provided by engineer. @@ -8755,6 +9867,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje. No comment provided by engineer. @@ -8762,6 +9875,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Gdy udostępnisz komuś profil incognito, będzie on używany w grupach, do których Cię zaprosi. No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi WiFi @@ -8854,6 +9971,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc You are already connected with %@. + Zostałeś już połączony z %@. No comment provided by engineer. @@ -8888,16 +10006,21 @@ Repeat join request? Powtórzyć prośbę dołączenia? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Jesteś połączony z serwerem służącym do odbierania wiadomości z tego połączenia. + subscription status explanation You are invited to group Jesteś zaproszony do grupy No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Nie masz połączenia z serwerem służącym do odbierania wiadomości w ramach tego połączenia (brak subskrypcji). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -8915,6 +10038,7 @@ Powtórzyć prośbę dołączenia? You can configure servers via settings. + Serwery można skonfigurować w ustawieniach. No comment provided by engineer. @@ -8959,6 +10083,7 @@ Powtórzyć prośbę dołączenia? You can set connection name, to remember who the link was shared with. + Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony. No comment provided by engineer. @@ -8966,6 +10091,10 @@ Powtórzyć prośbę dołączenia? Podgląd powiadomień na ekranie blokady można ustawić w ustawieniach. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Możesz udostępnić link lub kod QR - każdy będzie mógł dołączyć do grupy. Nie stracisz członków grupy, jeśli później ją usuniesz. @@ -9003,6 +10132,7 @@ Powtórzyć prośbę dołączenia? You can view your reports in Chat with admins. + Możesz przeglądać swoje raporty w czacie z administratorami. alert message @@ -9010,16 +10140,21 @@ Powtórzyć prośbę dołączenia? Nie możesz wysyłać wiadomości! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. Nie można zweryfikować użytkownika; proszę spróbować ponownie. No comment provided by engineer. - - You decide who can connect. - Ty decydujesz, kto może się połączyć. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9084,10 +10219,17 @@ Powtórzyć prośbę połączenia? You should receive notifications. + Powinieneś otrzymywać powiadomienia. token info + + You were born without an account + Urodziłeś się bez konta. + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. + Będziesz mógł wysyłać wiadomości **dopiero po zaakceptowaniu Twojej prośby**. No comment provided by engineer. @@ -9120,8 +10262,13 @@ Powtórzyć prośbę połączenia? Nadal będziesz otrzymywać połączenia i powiadomienia z wyciszonych profili, gdy są one aktywne. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. + Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana. No comment provided by engineer. @@ -9156,6 +10303,7 @@ Powtórzyć prośbę połączenia? Your business contact + Twój kontakt biznesowy No comment provided by engineer. @@ -9163,6 +10311,10 @@ Powtórzyć prośbę połączenia? Twoje połączenia No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database Twoja baza danych czatu @@ -9185,6 +10337,7 @@ Powtórzyć prośbę połączenia? Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile. + Twoja rozmowa została przeniesiona do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd. alert message @@ -9194,6 +10347,7 @@ Powtórzyć prośbę połączenia? Your contact + Twój kontakt No comment provided by engineer. @@ -9211,6 +10365,11 @@ Powtórzyć prośbę połączenia? Twoje kontakty pozostaną połączone. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + Twoje rozmowy należą do Ciebie, tak jak zawsze było przed pojawieniem się Internetu. Sieć nie jest miejscem, które odwiedzasz. Jest miejscem, które tworzysz i które należy do Ciebie. Nikt nie może Ci tego odebrać, niezależnie od tego, czy jest to miejsce prywatne, czy publiczne. + No comment provided by engineer. + Your credentials may be sent unencrypted. Twoje poświadczenia mogą zostać wysłane niezaszyfrowane. @@ -9228,6 +10387,11 @@ Powtórzyć prośbę połączenia? Your group + Twoja grupa + No comment provided by engineer. + + + Your network No comment provided by engineer. @@ -9245,6 +10409,11 @@ Powtórzyć prośbę połączenia? Twój profil No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Twój profil **%@** zostanie udostępniony. @@ -9265,11 +10434,23 @@ Powtórzyć prośbę połączenia? Twój profil został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do wszystkich kontaktów. alert message + + Your public address + No comment provided by engineer. + Your random profile Twój losowy profil No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address Twój adres serwera @@ -9285,21 +10466,11 @@ Powtórzyć prośbę połączenia? Twoje ustawienia No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Przyczyń się](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Wyślij do nas email](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Daj gwiazdkę na GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_kursywa_ @@ -9315,8 +10486,13 @@ Powtórzyć prośbę połączenia? powyżej, a następnie wybierz: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ + zaakceptowano %@ rcv group event chat item @@ -9326,12 +10502,18 @@ Powtórzyć prośbę połączenia? accepted invitation + zaproszenie zaakceptowane chat list item title accepted you + przyjął cię rcv group event chat item + + active + No comment provided by engineer. + admin administrator @@ -9354,6 +10536,7 @@ Powtórzyć prośbę połączenia? all + wszystkie member criteria value @@ -9373,6 +10556,7 @@ Powtórzyć prośbę połączenia? archived report + zarchiwizowany raport No comment provided by engineer. @@ -9441,8 +10625,13 @@ marked deleted chat item preview text dzwonie… call status + + can't broadcast + No comment provided by engineer. + can't send messages + nie można wysłać wiadomości No comment provided by engineer. @@ -9475,6 +10664,14 @@ marked deleted chat item preview text zmiana adresu… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored kolorowy @@ -9547,10 +10744,12 @@ marked deleted chat item preview text contact deleted + kontakt usunięty No comment provided by engineer. contact disabled + kontakt wyłączony No comment provided by engineer. @@ -9565,10 +10764,12 @@ marked deleted chat item preview text contact not ready + kontakt nie gotowy No comment provided by engineer. contact should accept… + kontakt powinien zaakceptować… No comment provided by engineer. @@ -9617,6 +10818,10 @@ pref value usunięty deleted chat item + + deleted channel + rcv group event chat item + deleted contact usunięto kontakt @@ -9727,11 +10932,20 @@ pref value błąd No comment provided by engineer. + + error: %@ + receive error chat item + expired wygasły No comment provided by engineer. + + failed + nieudane + No comment provided by engineer. + forwarded przekazane dalej @@ -9739,6 +10953,7 @@ pref value group + grupa shown on group welcome message @@ -9748,6 +10963,7 @@ pref value group is deleted + grupa została usunięta No comment provided by engineer. @@ -9850,6 +11066,10 @@ pref value opuścił rcv group event chat item + + link + No comment provided by engineer. + marked deleted zaznaczona jako usunięta @@ -9872,6 +11092,7 @@ pref value member has old version + członek posiada starą wersję No comment provided by engineer. @@ -9906,6 +11127,7 @@ pref value moderator + moderator member role @@ -9918,6 +11140,10 @@ pref value nigdy delete after time + + new + No comment provided by engineer. + new message nowa wiadomość @@ -9933,6 +11159,11 @@ pref value brak szyfrowania e2e No comment provided by engineer. + + no subscription + brak subskrypcji + No comment provided by engineer. + no text brak tekstu @@ -9940,6 +11171,7 @@ pref value not synchronized + nie zsynchronizowano No comment provided by engineer. @@ -9997,14 +11229,17 @@ time to disappear pending + oczekuje No comment provided by engineer. pending approval + oczekuje na zatwierdzenie No comment provided by engineer. pending review + oczekuje na ocenę No comment provided by engineer. @@ -10024,6 +11259,7 @@ time to disappear rejected + odrzucono No comment provided by engineer. @@ -10031,6 +11267,10 @@ time to disappear odrzucone połączenie call status + + relay + member role + removed usunięty @@ -10041,6 +11281,14 @@ time to disappear usunięto %@ rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address usunięto adres kontaktu @@ -10048,6 +11296,7 @@ time to disappear removed from group + usunięty z grupy No comment provided by engineer. @@ -10062,30 +11311,37 @@ time to disappear request is sent + prośba została wysłana No comment provided by engineer. request to join rejected + prośba o dołączenie została odrzucona No comment provided by engineer. requested connection + prośba o połączenie rcv group event chat item requested connection from group %@ + prośba o połączenie od grupy %@ rcv direct event chat item requested to connect + poproszono o połączenie chat list item title review + ocena No comment provided by engineer. reviewed by admins + sprawdzone przez administratorów No comment provided by engineer. @@ -10187,6 +11443,10 @@ ostatnia otrzymana wiadomość: %2$@ niezabezpieczony No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile zaktualizowano profil grupy @@ -10207,6 +11467,10 @@ ostatnia otrzymana wiadomość: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link przez link adresu kontaktu @@ -10274,6 +11538,7 @@ ostatnia otrzymana wiadomość: %2$@ you accepted this member + zaakceptowałeś tego członka snd group event chat item @@ -10281,6 +11546,10 @@ ostatnia otrzymana wiadomość: %2$@ jesteś obserwatorem No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ zablokowałeś %@ @@ -10341,6 +11610,10 @@ ostatnia otrzymana wiadomość: %2$@ \~strajk~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + @@ -10409,22 +11682,27 @@ ostatnia otrzymana wiadomość: %2$@ %d new events + %d nowych wydarzeń notification body From %d chat(s) + Z %d czatu(ów) notification body From: %@ + Od: %@ notification body New events + Nowe wydarzenia notification New messages + Nowe wiadomości notification diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 568ca53946..a438327ba1 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -167,7 +167,7 @@ %d hours - %d час. + %d ч time interval @@ -182,9 +182,27 @@ %d months - %d мес. + %d мес time interval + + %d relays failed + %d релеев с ошибками + channel relay bar +channel subscriber relay bar + + + %d relays not active + %d релеев неактивны + channel relay bar +channel subscriber relay bar + + + %d relays removed + %d релеев удалены + channel relay bar +channel subscriber relay bar + %d sec %d сек @@ -200,11 +218,63 @@ %d пропущенных сообщение(й) integrity error chat item + + %d subscriber + %d подписчик + channel subscriber count + + + %d subscribers + %d подписчиков + channel subscriber count + %d weeks %d недель time interval + + %1$d/%2$d relays active + %1$d/%2$d релеев активны + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + %1$d/%2$d релеев активны, %3$d с ошибками + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d релеев активны, %3$d с ошибками + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + %1$d/%2$d релеев активны, %3$d удалены + channel relay bar + + + %1$d/%2$d relays connected + %1$d/%2$d релеев подключены + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d релеев подключены, %3$d с ошибками + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d релеев подключены, %3$d с ошибками + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d релеев подключены, %3$d удалены + channel subscriber relay bar + %lld %lld @@ -215,6 +285,11 @@ %lld %@ No comment provided by engineer. + + %lld channel events + %lld событий канала + No comment provided by engineer. + %lld contact(s) selected Выбрано контактов: %lld @@ -242,7 +317,7 @@ %lld messages blocked by admin - %lld сообщений заблокировано администратором + %lld сообщений заблокировано админом No comment provided by engineer. @@ -257,7 +332,7 @@ %lld minutes - %lld минуты + %lld минут(ы) No comment provided by engineer. @@ -315,11 +390,21 @@ %u сообщений пропущено. No comment provided by engineer. + + (from owner) + (от владельца) + chat link info line + (new) (новое) No comment provided by engineer. + + (signed) + (с подписью) + chat link info line + (this device v%@) (это устройство v%@) @@ -352,7 +437,7 @@ **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Внимание**: Вы не сможете восстановить или поменять пароль, если Вы его потеряете. + **Внимание**: Вы не сможете восстановить или поменять пароль, если потеряете его. No comment provided by engineer. @@ -365,14 +450,19 @@ **Сканировать / Вставить ссылку**: чтобы соединиться через полученную ссылку. No comment provided by engineer. + + **Test relay** to retrieve its name. + **Протестируйте релей**, чтобы получить его имя. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. - **Внимание**: для работы мгновенных уведомлений пароль должен быть сохранен в Keychain. + **Внимание**: для работы мгновенных уведомлений пароль должен быть сохранён в Keychain. No comment provided by engineer. **Warning**: the archive will be removed. - **Внимание**: архив будет удален. + **Внимание**: архив будет удалён. No comment provided by engineer. @@ -395,7 +485,7 @@ - delivery receipts (up to 20 members). - faster and more stable. - соединиться с [каталогом групп](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! -- отчеты о доставке (до 20 членов). +- отчёты о доставке (до 20 членов). - быстрее и стабильнее. No comment provided by engineer. @@ -408,6 +498,15 @@ - и прочее! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + - включение картинок ссылок. +- защита от фишинга. +- удаление трекинга ссылок. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -503,7 +602,12 @@ time interval A few more things - Еще несколько изменений + Ещё несколько изменений + No comment provided by engineer. + + + A link for one person to connect + Ссылка для одного человека No comment provided by engineer. @@ -524,7 +628,7 @@ time interval A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. - Будет использовано отдельное TCP соединение **для каждого контакта и члена группы**. + Будет использовано отдельное TCP-соединение **для каждого контакта и члена группы**. **Примечание**: Чем больше подключений, тем быстрее разряжается батарея и расходуется трафик, а некоторые соединения могут отваливаться. No comment provided by engineer. @@ -604,7 +708,7 @@ swipe action Accept member - Принять члена + Принять члена группы alert title @@ -632,9 +736,9 @@ swipe action Активные соединения No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Добавьте адрес в свой профиль, чтобы Ваши контакты могли поделиться им. Профиль будет отправлен Вашим контактам. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. + Добавьте адрес в свой профиль, чтобы Ваши SimpleX контакты могли поделиться им. Профиль будет отправлен Вашим SimpleX контактам. No comment provided by engineer. @@ -649,7 +753,7 @@ swipe action Add message - Добавить cообщение + Добавить сообщение placeholder for sending contact request @@ -664,7 +768,7 @@ swipe action Add servers by scanning QR codes. - Добавить серверы через QR код. + Добавить серверы через QR-код. No comment provided by engineer. @@ -702,6 +806,11 @@ swipe action Дополнительные серверы сообщений No comment provided by engineer. + + Adding relays will be supported later. + Добавление релеев будет поддерживаться позже. + No comment provided by engineer. + Additional accent Дополнительный акцент @@ -754,7 +863,7 @@ swipe action Advanced settings - Настройки сети + Дополнительные настройки No comment provided by engineer. @@ -774,7 +883,7 @@ swipe action All chats will be removed from the list %@, and the list deleted. - Все чаты будут удалены из списка %@, и список удален. + Все чаты будут удалены из списка %@, и список удалён. alert message @@ -792,9 +901,14 @@ swipe action Все члены группы останутся соединены. No comment provided by engineer. + + All messages + Все сообщения + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. - Все сообщения и файлы отправляются с **end-to-end шифрованием**, с постквантовой безопасностью в прямых разговорах. + Все сообщения и файлы отправляются с **сквозным шифрованием**, с пост-квантовой безопасностью в прямых разговорах. No comment provided by engineer. @@ -817,6 +931,16 @@ swipe action Все профили profile dropdown + + All relays failed + Все релеи недоступны + No comment provided by engineer. + + + All relays removed + Все релеи удалены + No comment provided by engineer. + All reports will be archived for you. Все сообщения о нарушениях будут заархивированы для вас. @@ -834,12 +958,12 @@ swipe action All your contacts will remain connected. Profile update will be sent to your contacts. - Все Ваши контакты сохранятся. Обновленный профиль будет отправлен Вашим контактам. + Все Ваши контакты сохранятся. Обновлённый профиль будет отправлен Вашим контактам. No comment provided by engineer. All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Все ваши контакты, разговоры и файлы будут надежно зашифрованы и загружены на выбранные XFTP серверы. + Все ваши контакты, разговоры и файлы будут надёжно зашифрованы и загружены на выбранные XFTP-серверы. No comment provided by engineer. @@ -877,6 +1001,11 @@ swipe action Разрешить необратимое удаление сообщений, только если Ваш контакт разрешает это Вам. (24 часа) No comment provided by engineer. + + Allow members to chat with admins. + Разрешить членам группы общаться с админами. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Разрешить реакции на сообщения, только если ваш контакт разрешает их. @@ -892,6 +1021,11 @@ swipe action Разрешить личные сообщения членам группы. No comment provided by engineer. + + Allow sending direct messages to subscribers. + Разрешить отправку личных сообщений подписчикам. + No comment provided by engineer. + Allow sending disappearing messages. Разрешить посылать исчезающие сообщения. @@ -902,6 +1036,11 @@ swipe action Разрешить поделиться No comment provided by engineer. + + Allow subscribers to chat with admins. + Разрешить подписчикам общаться с админами. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Разрешить необратимо удалять отправленные сообщения. (24 часа) @@ -989,7 +1128,7 @@ swipe action Always use relay - Всегда соединяться через relay + Всегда соединяться через релей No comment provided by engineer. @@ -1007,11 +1146,6 @@ swipe action Принять звонок No comment provided by engineer. - - Anybody can host servers. - Кто угодно может запустить сервер. - No comment provided by engineer. - App build: %@ Сборка приложения: %@ @@ -1044,7 +1178,7 @@ swipe action App passcode is replaced with self-destruct passcode. - Код доступа в приложение будет заменен кодом самоуничтожения. + Код доступа в приложение будет заменён кодом самоуничтожения. No comment provided by engineer. @@ -1134,7 +1268,7 @@ swipe action Audio & video calls - Аудио- и видеозвонки + Аудио и видеозвонки No comment provided by engineer. @@ -1142,6 +1276,11 @@ swipe action Аудио и видео звонки No comment provided by engineer. + + Audio call + Аудиозвонок + No comment provided by engineer. + Audio/video calls Аудио/видео звонки @@ -1174,7 +1313,7 @@ swipe action Auto-accept - Автоприем + Автоприём No comment provided by engineer. @@ -1184,7 +1323,7 @@ swipe action Auto-accept images - Автоприем изображений + Автоприём изображений No comment provided by engineer. @@ -1209,7 +1348,24 @@ swipe action Bad message hash - Ошибка хэш сообщения + Ошибка хэша сообщения + No comment provided by engineer. + + + Be free +in your network + Будь свободен +в своей сети + No comment provided by engineer. + + + Be free in your network. + Будь свободен в своей сети. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Потому что мы разрушили саму возможность узнать, кто вы. Чтобы вашу свободу невозможно было отнять. No comment provided by engineer. @@ -1274,7 +1430,7 @@ swipe action Black - Черная + Чёрная No comment provided by engineer. @@ -1307,6 +1463,11 @@ swipe action Заблокировать члена группы? No comment provided by engineer. + + Block subscriber for all? + Заблокировать подписчика для всех? + No comment provided by engineer. + Blocked by admin Заблокирован администратором @@ -1357,6 +1518,16 @@ swipe action Вы и Ваш контакт можете отправлять голосовые сообщения. No comment provided by engineer. + + Bottom bar + Нижнее меню + No comment provided by engineer. + + + Broadcast + Опубликовать + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Болгарский, финский, тайский и украинский - благодаря пользователям и [Weblate] (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1364,8 +1535,8 @@ swipe action Business address - Бизнес адрес - No comment provided by engineer. + Бизнес-адрес + chat link info line Business chats @@ -1374,7 +1545,7 @@ swipe action Business connection - Бизнес контакт + Бизнес-контакт No comment provided by engineer. @@ -1387,18 +1558,9 @@ swipe action По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - Используя SimpleX Chat, Вы согласны: -- отправлять только законные сообщения в публичных группах. -- уважать других пользователей – не отправлять спам. - No comment provided by engineer. - Call already ended! - Звонок уже завершен! + Звонок уже завершён! No comment provided by engineer. @@ -1418,7 +1580,7 @@ swipe action Can't call contact - Не удается позвонить контакту + Не удаётся позвонить контакту No comment provided by engineer. @@ -1495,7 +1657,7 @@ new chat action Change chat profiles - Поменять профили + Изменить профили чата authentication reason @@ -1544,6 +1706,82 @@ new chat action authentication reason set passcode view + + Channel + Канал + No comment provided by engineer. + + + Channel display name + Имя канала + No comment provided by engineer. + + + Channel full name (optional) + Полное имя канала (необязательно) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + У канала нет активных релеев. Попробуйте подключиться позже. + alert message +alert subtitle + + + Channel image + Картинка канала + No comment provided by engineer. + + + Channel link + Ссылка канала + chat link info line + + + Channel preferences + Предпочтения канала + No comment provided by engineer. + + + Channel profile + Профиль канала + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + Профиль канала хранится на устройствах подписчиков и на чат-релеях. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + Профиль канала был изменен. Если Вы сохраните его, обновлённый профиль будет отправлен подписчикам канала. + alert message + + + Channel temporarily unavailable + Канал временно недоступен + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + Канал будет удалён для всех подписчиков - это нельзя отменить! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + Канал будет удалён для Вас - это нельзя отменить! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + Канал начнёт работу с %1$d из %2$d релеев. Продолжить? + alert message + + + Channels + Каналы + No comment provided by engineer. + Chat Разговор @@ -1601,7 +1839,7 @@ set passcode view Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. - Чат остановлен. Если вы уже использовали эту базу данных на другом устройстве, перенесите ее обратно до запуска чата. + Чат остановлен. Если вы уже использовали эту базу данных на другом устройстве, перенесите её обратно до запуска чата. No comment provided by engineer. @@ -1616,7 +1854,7 @@ set passcode view Chat preferences - Предпочтения + Настройки чатов No comment provided by engineer. @@ -1629,6 +1867,26 @@ set passcode view Профиль чата No comment provided by engineer. + + Chat relay + Чат-релей + No comment provided by engineer. + + + Chat relays + Чат-релеи + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + Чат-релеи пересылают сообщения в Ваших каналах. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + Чат-релеи пересылают сообщения подписчикам каналов. + No comment provided by engineer. + Chat theme Тема чата @@ -1636,18 +1894,19 @@ set passcode view Chat will be deleted for all members - this cannot be undone! - Разговор будет удален для всех участников - это действие нельзя отменить! + Разговор будет удалён для всех участников - это действие нельзя отменить! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! - Разговор будет удален для Вас - это действие нельзя отменить! + Разговор будет удалён для Вас - это действие нельзя отменить! No comment provided by engineer. Chat with admins Чат с админами - chat toolbar + chat feature +chat toolbar Chat with member @@ -1656,7 +1915,7 @@ set passcode view Chat with members before they join. - Общайтесь с членами до того как принять их. + Общайтесь с членами группы до того как принять их. No comment provided by engineer. @@ -1664,11 +1923,26 @@ set passcode view Чаты No comment provided by engineer. + + Chats with admins are prohibited. + Чаты с админами запрещены. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + Чаты с админами в публичных каналах не имеют E2E шифрования - используйте только с доверенными чат-релеями. + alert message + Chats with members Чаты с членами группы No comment provided by engineer. + + Chats with members are disabled + Чаты с членами группы отключены + No comment provided by engineer. + Check messages every 20 min. Проверять сообщения каждые 20 минут. @@ -1679,6 +1953,16 @@ set passcode view Проверять сообщения по возможности. No comment provided by engineer. + + Check relay address and try again. + Проверьте адрес релея и попробуйте снова. + alert message + + + Check relay name and try again. + Проверьте имя релея и попробуйте снова. + alert message + Check server address and try again. Проверьте адрес сервера и попробуйте снова. @@ -1691,7 +1975,7 @@ set passcode view Choose _Migrate from another device_ on the new device and scan QR code. - Выберите _Мигрировать с другого устройства_ на новом устройстве и сосканируйте QR код. + Выберите _Мигрировать с другого устройства_ на новом устройстве и сосканируйте QR-код. No comment provided by engineer. @@ -1802,7 +2086,7 @@ set passcode view Conditions of use Условия использования - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1821,12 +2105,12 @@ set passcode view Configure ICE servers - Настройка ICE серверов + Настройка ICE-серверов No comment provided by engineer. - - Configure server operators - Настроить операторов серверов + + Configure relays + Настроить релеи No comment provided by engineer. @@ -1871,7 +2155,7 @@ set passcode view Confirm that you remember database passphrase to migrate it. - Подтвердите, что Вы помните пароль базы данных для ее миграции. + Подтвердите, что Вы помните пароль базы данных для её миграции. No comment provided by engineer. @@ -1887,7 +2171,8 @@ set passcode view Connect Соединиться - server test step + relay test step +server test step Connect automatically @@ -1933,6 +2218,11 @@ This is your own one-time link! Соединиться через ссылку new chat sheet title + + Connect via link or QR code + Соединитесь по ссылке или QR + No comment provided by engineer. + Connect via one-time link Соединиться через одноразовую ссылку @@ -2011,6 +2301,11 @@ This is your own one-time link! Connection error (AUTH) Ошибка соединения (AUTH) + conn error description + + + Connection failed + Ошибка соединения No comment provided by engineer. @@ -2065,6 +2360,11 @@ This is your own one-time link! Соединения No comment provided by engineer. + + Contact address + Адрес контакта + chat link info line + Contact allows Контакт разрешает @@ -2077,7 +2377,7 @@ This is your own one-time link! Contact deleted! - Контакт удален! + Контакт удалён! No comment provided by engineer. @@ -2092,7 +2392,7 @@ This is your own one-time link! Contact is deleted. - Контакт удален. + Контакт удалён. No comment provided by engineer. @@ -2112,7 +2412,7 @@ This is your own one-time link! Contact will be deleted - this cannot be undone! - Контакт будет удален — это нельзя отменить! + Контакт будет удалён - это нельзя отменить! No comment provided by engineer. @@ -2135,19 +2435,24 @@ This is your own one-time link! Продолжить No comment provided by engineer. + + Contribute + Внести свой вклад + No comment provided by engineer. + Conversation deleted! - Разговор удален! + Разговор удалён! No comment provided by engineer. Copy - Скопировать + Копировать No comment provided by engineer. Copy error - Ошибка копирования + Скопировать ошибку No comment provided by engineer. @@ -2163,12 +2468,7 @@ This is your own one-time link! Correct name to %@? Исправить имя на %@? - No comment provided by engineer. - - - Create - Создать - No comment provided by engineer. + alert message Create 1-time link @@ -2220,6 +2520,16 @@ This is your own one-time link! Создать профиль No comment provided by engineer. + + Create public channel + Создать публичный канал + No comment provided by engineer. + + + Create public channel (BETA) + Создать публичный канал (БЕТА) + No comment provided by engineer. + Create queue Создание очереди @@ -2230,11 +2540,21 @@ This is your own one-time link! Создайте Ваш адрес No comment provided by engineer. + + Create your link + Создайте Вашу ссылку + No comment provided by engineer. + Create your profile Создать профиль No comment provided by engineer. + + Create your public address + Создайте Ваш публичный адрес + No comment provided by engineer. + Created Создано @@ -2255,6 +2575,11 @@ This is your own one-time link! Создание ссылки на архив No comment provided by engineer. + + Creating channel + Создание канала + No comment provided by engineer. + Creating link… Создаётся ссылка… @@ -2338,7 +2663,7 @@ This is your own one-time link! Database encryption passphrase will be updated and stored in the keychain. - Пароль базы данных будет изменен и сохранен в Keychain. + Пароль базы данных будет изменен и сохранён в Keychain. No comment provided by engineer. @@ -2376,12 +2701,12 @@ This is your own one-time link! Database passphrase is different from saved in the keychain. - Пароль базы данных отличается от сохраненного в Keychain. + Пароль базы данных отличается от сохранённого в Keychain. No comment provided by engineer. Database passphrase is required to open chat. - Введите пароль базы данных чтобы открыть чат. + Введите пароль базы данных, чтобы открыть чат. No comment provided by engineer. @@ -2392,7 +2717,7 @@ This is your own one-time link! Database will be encrypted and the passphrase stored in the keychain. - База данных будет зашифрована и пароль сохранен в Keychain. + База данных будет зашифрована и пароль сохранён в Keychain. No comment provided by engineer. @@ -2413,10 +2738,10 @@ This is your own one-time link! Отладка доставки No comment provided by engineer. - - Decentralized - Децентрализованный - No comment provided by engineer. + + Decode link + Расшифровать ссылку + relay test step Decryption error @@ -2464,6 +2789,16 @@ swipe action Удалить и уведомить контакт No comment provided by engineer. + + Delete channel + Удалить канал + No comment provided by engineer. + + + Delete channel? + Удалить канал? + No comment provided by engineer. + Delete chat Удалить разговор @@ -2536,7 +2871,7 @@ swipe action Delete for everyone - Удалить для всех + Удаление для всех chat feature @@ -2576,9 +2911,19 @@ swipe action Delete member message? - Удалить сообщение участника? + Удалить сообщение члена группы\? No comment provided by engineer. + + Delete member messages + Удалить сообщения члена группы + No comment provided by engineer. + + + Delete member messages? + Удалить сообщения члена группы? + alert title + Delete message? Удалить сообщение? @@ -2587,7 +2932,8 @@ swipe action Delete messages Удалить сообщения - alert button + alert action +alert button Delete messages after @@ -2624,6 +2970,11 @@ swipe action Удаление очереди server test step + + Delete relay + Удалить релей + No comment provided by engineer. + Delete report Удалить сообщение о нарушении @@ -2781,14 +3132,24 @@ swipe action Direct messages between members are prohibited in this chat. - Личные сообщения запрещены в этой группе. + Прямые сообщения между членами группы запрещены. No comment provided by engineer. Direct messages between members are prohibited. - Прямые сообщения между членами запрещены. + Прямые сообщения между членами группы запрещены. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + Прямые сообщения между подписчиками запрещены. + No comment provided by engineer. + + + Disable + Выключить + alert button + Disable (keep overrides) Выключить (кроме исключений) @@ -2836,7 +3197,7 @@ swipe action Disappearing messages are prohibited. - Исчезающие сообщения запрещены в этой группе. + Исчезающие сообщения запрещены. No comment provided by engineer. @@ -2851,7 +3212,7 @@ swipe action Disconnect - Разрыв соединения + Отключить server test step @@ -2894,9 +3255,14 @@ swipe action Не отправлять историю новым членам. No comment provided by engineer. + + Do not send history to new subscribers. + Не отправлять историю новым подписчикам. + No comment provided by engineer. + Do not use credentials with proxy. - Не использовать учетные данные с прокси. + Не использовать учётные данные с прокси. No comment provided by engineer. @@ -2942,7 +3308,7 @@ chat item action Download errors - Ошибки приема + Ошибки приёма No comment provided by engineer. @@ -2995,11 +3361,21 @@ chat item action E2E зашифрованные нотификации. No comment provided by engineer. + + Easier to invite your friends 👋 + Проще пригласить друзей 👋 + No comment provided by engineer. + Edit Редактировать chat item action + + Edit channel profile + Редактировать профиль канала + No comment provided by engineer. + Edit group profile Редактировать профиль группы @@ -3013,7 +3389,7 @@ chat item action Enable Включить - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3035,6 +3411,11 @@ chat item action Включить TCP keep-alive No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + Включите хотя бы один чат-релей в настройках Сеть и серверы. + channel creation warning + Enable automatic message deletion? Включить автоматическое удаление сообщений? @@ -3045,6 +3426,11 @@ chat item action Включить доступ к камере No comment provided by engineer. + + Enable chats with admins? + Включить чаты с админами? + alert title + Enable disappearing messages by default. Включите исчезающие сообщения по умолчанию. @@ -3065,16 +3451,16 @@ chat item action Включить мгновенные уведомления? No comment provided by engineer. + + Enable link previews? + Включить картинки ссылок? + alert title + Enable lock Включить блокировку No comment provided by engineer. - - Enable notifications - Включить уведомления - No comment provided by engineer. - Enable periodic notifications? Включить периодические уведомления? @@ -3117,7 +3503,7 @@ chat item action Encrypt stored files & media - Шифруйте сохраненные файлы и медиа + Шифруйте сохранённые файлы и медиа No comment provided by engineer. @@ -3152,7 +3538,7 @@ chat item action Encrypted message: no passphrase - Зашифрованное сообщение: пароль не сохранен + Зашифрованное сообщение: пароль не сохранён notification @@ -3180,6 +3566,11 @@ chat item action Введите Код No comment provided by engineer. + + Enter channel name… + Введите имя канала… + No comment provided by engineer. + Enter correct passphrase. Введите правильный пароль. @@ -3205,6 +3596,16 @@ chat item action Введите пароль выше, чтобы раскрыть! No comment provided by engineer. + + Enter profile name... + Введите имя профиля... + No comment provided by engineer. + + + Enter relay name… + Введите имя релея… + No comment provided by engineer. + Enter server manually Ввести сервер вручную @@ -3233,7 +3634,7 @@ chat item action Error Ошибка - No comment provided by engineer. + conn error description Error aborting address change @@ -3242,7 +3643,7 @@ chat item action Error accepting conditions - Ошибка приема условий + Ошибка приёма условий alert title @@ -3260,6 +3661,11 @@ chat item action Ошибка при добавлении членов группы No comment provided by engineer. + + Error adding relay + Ошибка добавления релея + alert title + Error adding server Ошибка добавления сервера @@ -3310,11 +3716,21 @@ chat item action Ошибка подключения к пересылающему серверу %@. Попробуйте позже. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Ошибка подключения к серверу, используемому для получения сообщений от этого соединения: %@ + subscription status explanation + Error creating address Ошибка при создании адреса No comment provided by engineer. + + Error creating channel + Ошибка при создании канала + alert title + Error creating group Ошибка при создании группы @@ -3450,11 +3866,6 @@ chat item action Ошибка при открытии чата No comment provided by engineer. - - Error opening group - Ошибка при открытии группы - No comment provided by engineer. - Error receiving file Ошибка при получении файла @@ -3497,7 +3908,12 @@ chat item action Error saving ICE servers - Ошибка при сохранении ICE серверов + Ошибка при сохранении ICE-серверов + No comment provided by engineer. + + + Error saving channel profile + Ошибка при сохранении профиля канала No comment provided by engineer. @@ -3565,6 +3981,11 @@ chat item action Ошибка настроек отчётов о доставке! No comment provided by engineer. + + Error sharing channel + Ошибка при публикации канала + alert title + Error starting chat Ошибка при запуске чата @@ -3645,7 +4066,8 @@ snd error text Error: %@. Ошибка: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3734,7 +4156,7 @@ snd error text Faster joining and more reliable messages. - Быстрое вступление и надежная доставка сообщений. + Быстрое вступление и надёжная доставка сообщений. No comment provided by engineer. @@ -3773,7 +4195,7 @@ snd error text File not found - most likely file was deleted or cancelled. - Файл не найден - скорее всего, файл был удален или отменен. + Файл не найден - скорее всего, файл был удалён или отменен. file error text @@ -3833,7 +4255,7 @@ snd error text Files and media are prohibited. - Файлы и медиа запрещены в этой группе. + Файлы и медиа запрещены. No comment provided by engineer. @@ -3846,6 +4268,11 @@ snd error text Файлы и медиа запрещены! No comment provided by engineer. + + Filter + Фильтр + No comment provided by engineer. + Filter unread and favorite chats. Фильтровать непрочитанные и избранные чаты. @@ -3868,7 +4295,7 @@ snd error text Find chats faster - Быстро найти чаты + Быстрый поиск чатов No comment provided by engineer. @@ -3883,8 +4310,9 @@ snd error text Fingerprint in server address does not match certificate. - Возможно, хэш сертификата в адресе сервера неверный - server test error + Хэш в адресе сервера не соответствует сертификату. + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3918,7 +4346,7 @@ snd error text Fix not supported by group member - Починка не поддерживается членом группы. + Починка не поддерживается членом группы No comment provided by engineer. @@ -3926,10 +4354,16 @@ snd error text Для всех модераторов No comment provided by engineer. + + For anyone to reach you + Любой может связаться с Вами + No comment provided by engineer. + For chat profile %@: Для профиля чата %@: - servers error + servers error +servers warning For console @@ -4070,11 +4504,21 @@ Error: %2$@ ГИФ файлы и стикеры No comment provided by engineer. + + Get link + Получить ссылку + relay test step + Get notified when mentioned. Уведомления, когда Вас упомянули. No comment provided by engineer. + + Get started + Начать + No comment provided by engineer. + Good afternoon! Добрый день! @@ -4133,7 +4577,7 @@ Error: %2$@ Group link Ссылка группы - No comment provided by engineer. + chat link info line Group links @@ -4197,7 +4641,7 @@ Error: %2$@ Help admins moderating their groups. - Помогайте администраторам модерировать их группы. + Помогайте админам модерировать их группы. No comment provided by engineer. @@ -4217,7 +4661,7 @@ Error: %2$@ Hide - Спрятать + Скрыть chat item action @@ -4245,6 +4689,11 @@ Error: %2$@ История не отправляется новым членам. No comment provided by engineer. + + History is not sent to new subscribers. + История не отправляется новым подписчикам. + No comment provided by engineer. + How SimpleX works Как SimpleX работает @@ -4272,7 +4721,7 @@ Error: %2$@ How to use it - Про адрес + Как использовать No comment provided by engineer. @@ -4287,12 +4736,12 @@ Error: %2$@ ICE servers (one per line) - ICE серверы (один на строке) + ICE-серверы (один на строке) No comment provided by engineer. IP address - IP адрес + IP-адрес No comment provided by engineer. @@ -4310,6 +4759,11 @@ Error: %2$@ Если Вы введёте код самоуничтожения при открытии приложения: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + Если Вы присоединились к каналам или создали их, они перестанут работать навсегда. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Если сейчас Вам нужно использовать чат, нажмите **Отложить** внизу (Вы сможете мигрировать данные чата при следующем запуске приложения). @@ -4330,16 +4784,16 @@ Error: %2$@ Изображение будет принято, когда Ваш контакт будет в сети, подождите или проверьте позже! No comment provided by engineer. + + Images + Изображения + No comment provided by engineer. + Immediately Сразу No comment provided by engineer. - - Immune to spam - Защищен от спама - No comment provided by engineer. - Import Импортировать @@ -4433,7 +4887,7 @@ More improvements are coming soon! Incognito mode protects your privacy by using a new random profile for each contact. - Режим Инкогнито защищает Вашу конфиденциальность — для каждого контакта создается новый случайный профиль. + Режим Инкогнито защищает Вашу конфиденциальность - для каждого контакта создаётся новый случайный профиль. No comment provided by engineer. @@ -4481,9 +4935,9 @@ More improvements are coming soon! Роль при вступлении No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - [SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + SimpleX Chat для терминала No comment provided by engineer. @@ -4535,13 +4989,13 @@ More improvements are coming soon! Invalid QR code - Неверный QR код + Ошибка QR-кода No comment provided by engineer. Invalid connection link Ошибка в ссылке контакта - No comment provided by engineer. + conn error description Invalid display name! @@ -4561,7 +5015,17 @@ More improvements are coming soon! Invalid name! Неверное имя! - No comment provided by engineer. + alert title + + + Invalid relay address! + Неверный адрес релея! + alert title + + + Invalid relay name! + Неверное имя релея! + alert title Invalid response @@ -4588,9 +5052,19 @@ More improvements are coming soon! Пригласить друзей No comment provided by engineer. + + Invite member + Пригласить члена группы + No comment provided by engineer. + Invite members - Пригласить членов группы + Пригласить в группу + No comment provided by engineer. + + + Invite someone privately + Пригласите конфиденциально No comment provided by engineer. @@ -4610,12 +5084,12 @@ More improvements are coming soon! Irreversible message deletion is prohibited in this chat. - Необратимое удаление сообщений запрещено в этом чате. + Необратимое удаление сообщений запрещено. No comment provided by engineer. Irreversible message deletion is prohibited. - Необратимое удаление сообщений запрещено в этой группе. + Необратимое удаление сообщений запрещено. No comment provided by engineer. @@ -4641,7 +5115,7 @@ More improvements are coming soon! It protects your IP address and connections. - Защищает ваш IP адрес и соединения. + Защищает ваш IP-адрес и соединения. No comment provided by engineer. @@ -4666,7 +5140,12 @@ More improvements are coming soon! Join as %@ - вступить как %@ + Вступить как %s + No comment provided by engineer. + + + Join channel + Вступить в канал No comment provided by engineer. @@ -4756,6 +5235,16 @@ This is your link for group %@! Выйти swipe action + + Leave channel + Покинуть канал + No comment provided by engineer. + + + Leave channel? + Выйти из канала? + No comment provided by engineer. + Leave chat Покинуть разговор @@ -4781,6 +5270,11 @@ This is your link for group %@! Меньше трафик в мобильных сетях. No comment provided by engineer. + + Let someone connect to you + Дайте собеседнику Вашу ссылку + No comment provided by engineer. + Let's talk in SimpleX Chat Давайте поговорим в SimpleX Chat @@ -4801,6 +5295,11 @@ This is your link for group %@! Свяжите мобильное и настольное приложения! 🔗 No comment provided by engineer. + + Link signature verified. + Подпись ссылки проверена. + owner verification + Linked desktop options Опции связанных компьютеров @@ -4811,6 +5310,11 @@ This is your link for group %@! Связанные компьютеры No comment provided by engineer. + + Links + Ссылки + No comment provided by engineer. + List Список @@ -4873,12 +5377,12 @@ This is your link for group %@! Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. - Пожалуйста, проверьте, что адреса WebRTC ICE серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. + Пожалуйста, проверьте, что адреса WebRTC ICE-серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. No comment provided by engineer. Mark deleted for everyone - Пометить как удаленное для всех + Пометить как удалённое для всех No comment provided by engineer. @@ -4928,7 +5432,7 @@ This is your link for group %@! Member inactive - Член неактивен + Член группы неактивен item status text @@ -4936,6 +5440,11 @@ This is your link for group %@! Член группы удалён - невозможно принять запрос No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Сообщения члена группы будут удалены - это нельзя отменить! + alert message + Member reports Сообщения о нарушениях @@ -4958,17 +5467,17 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - Член будет удален из разговора - это действие нельзя отменить! - No comment provided by engineer. + Член будет удалён из разговора - это действие нельзя отменить! + alert message Member will be removed from group - this cannot be undone! - Член группы будет удален - это действие нельзя отменить! - No comment provided by engineer. + Член группы будет удалён - это действие нельзя отменить! + alert message Member will join the group, accept member? - Участник хочет присоединиться к группе. Принять? + Член группы хочет присоединиться. Принять? alert message @@ -4976,6 +5485,11 @@ This is your link for group %@! Члены могут добавлять реакции на сообщения. No comment provided by engineer. + + Members can chat with admins. + Члены группы могут общаться с админами. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Члены могут необратимо удалять отправленные сообщения. (24 часа) @@ -4988,12 +5502,12 @@ This is your link for group %@! Members can send SimpleX links. - Члены могут отправлять ссылки SimpleX. + Члены группы могут отправлять ссылки SimpleX. No comment provided by engineer. Members can send direct messages. - Члены могут посылать прямые сообщения. + Члены могут посылать личные сообщения. No comment provided by engineer. @@ -5008,12 +5522,12 @@ This is your link for group %@! Members can send voice messages. - Члены могут отправлять голосовые сообщения. + Члены группы могут отправлять голосовые сообщения. No comment provided by engineer. Mention members 👋 - Упоминайте участников 👋 + Упоминайте членов группы 👋 No comment provided by engineer. @@ -5028,7 +5542,7 @@ This is your link for group %@! Message delivery receipts! - Отчеты о доставке сообщений! + Отчёты о доставке сообщений! No comment provided by engineer. @@ -5041,6 +5555,11 @@ This is your link for group %@! Черновик сообщения No comment provided by engineer. + + Message error + Ошибка сообщения + No comment provided by engineer. + Message forwarded Сообщение переслано @@ -5073,12 +5592,12 @@ This is your link for group %@! Message reactions are prohibited. - Реакции на сообщения запрещены в этой группе. + Реакции на сообщения запрещены. No comment provided by engineer. Message reception - Прием сообщений + Приём сообщений No comment provided by engineer. @@ -5128,7 +5647,7 @@ This is your link for group %@! Messages are protected by **end-to-end encryption**. - Сообщения защищены **end-to-end шифрованием**. + Сообщения защищены **сквозным шифрованием**. No comment provided by engineer. @@ -5136,6 +5655,16 @@ This is your link for group %@! Сообщения от %@ будут показаны! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + Сообщения в этом канале **не защищены сквозным шифрованием**. Чат-релеи могут видеть эти сообщения. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + Сообщения в этом канале не защищены сквозным шифрованием. Чат-релеи могут видеть эти сообщения. + E2EE info chat item + Messages in this chat will never be deleted. Сообщения в этом чате никогда не будут удалены. @@ -5158,12 +5687,17 @@ This is your link for group %@! Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery. - Сообщения, файлы и звонки защищены **end-to-end шифрованием** с прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома. + Сообщения, файлы и звонки защищены **сквозным шифрованием** с прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома. No comment provided by engineer. Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. - Сообщения, файлы и звонки защищены **квантово-устойчивым end-to-end шифрованием** с прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома. + Сообщения, файлы и звонки защищены **квантово-устойчивым сквозным шифрованием** с идеальной прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома. + No comment provided by engineer. + + + Migrate + Мигрировать No comment provided by engineer. @@ -5171,11 +5705,6 @@ This is your link for group %@! Мигрировать устройство No comment provided by engineer. - - Migrate from another device - Миграция с другого устройства - No comment provided by engineer. - Migrate here Мигрировать сюда @@ -5188,7 +5717,7 @@ This is your link for group %@! Migrate to another device via QR code. - Мигрируйте на другое устройство через QR код. + Мигрируйте на другое устройство через QR-код. No comment provided by engineer. @@ -5253,12 +5782,12 @@ This is your link for group %@! More reliable network connection. - Более надежное соединение с сетью. + Более надёжное соединение с сетью. No comment provided by engineer. More reliable notifications - Более надежные уведомления + Более надёжные уведомления No comment provided by engineer. @@ -5293,7 +5822,12 @@ This is your link for group %@! Network & servers - Сеть & серверы + Сеть и серверы + No comment provided by engineer. + + + Network commitments + Обязательства сети No comment provided by engineer. @@ -5306,6 +5840,11 @@ This is your link for group %@! Децентрализация сети No comment provided by engineer. + + Network error + Ошибка сети + conn error description + Network issues - message expired after many attempts to send it. Ошибка сети - сообщение не было отправлено после многократных попыток. @@ -5321,6 +5860,13 @@ This is your link for group %@! Оператор сети No comment provided by engineer. + + Network routers cannot know +who talks to whom + Серверы сети не могут знать, +кто с кем общается + No comment provided by engineer. + Network settings Настройки сети @@ -5329,13 +5875,18 @@ This is your link for group %@! Network status Состояние сети - No comment provided by engineer. + alert title New Новый token status text + + New 1-time link + Новая одноразовая ссылка + No comment provided by engineer. + New Passcode Новый Код @@ -5343,12 +5894,12 @@ This is your link for group %@! New SOCKS credentials will be used every time you start the app. - Новые учетные данные SOCKS будут использоваться при каждом запуске приложения. + Новые учётные данные SOCKS будут использоваться при каждом запуске приложения. No comment provided by engineer. New SOCKS credentials will be used for each server. - Новые учетные данные SOCKS будут использоваться для каждого сервера. + Новые учётные данные SOCKS будут использоваться для каждого сервера. No comment provided by engineer. @@ -5361,6 +5912,11 @@ This is your link for group %@! Новый интерфейс 🎉 No comment provided by engineer. + + New chat relay + Новый чат-релей + No comment provided by engineer. + New contact request Новый запрос на соединение @@ -5408,7 +5964,7 @@ This is your link for group %@! New member wants to join the group. - Новый участник хочет присоединиться к группе. + Новый член группы хочет присоединиться. rcv group event chat item @@ -5431,11 +5987,33 @@ This is your link for group %@! Нет No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + Без аккаунта. Без номера. Без email. Без ID. +Самое безопасное шифрование. + No comment provided by engineer. + + + No active relays + Нет активных релеев + No comment provided by engineer. + No app password Нет кода доступа Authentication unavailable + + No chat relays + Нет чат-релеев + No comment provided by engineer. + + + No chat relays enabled. + Чат-релеи не включены. + servers warning + No chats Нет чатов @@ -5558,12 +6136,12 @@ This is your link for group %@! No servers to receive files. - Нет серверов для приема файлов. + Нет серверов для приёма файлов. servers error No servers to receive messages. - Нет серверов для приема сообщений. + Нет серверов для приёма сообщений. servers error @@ -5581,11 +6159,26 @@ This is your link for group %@! Нет непрочитанных чатов No comment provided by engineer. - - No user identifiers. - Без идентификаторов пользователей. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + Никто не отслеживал ваши разговоры. Никто не составлял карту ваших перемещений. Конфиденциальность не была функцией - это был образ жизни. No comment provided by engineer. + + Non-profit governance + Некоммерческое управление + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + Не более надёжный замок на чужой двери. Не более вежливый хозяин, который уважает вашу частную жизнь, но всё равно ведёт учёт всех посетителей. Вы не гость. Вы у себя дома. Ни один король не войдёт в ваш дом - вы суверенны. + No comment provided by engineer. + + + Not all relays connected + Не все релеи подключены + alert title + Not compatible! Несовместимая версия! @@ -5643,11 +6236,11 @@ This is your link for group %@! OK OK - No comment provided by engineer. + alert button Off - Выключено + Нет blur media @@ -5662,11 +6255,21 @@ new chat action Предыдущая версия данных чата No comment provided by engineer. + + On your phone, not on servers. + На Вашем телефоне, не на серверах. + No comment provided by engineer. + One-time invitation link Одноразовая ссылка No comment provided by engineer. + + One-time link + Одноразовая ссылка + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5686,6 +6289,11 @@ Requires compatible VPN. Onion хосты не используются. No comment provided by engineer. + + Only channel owners can change channel preferences. + Изменить настройки канала могут только владельцы канала. + No comment provided by engineer. + Only chat owners can change preferences. Только владельцы разговора могут поменять предпочтения. @@ -5789,7 +6397,8 @@ Requires compatible VPN. Open Открыть - alert action + alert action +alert button Open Settings @@ -5801,6 +6410,11 @@ Requires compatible VPN. Открыть изменения No comment provided by engineer. + + Open channel + Открыть канал + new chat action + Open chat Открыть чат @@ -5821,6 +6435,11 @@ Requires compatible VPN. Открыть условия No comment provided by engineer. + + Open external link? + Открыть внешнюю ссылку? + alert title + Open full link Открыть полную ссылку @@ -5841,6 +6460,11 @@ Requires compatible VPN. Открытие миграции на другое устройство authentication reason + + Open new channel + Открыть новый канал + new chat action + Open new chat Открыть новый чат @@ -5886,6 +6510,17 @@ Requires compatible VPN. Сервер оператора alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + Операторы обязуются: +- Быть независимыми +- Минимизировать использование метаданных +- Использовать проверенный и открытый исходный код + No comment provided by engineer. + Or import archive file Или импортировать файл архива @@ -5898,7 +6533,7 @@ Requires compatible VPN. Or scan QR code - Или отсканируйте QR код + Или отсканируйте QR-код No comment provided by engineer. @@ -5906,6 +6541,11 @@ Requires compatible VPN. Или передайте эту ссылку No comment provided by engineer. + + Or show QR in person or via video call. + Или покажите QR лично или через видеозвонок. + No comment provided by engineer. + Or show this code Или покажите этот код @@ -5916,6 +6556,11 @@ Requires compatible VPN. Или поделиться конфиденциально No comment provided by engineer. + + Or use this QR - print or show online. + Или используйте этот QR - распечатайте или покажите онлайн. + No comment provided by engineer. + Organize chats into lists Организуйте чаты в списки @@ -5933,6 +6578,21 @@ Requires compatible VPN. %@ alert message + + Owner + Владелец + No comment provided by engineer. + + + Owners + Владельцы + No comment provided by engineer. + + + Ownership: you can run your own relays. + Владение: Вы можете запустить свои собственные релеи. + No comment provided by engineer. + PING count Количество PING @@ -5988,6 +6648,11 @@ Requires compatible VPN. Вставить изображение No comment provided by engineer. + + Paste link / Scan + Вставить ссылку / Сканировать + No comment provided by engineer. + Paste link to connect! Вставьте ссылку, чтобы соединиться! @@ -6042,12 +6707,12 @@ Please share any other issues with the developers. Please check that you used the correct link or ask your contact to send you another one. - Пожалуйста, проверьте, что Вы использовали правильную ссылку или попросите, чтобы Ваш контакт отправил Вам другую ссылку. + Пожалуйста, проверьте, что Вы использовали правильную ссылку, или попросите Ваш контакт отправить Вам новую. No comment provided by engineer. Please check your network connection with %@ and try again. - Пожалуйста, проверьте Ваше соединение с %@ и попробуйте еще раз. + Пожалуйста, проверьте Ваше соединение с %@ и попробуйте ещё раз. alert message @@ -6089,7 +6754,7 @@ Error: %@ Please report it to the developers. - Пожалуйста, сообщите об этой ошибке девелоперам. + Пожалуйста, сообщите об этой ошибке разработчикам. No comment provided by engineer. @@ -6099,12 +6764,12 @@ Error: %@ Please store passphrase securely, you will NOT be able to access chat if you lose it. - Пожалуйста, надежно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его. + Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его. No comment provided by engineer. Please store passphrase securely, you will NOT be able to change it if you lose it. - Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. + Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. No comment provided by engineer. @@ -6142,6 +6807,16 @@ Error: %@ Сохранить последний черновик, вместе с вложениями. No comment provided by engineer. + + Preset relay address + Адрес релея по умолчанию + No comment provided by engineer. + + + Preset relay name + Имя релея по умолчанию + No comment provided by engineer. + Preset server address Адрес сервера по умолчанию @@ -6177,19 +6852,19 @@ Error: %@ Политика конфиденциальности и условия использования. No comment provided by engineer. - - Privacy redefined - Более конфиденциальный + + Privacy: for owners and subscribers. + Конфиденциальность: для владельцев и подписчиков. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. + + Private and secure messaging. + Конфиденциальный и безопасный обмен сообщениями. No comment provided by engineer. Private filenames - Защищенные имена файлов + Защищённые имена файлов No comment provided by engineer. @@ -6227,6 +6902,11 @@ Error: %@ Таймаут конфиденциальной доставки alert title + + Proceed + Продолжить + alert action + Profile and server connections Профиль и соединения на сервере @@ -6252,9 +6932,9 @@ Error: %@ Тема профиля No comment provided by engineer. - - Profile update will be sent to your contacts. - Обновлённый профиль будет отправлен Вашим контактам. + + Profile update will be sent to your SimpleX contacts. + Обновление профиля будет отправлено Вашим SimpleX контактам. alert message @@ -6262,6 +6942,11 @@ Error: %@ Запретить аудио/видео звонки. No comment provided by engineer. + + Prohibit chats with admins. + Запретить чаты с админами. + No comment provided by engineer. + Prohibit irreversible message deletion. Запретить необратимое удаление сообщений. @@ -6289,27 +6974,32 @@ Error: %@ Prohibit sending direct messages to members. - Запретить посылать прямые сообщения членам группы. + Запретить посылать личные сообщения членам группы. + No comment provided by engineer. + + + Prohibit sending direct messages to subscribers. + Запретить отправку личных сообщений подписчикам. No comment provided by engineer. Prohibit sending disappearing messages. - Запретить посылать исчезающие сообщения. + Запретить отправлять исчезающие сообщения. No comment provided by engineer. Prohibit sending files and media. - Запретить слать файлы и медиа. + Запретить отправлять файлы и медиа. No comment provided by engineer. Prohibit sending voice messages. - Запретить отправлять голосовые сообщений. + Запретить отправлять голосовые сообщения. No comment provided by engineer. Protect IP address - Защитить IP адрес + Защитить IP-адрес No comment provided by engineer. @@ -6320,7 +7010,7 @@ Error: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. - Защитите ваш IP адрес от серверов сообщений, выбранных Вашими контактами. + Защитите ваш IP-адрес от серверов сообщений, выбранных Вашими контактами. Включите в настройках *Сети и серверов*. No comment provided by engineer. @@ -6359,6 +7049,11 @@ Enable in *Network & servers* settings. Прокси требует пароль No comment provided by engineer. + + Public channels - speak freely 🚀 + Публичные каналы - говорите свободно 🚀 + No comment provided by engineer. + Push notifications Доставка уведомлений @@ -6399,24 +7094,14 @@ Enable in *Network & servers* settings. Узнать больше No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Дополнительная информация в [Руководстве пользователя](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Узнать больше в Руководстве пользователя. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Узнать больше в [Руководстве пользователя](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Узнать больше в [Руководстве пользователя](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Узнайте больше из нашего [GitHub репозитория](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Узнайте больше из нашего GitHub репозитория. No comment provided by engineer. @@ -6426,7 +7111,7 @@ Enable in *Network & servers* settings. Receive errors - Ошибки приема + Ошибки приёма No comment provided by engineer. @@ -6439,11 +7124,6 @@ Enable in *Network & servers* settings. Получено: %@ copied message info - - Received file event - Загрузка файла - notification - Received message Полученное сообщение @@ -6578,23 +7258,63 @@ swipe action Reject member? - Отклонить участника? + Отклонить члена группы? alert title + + Relay + Релей + No comment provided by engineer. + + + Relay address + Адрес релея + alert title + + + Relay connection failed + Ошибка подключения релея + alert title + + + Relay link + Ссылка релея + No comment provided by engineer. + + + Relay results: + Результаты релея: + alert message + Relay server is only used if necessary. Another party can observe your IP address. - Relay сервер используется только при необходимости. Другая сторона может видеть Ваш IP адрес. + Релей-сервер используется только при необходимости. Другая сторона может видеть Ваш IP-адрес. No comment provided by engineer. Relay server protects your IP address, but it can observe the duration of the call. - Relay сервер защищает Ваш IP адрес, но может отслеживать продолжительность звонка. + Релей-сервер защищает Ваш IP-адрес, но может отслеживать продолжительность звонка. + No comment provided by engineer. + + + Relay test failed! + Тест релея не пройден! + No comment provided by engineer. + + + Reliability: many relays per channel. + Надёжность: несколько релеев на каждый канал. No comment provided by engineer. Remove Удалить - No comment provided by engineer. + alert action + + + Remove and delete messages + Удалить вместе с сообщениями + alert action Remove archive? @@ -6619,13 +7339,23 @@ swipe action Remove member? Удалить члена группы? - No comment provided by engineer. + alert title Remove passphrase from keychain? Удалить пароль из Keychain? No comment provided by engineer. + + Remove subscriber + Удалить подписчика + No comment provided by engineer. + + + Remove subscriber? + Удалить подписчика? + alert title + Removes messages and blocks members. Может удалять сообщения и блокировать членов. @@ -6768,12 +7498,12 @@ swipe action Restart the app to create a new chat profile - Перезапустите приложение, чтобы создать новый профиль. + Перезапустите приложение, чтобы создать новый профиль No comment provided by engineer. Restart the app to use imported chat database - Перезапустите приложение, чтобы использовать импортированные данные чата. + Перезапустите приложение, чтобы использовать импортированные данные чата No comment provided by engineer. @@ -6818,12 +7548,12 @@ swipe action Review members - Одобрять членов + Одобрять членов группы admission stage Review members before admitting ("knocking"). - Одобрять членов для вступления в группу. + Вручную одобрять членов для вступления в группу. admission stage description @@ -6853,12 +7583,17 @@ swipe action SMP server - SMP сервер + SMP-сервер No comment provided by engineer. SOCKS proxy - SOCKS прокси + SOCKS-прокси + No comment provided by engineer. + + + Safe web links + Безопасные веб-ссылки No comment provided by engineer. @@ -6887,6 +7622,11 @@ chat item action Сохранить (и уведомить членов) alert button + + Save (and notify subscribers) + Сохранить (и уведомить подписчиков) + alert button + Save admission settings? Сохранить настройки вступления? @@ -6902,6 +7642,11 @@ chat item action Сохранить и уведомить членов группы No comment provided by engineer. + + Save and notify subscribers + Сохранить и уведомить подписчиков + No comment provided by engineer. + Save and reconnect Сохранить и переподключиться @@ -6912,6 +7657,16 @@ chat item action Сохранить сообщение и обновить группу No comment provided by engineer. + + Save channel profile + Сохранить профиль канала + No comment provided by engineer. + + + Save channel profile? + Сохранить профиль канала? + alert title + Save group profile Сохранить профиль группы @@ -6974,7 +7729,7 @@ chat item action Saved WebRTC ICE servers will be removed - Сохраненные WebRTC ICE серверы будут удалены + Сохранённые WebRTC ICE-серверы будут удалены No comment provided by engineer. @@ -6984,7 +7739,7 @@ chat item action Saved message - Сохраненное сообщение + Сохранённое сообщение message info title @@ -7004,12 +7759,12 @@ chat item action Scan QR code - Сканировать QR код + Сканировать QR-код No comment provided by engineer. Scan QR code from desktop - Сканировать QR код с компьютера + Сканировать QR-код с компьютера No comment provided by engineer. @@ -7024,7 +7779,7 @@ chat item action Scan server QR code - Сканировать QR код сервера + Сканировать QR-код сервера No comment provided by engineer. @@ -7037,9 +7792,34 @@ chat item action Поле поиска поддерживает ссылки-приглашения. No comment provided by engineer. + + Search files + Поиск файлов + No comment provided by engineer. + + + Search images + Поиск изображений + No comment provided by engineer. + + + Search links + Поиск ссылок + No comment provided by engineer. + Search or paste SimpleX link - Искать или вставьте ссылку SimpleX + Искать или вставить ссылку SimpleX + No comment provided by engineer. + + + Search videos + Поиск видео + No comment provided by engineer. + + + Search voice messages + Поиск голосовых сообщений No comment provided by engineer. @@ -7067,6 +7847,11 @@ chat item action Код безопасности No comment provided by engineer. + + Security: owners hold channel keys. + Безопасность: владельцы хранят ключи канала. + No comment provided by engineer. + Select Выбрать @@ -7114,7 +7899,7 @@ chat item action Send a live message - it will update for the recipient(s) as you type it - Отправить живое сообщение — оно будет обновляться для получателей по мере того, как Вы его вводите + Отправить живое сообщение - оно будет обновляться для получателей по мере того, как Вы его вводите No comment provided by engineer. @@ -7129,7 +7914,7 @@ chat item action Send direct message to connect - Отправьте сообщение чтобы соединиться + Отправить личное сообщение контакту No comment provided by engineer. @@ -7159,7 +7944,7 @@ chat item action Send messages directly when IP address is protected and your or destination server does not support private routing. - Отправлять сообщения напрямую, когда IP адрес защищен, и Ваш сервер или сервер получателя не поддерживает конфиденциальную доставку. + Отправлять сообщения напрямую, когда IP-адрес защищён, и Ваш сервер или сервер получателя не поддерживает конфиденциальную доставку. No comment provided by engineer. @@ -7179,7 +7964,7 @@ chat item action Send questions and ideas - Отправьте вопросы и идеи + Вопросы и предложения No comment provided by engineer. @@ -7197,6 +7982,11 @@ chat item action Отправить запрос без сообщения No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + Отправьте ссылку через любой мессенджер - это безопасно. Попросите вставить её в SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Отправьте из галереи или из дополнительных клавиатур. @@ -7207,6 +7997,11 @@ chat item action Отправить до 100 последних сообщений новым членам. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + Отправлять до 100 последних сообщений новым подписчикам. + No comment provided by engineer. + Send your private feedback to groups. Отправляйте Ваши конфиденциальные предложения группе. @@ -7222,6 +8017,11 @@ chat item action Отправитель мог удалить запрос на соединение. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + Отправка картинки ссылки может раскрыть Ваш IP-адрес веб-сайту. Вы можете изменить это в настройках безопасности позже. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Отправка отчётов о доставке будет включена для всех контактов во всех видимых профилях чата. @@ -7277,11 +8077,6 @@ chat item action Отправлено напрямую No comment provided by engineer. - - Sent file event - Отправка файла - notification - Sent message Отправленное сообщение @@ -7339,7 +8134,7 @@ chat item action Server operator changed. - Оператор серверов изменен. + Оператор сервера изменен. alert title @@ -7352,14 +8147,19 @@ chat item action Протокол сервера изменен. alert title + + Server requires authorization to connect to relay, check password. + Для подключения к релею требуется авторизация, проверьте пароль. + relay test error + Server requires authorization to create queues, check password. - Сервер требует авторизации для создания очередей, проверьте пароль + Сервер требует авторизации для создания очередей, проверьте пароль. server test error Server requires authorization to upload, check password. - Сервер требует авторизации для загрузки, проверьте пароль + Сервер требует авторизации для загрузки, проверьте пароль. server test error @@ -7482,6 +8282,16 @@ chat item action Настройки были изменены. alert message + + Setup notifications + Настроить уведомления + No comment provided by engineer. + + + Setup routers + Настроить серверы + No comment provided by engineer. + Shape profile images Форма картинок профилей @@ -7518,11 +8328,16 @@ chat item action Поделитесь адресом No comment provided by engineer. - - Share address with contacts? - Поделиться адресом с контактами? + + Share address with SimpleX contacts? + Поделиться адресом с контактами SimpleX? alert title + + Share channel + Поделиться каналом + No comment provided by engineer. + Share from other apps. Поделитесь из других приложений. @@ -7548,6 +8363,11 @@ chat item action Поделиться профилем No comment provided by engineer. + + Share relay address + Поделиться адресом релея + No comment provided by engineer. + Share this 1-time invite link Поделиться одноразовой ссылкой-приглашением @@ -7558,9 +8378,14 @@ chat item action Поделиться в SimpleX No comment provided by engineer. - - Share with contacts - Поделиться с контактами + + Share via chat + Поделиться в чате + No comment provided by engineer. + + + Share with SimpleX contacts + Поделиться с контактами SimpleX No comment provided by engineer. @@ -7585,7 +8410,7 @@ chat item action Show QR code - Показать QR код + Показать QR-код No comment provided by engineer. @@ -7595,7 +8420,7 @@ chat item action Show developer options - Показать опции для девелоперов + Показать опции для разработчиков No comment provided by engineer. @@ -7685,7 +8510,7 @@ chat item action SimpleX address settings - Настройки автоприема + Настройки автоприёма alert title @@ -7710,7 +8535,7 @@ chat item action SimpleX links - SimpleX ссылки + Ссылки SimpleX chat feature @@ -7733,14 +8558,14 @@ chat item action Аудит SimpleX протоколов от Trail of Bits. No comment provided by engineer. - - SimpleX relay link - Ссылка SimpleX relay + + SimpleX relay address + Адрес релея SimpleX simplex link type Simplified incognito mode - Упрощенный режим Инкогнито + Упрощённый режим Инкогнито No comment provided by engineer. @@ -7811,6 +8636,11 @@ report reason Квадрат, круг и все, что между ними. No comment provided by engineer. + + Star on GitHub + Поставить звёздочку на GitHub + No comment provided by engineer. + Start chat Запустить чат @@ -7911,6 +8741,78 @@ report reason Подписано No comment provided by engineer. + + Subscriber + Подписчик + No comment provided by engineer. + + + Subscriber reports + Сообщения о нарушениях + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + Подписчик будет удалён из канала - это нельзя отменить! + alert message + + + Subscribers + Подписчики + No comment provided by engineer. + + + Subscribers can add message reactions. + Подписчики могут добавлять реакции на сообщения. + No comment provided by engineer. + + + Subscribers can chat with admins. + Подписчики могут общаться с админами. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + Подписчики могут необратимо удалять отправленные сообщения. (24 часа) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + Подписчики могут отправлять сообщения о нарушениях модераторам. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + Подписчики могут отправлять ссылки SimpleX. + No comment provided by engineer. + + + Subscribers can send direct messages. + Подписчики могут отправлять личные сообщения. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + Подписчики могут отправлять исчезающие сообщения. + No comment provided by engineer. + + + Subscribers can send files and media. + Подписчики могут отправлять файлы и медиа. + No comment provided by engineer. + + + Subscribers can send voice messages. + Подписчики могут отправлять голосовые сообщения. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + Подписчики используют ссылку релея для подключения к каналу. +Адрес релея был использован для настройки этого релея для канала. + No comment provided by engineer. + Subscription errors Ошибки подписки @@ -7958,7 +8860,7 @@ report reason TCP connection timeout - Таймаут TCP соединения + Таймаут TCP-соединения No comment provided by engineer. @@ -7991,6 +8893,11 @@ report reason Сделать фото No comment provided by engineer. + + Talk to someone + Начните разговор + No comment provided by engineer. + Tap Connect to chat Нажмите Соединиться @@ -8003,12 +8910,12 @@ report reason Tap Connect to use bot - Нажмите Соединиться, чтобы использовать бот. + Нажмите Соединиться, чтобы использовать бот No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Нажмите Создать адрес SimpleX в меню, чтобы создать его позже. + + Tap Join channel + Нажмите Войти в канал No comment provided by engineer. @@ -8023,12 +8930,12 @@ report reason Tap to Connect - Нажмите чтобы соединиться + Нажмите, чтобы соединиться No comment provided by engineer. Tap to activate profile. - Нажмите, чтобы сделать профиль активным. + Нажмите на профиль, чтобы переключиться. No comment provided by engineer. @@ -8041,6 +8948,11 @@ report reason Нажмите, чтобы вступить инкогнито No comment provided by engineer. + + Tap to open + Нажмите, чтобы открыть + No comment provided by engineer. + Tap to paste link Нажмите, чтобы вставить ссылку @@ -8059,13 +8971,19 @@ report reason Test failed at step %@. Ошибка теста на шаге %@. - server test failure + relay test failure +server test failure Test notifications Протестировать уведомления No comment provided by engineer. + + Test relay + Тест релея + No comment provided by engineer. + Test server Тестировать сервер @@ -8118,6 +9036,11 @@ It can happen because of some bug or when the connection is compromised.Приложение улучшает конфиденциальность используя разных операторов в каждом разговоре. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + Приложение удалило это сообщение после %lld попыток его получить. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). Приложение будет запрашивать подтверждение загрузки с неизвестных серверов (за исключением .onion адресов). @@ -8130,9 +9053,14 @@ It can happen because of some bug or when the connection is compromised. The code you scanned is not a SimpleX link QR code. - Этот QR код не является SimpleX-ccылкой. + Этот QR-код не является SimpleX-ccылкой. No comment provided by engineer. + + The connection reached the limit of undelivered messages + Соединение достигло лимита недоставленных сообщений + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети. @@ -8158,9 +9086,11 @@ It can happen because of some bug or when the connection is compromised.Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! No comment provided by engineer. - - The future of messaging - Будущее коммуникаций + + The first network where you own +your contacts and groups. + Первая сеть, в которой Вы владеете +своими контактами и группами. No comment provided by engineer. @@ -8180,7 +9110,7 @@ It can happen because of some bug or when the connection is compromised. The message will be marked as moderated for all members. - Сообщение будет помечено как удаленное для всех членов группы. + Сообщение будет помечено как удалённое для всех членов группы. No comment provided by engineer. @@ -8190,7 +9120,7 @@ It can happen because of some bug or when the connection is compromised. The messages will be marked as moderated for all members. - Сообщения будут помечены как удаленные для всех членов группы. + Сообщения будут помечены как удалённые для всех членов группы. No comment provided by engineer. @@ -8198,9 +9128,14 @@ It can happen because of some bug or when the connection is compromised.Предыдущая версия данных чата не удалена при перемещении, её можно удалить. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + Древнейшая человеческая свобода - говорить с другим человеком без слежки - построенная на инфраструктуре, которая не может её предать. + No comment provided by engineer. + The same conditions will apply to operator **%@**. - Те же самые условия будут приняты для оператора **%@**. + Те же условия будут действовать для оператора **%s**. No comment provided by engineer. @@ -8235,7 +9170,7 @@ It can happen because of some bug or when the connection is compromised. The uploaded database archive will be permanently removed from the servers. - Загруженный архив базы данных будет навсегда удален с серверов. + Загруженный архив базы данных будет навсегда удалён с серверов. No comment provided by engineer. @@ -8243,6 +9178,16 @@ It can happen because of some bug or when the connection is compromised.Темы No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + Потом мы вышли в интернет, и каждая платформа попросила частичку вас - ваше имя, ваш номер, ваших друзей. Мы смирились с тем, что за возможность общаться приходится отдавать информацию о том, с кем мы общаемся. Каждое поколение людей и технологий жило так - телефон, электронная почта, мессенджеры, социальные сети. Казалось, что другого пути нет. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + Другой путь есть. Сеть без номеров телефонов. Без имён пользователей. Без аккаунтов. Без каких-либо идентификаторов пользователей. Сеть, которая соединяет людей и передаёт зашифрованные сообщения, не зная, кто с кем связан. + No comment provided by engineer. + These conditions will also apply for: **%@**. Эти условия также будут применены к: **%@**. @@ -8255,17 +9200,17 @@ It can happen because of some bug or when the connection is compromised. They can be overridden in contact and group settings. - Они могут быть переопределены в настройках контактов и групп. + Они могут быть изменены в настройках контактов и групп. No comment provided by engineer. This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Это действие нельзя отменить — все полученные и отправленные файлы будут удалены. Изображения останутся в низком разрешении. + Это действие нельзя отменить - все полученные и отправленные файлы будут удалены. Изображения останутся в низком разрешении. No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут. + Это действие нельзя отменить - все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут. No comment provided by engineer. @@ -8275,17 +9220,17 @@ It can happen because of some bug or when the connection is compromised. This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. + Это действие нельзя отменить - Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. No comment provided by engineer. This chat is protected by end-to-end encryption. - Чат защищен end-to-end шифрованием. + Чат защищён сквозным шифрованием. E2EE info chat item This chat is protected by quantum resistant end-to-end encryption. - Чат защищен квантово-устойчивым end-to-end шифрованием. + Чат защищён квантово-устойчивым сквозным шифрованием. E2EE info chat item @@ -8308,6 +9253,16 @@ It can happen because of some bug or when the connection is compromised.Эта группа больше не существует. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + Это адрес чат-релея, с ним нельзя соединиться. + alert message + + + This is your link for channel %@! + Это ваша ссылка на канал %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку. @@ -8320,7 +9275,7 @@ It can happen because of some bug or when the connection is compromised. This message was deleted or not received yet. - Это сообщение было удалено или еще не получено. + Это сообщение было удалено или ещё не получено. No comment provided by engineer. @@ -8358,6 +9313,11 @@ It can happen because of some bug or when the connection is compromised.Чтобы скрыть нежелательные сообщения. No comment provided by engineer. + + To make SimpleX Network last. + Чтобы сохранить сеть SimpleX для всех. + No comment provided by engineer. + To make a new connection Чтобы соединиться @@ -8375,7 +9335,7 @@ It can happen because of some bug or when the connection is compromised. To protect your IP address, private routing uses your SMP servers to deliver messages. - Чтобы защитить ваш IP адрес, приложение использует Ваши SMP серверы для конфиденциальной доставки сообщений. + Чтобы защитить Ваш IP-адрес, приложение использует Ваши SMP-серверы для конфиденциальной доставки сообщений. No comment provided by engineer. @@ -8387,7 +9347,7 @@ You will be prompted to complete authentication before this feature is enabled.< To protect your privacy, SimpleX uses separate IDs for each of your contacts. - Чтобы защитить Вашу конфиденциальность, SimpleX использует разные идентификаторы для каждого Вашeго контакта. + Чтобы защитить Вашу конфиденциальность, SimpleX использует разные ID для каждого Вашего контакта. No comment provided by engineer. @@ -8417,7 +9377,7 @@ You will be prompted to complete authentication before this feature is enabled.< To send - Для оправки + Для отправки No comment provided by engineer. @@ -8442,12 +9402,7 @@ You will be prompted to complete authentication before this feature is enabled.< To verify end-to-end encryption with your contact compare (or scan) the code on your devices. - Чтобы подтвердить end-to-end шифрование с Вашим контактом сравните (или сканируйте) код безопасности на Ваших устройствах. - No comment provided by engineer. - - - Toggle chat list: - Переключите список чатов: + Чтобы подтвердить безопасность сквозного шифрования с Вашим контактом сравните (или сканируйте) код на ваших устройствах. No comment provided by engineer. @@ -8465,6 +9420,11 @@ You will be prompted to complete authentication before this feature is enabled.< Прозрачность тулбара No comment provided by engineer. + + Top bar + Верхнее меню + No comment provided by engineer. + Total Всего @@ -8472,7 +9432,7 @@ You will be prompted to complete authentication before this feature is enabled.< Transport isolation - Отдельные сессии для + Отдельные транспортные сессии No comment provided by engineer. @@ -8480,15 +9440,10 @@ You will be prompted to complete authentication before this feature is enabled.< Транспортные сессии No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. + + Trying to connect to the server used to receive messages from this connection. Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта. - No comment provided by engineer. + subscription status explanation Turkish interface @@ -8535,6 +9490,11 @@ You will be prompted to complete authentication before this feature is enabled.< Разблокировать члена группы? No comment provided by engineer. + + Unblock subscriber for all? + Разблокировать подписчика для всех? + No comment provided by engineer. + Undelivered messages Недоставленные сообщения @@ -8599,7 +9559,7 @@ You will be prompted to complete authentication before this feature is enabled.< Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. Возможно, Ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом. -Чтобы установить соединение, попросите Ваш контакт создать еще одну ссылку и проверьте Ваше соединение с сетью. +Чтобы установить соединение, попросите Ваш контакт создать ещё одну ссылку и проверьте Ваше соединение с сетью. No comment provided by engineer. @@ -8635,13 +9595,18 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link Ссылка не поддерживается - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. До 100 последних сообщений отправляются новым членам. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + До 100 последних сообщений отправляется новым подписчикам. + No comment provided by engineer. + Update Обновить @@ -8664,12 +9629,12 @@ To connect, please ask your contact to create another connection link and check Updated conditions - Обновленные условия + Обновлённые условия No comment provided by engineer. Updating settings will re-connect the client to all servers. - Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами. + Обновление настроек приведёт к сбросу и установке нового соединения со всеми серверами. No comment provided by engineer. @@ -8749,7 +9714,7 @@ To connect, please ask your contact to create another connection link and check Use SOCKS proxy - Использовать SOCKS прокси + Использовать SOCKS-прокси No comment provided by engineer. @@ -8767,11 +9732,6 @@ To connect, please ask your contact to create another connection link and check Использовать TCP-порт 443 только для серверов по умолчанию. No comment provided by engineer. - - Use chat - Использовать чат - No comment provided by engineer. - Use current profile Использовать активный профиль @@ -8787,6 +9747,11 @@ To connect, please ask your contact to create another connection link and check Использовать для сообщений No comment provided by engineer. + + Use for new channels + Использовать для новых каналов + No comment provided by engineer. + Use for new connections Использовать для новых соединений @@ -8809,7 +9774,7 @@ To connect, please ask your contact to create another connection link and check Use new incognito profile - Использовать новый Инкогнито профиль + Использовать новый профиль инкогнито new chat action @@ -8819,7 +9784,7 @@ To connect, please ask your contact to create another connection link and check Use private routing with unknown servers when IP address is not protected. - Использовать конфиденциальную доставку с неизвестными серверами, когда IP адрес не защищен. + Использовать конфиденциальную доставку с неизвестными серверами, когда IP-адрес не защищён. No comment provided by engineer. @@ -8827,6 +9792,11 @@ To connect, please ask your contact to create another connection link and check Использовать конфиденциальную доставку с неизвестными серверами. No comment provided by engineer. + + Use relay + Использовать релей + No comment provided by engineer. + Use server Использовать сервер @@ -8847,6 +9817,11 @@ To connect, please ask your contact to create another connection link and check Используйте приложение одной рукой. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + Используйте этот адрес в профиле социальных сетей, на сайте или в подписи email. + No comment provided by engineer. + Use web port Использовать веб-порт @@ -8867,6 +9842,11 @@ To connect, please ask your contact to create another connection link and check Используются серверы, предоставленные SimpleX Chat. No comment provided by engineer. + + Verify + Проверить + relay test step + Verify code with desktop Сверьте код с компьютером @@ -8927,6 +9907,11 @@ To connect, please ask your contact to create another connection link and check Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже! No comment provided by engineer. + + Videos + Видео + No comment provided by engineer. + Videos and files up to 1gb Видео и файлы до 1гб @@ -8982,6 +9967,21 @@ To connect, please ask your contact to create another connection link and check Голосовое сообщение… No comment provided by engineer. + + Wait + Подождать + alert action + + + Wait response + Ожидание ответа + relay test step + + + Waiting for channel owner to add relays. + Ожидает, когда владелец канала добавит релеи. + No comment provided by engineer. + Waiting for desktop... Ожидается подключение компьютера... @@ -8989,12 +9989,12 @@ To connect, please ask your contact to create another connection link and check Waiting for file - Ожидается прием файла + Ожидается приём файла No comment provided by engineer. Waiting for image - Ожидается прием изображения + Ожидается приём изображения No comment provided by engineer. @@ -9014,17 +10014,22 @@ To connect, please ask your contact to create another connection link and check Warning: starting chat on multiple devices is not supported and will cause message delivery failures - Внимание: запуск чата на нескольких устройствах не поддерживается и приведет к сбоям доставки сообщений + Внимание: запуск чата на нескольких устройствах не поддерживается и приведёт к сбоям доставки сообщений No comment provided by engineer. Warning: you may lose some data! - Предупреждение: Вы можете потерять какие то данные! + Предупреждение: Вы можете потерять некоторые данные! + No comment provided by engineer. + + + We made connecting simpler for new users. + Мы упростили подключение для новых пользователей. No comment provided by engineer. WebRTC ICE servers - WebRTC ICE серверы + WebRTC ICE-серверы No comment provided by engineer. @@ -9049,7 +10054,7 @@ To connect, please ask your contact to create another connection link and check What's new - Новые функции + Что нового No comment provided by engineer. @@ -9069,7 +10074,12 @@ To connect, please ask your contact to create another connection link and check When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. - Когда Вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом. + Когда Вы соединены с контактом инкогнито, тот же самый профиль инкогнито будет использоваться для групп с этим контактом. + No comment provided by engineer. + + + Why SimpleX is built. + Зачем создан SimpleX. No comment provided by engineer. @@ -9104,12 +10114,12 @@ To connect, please ask your contact to create another connection link and check Without Tor or VPN, your IP address will be visible to file servers. - Без Тора или ВПН, Ваш IP адрес будет доступен серверам файлов. + Без Tor или VPN, Ваш IP-адрес будет доступен серверам файлов. No comment provided by engineer. Without Tor or VPN, your IP address will be visible to these XFTP relays: %@. - Без Тора или ВПН, Ваш IP адрес будет доступен этим серверам файлов: %@. + Без Тора или ВПН, Ваш IP-адрес будет доступен этим серверам файлов: %@. alert message @@ -9124,7 +10134,7 @@ To connect, please ask your contact to create another connection link and check Wrong key or unknown file chunk address - most likely file is deleted. - Неверный ключ или неизвестный адрес блока файла - скорее всего, файл удален. + Неверный ключ или неизвестный адрес блока файла - скорее всего, файл удалён. file error text @@ -9134,7 +10144,7 @@ To connect, please ask your contact to create another connection link and check XFTP server - XFTP сервер + XFTP-сервер No comment provided by engineer. @@ -9199,16 +10209,21 @@ Repeat join request? Повторить запрос на вступление? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Вы подключены к серверу, используемому для приёма сообщений от этого соединения. + subscription status explanation You are invited to group Вы приглашены в группу No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Вы не подключены к серверу, через который Вы получали сообщения от этого контакта (нет подписки). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Вы не подключены к этим серверам. Для доставки сообщений на них используется конфиденциальная доставка. @@ -9246,7 +10261,7 @@ Repeat join request? You can give another try. - Вы можете попробовать еще раз. + Вы можете попробовать ещё раз. No comment provided by engineer. @@ -9279,9 +10294,14 @@ Repeat join request? Вы можете установить просмотр уведомлений на экране блокировки в настройках. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + Вы можете поделиться ссылкой или QR-кодом - любой сможет вступить в канал. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. - Вы можете поделиться ссылкой или QR кодом - через них можно присоединиться к группе. Вы сможете удалить ссылку, сохранив членов группы, которые через нее соединились. + Вы можете поделиться ссылкой или QR-кодом - любой сможет присоединиться к группе. Члены группы останутся, даже если вы позже удалите ссылку. No comment provided by engineer. @@ -9291,7 +10311,7 @@ Repeat join request? You can start chat via app Settings / Database or by restarting the app - Вы можете запустить чат через Настройки приложения или перезапустив приложение. + Вы можете запустить чат через Настройки приложения или перезапустив приложение No comment provided by engineer. @@ -9324,14 +10344,23 @@ Repeat join request? Вы не можете отправлять сообщения! alert title - - You could not be verified; please try again. - Верификация не удалась; пожалуйста, попробуйте ещё раз. + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + Вы обязуетесь: +- Только законный контент в публичных группах +- Уважать других пользователей - без спама No comment provided by engineer. - - You decide who can connect. - Вы определяете, кто может соединиться. + + You connected to the channel via this relay link. + Вы подключились к каналу через эту ссылку релея. + No comment provided by engineer. + + + You could not be verified; please try again. + Ошибка аутентификации; попробуйте ещё раз. No comment provided by engineer. @@ -9343,7 +10372,7 @@ Repeat connection request? You have to enter passphrase every time the app starts - it is not stored on the device. - Пароль не сохранен на устройстве — Вы будете должны ввести его при каждом запуске чата. + Пароль не сохранён на устройстве - Вы будете должны ввести его при каждом запуске чата. No comment provided by engineer. @@ -9358,7 +10387,7 @@ Repeat connection request? You joined this group. Connecting to inviting group member. - Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы. + Вы вступили в группу. Устанавливается соединение с пригласившим Вас членом группы. No comment provided by engineer. @@ -9373,7 +10402,7 @@ Repeat connection request? You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. - Вы должны всегда использовать самую новую версию данных чата, ТОЛЬКО на одном устройстве, иначе Вы можете перестать получать сообщения от каких то контактов. + Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе Вы можете перестать получать сообщения от некоторых контактов. No comment provided by engineer. @@ -9401,6 +10430,11 @@ Repeat connection request? Вы должны получать уведомления. token info + + You were born without an account + Вы родились без аккаунта + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. Вы сможете отправлять сообщения **только после того как Ваш запрос будет принят**. @@ -9433,7 +10467,12 @@ Repeat connection request? You will still receive calls and notifications from muted profiles when they are active. - Вы все равно получите звонки и уведомления в профилях без звука, когда они активные. + Вы всё равно получите звонки и уведомления в профилях без звука, когда они активные. + No comment provided by engineer. + + + You will stop receiving messages from this channel. Chat history will be preserved. + Вы перестанете получать сообщения из этого канала. История чата сохранится. No comment provided by engineer. @@ -9448,22 +10487,22 @@ Repeat connection request? You won't lose your contacts if you later delete your address. - Вы сможете удалить адрес, сохранив контакты, которые через него соединились. + Вы не потеряете контакты, если позже удалите Ваш адрес. No comment provided by engineer. 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 - Вы пытаетесь пригласить инкогнито контакт в группу, где Вы используете свой основной профиль + Вы пытаетесь пригласить контакт, который знает Ваш профиль инкогнито, в группу, где Вы используете основной профиль No comment provided by engineer. You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed - Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие Вашего основного профиля, приглашать контакты не разрешено + Вы используете профиль инкогнито в этой группе. Для защиты Вашего основного профиля приглашать контакты запрещено No comment provided by engineer. Your ICE servers - Ваши ICE серверы + Ваши ICE-серверы No comment provided by engineer. @@ -9473,7 +10512,7 @@ Repeat connection request? Your business contact - Ваш бизнес контакт + Ваш бизнес-контакт No comment provided by engineer. @@ -9481,6 +10520,11 @@ Repeat connection request? Ваши звонки No comment provided by engineer. + + Your channel + Ваш канал + No comment provided by engineer. + Your chat database База данных @@ -9531,9 +10575,14 @@ Repeat connection request? Ваши контакты сохранятся. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + Ваши разговоры принадлежат вам, как это всегда было до интернета. Сеть - это не место, куда вы приходите. Это место, которое вы создаёте и которым владеете. И никто не может это у вас отнять, делаете ли вы его конфиденциальным или публичным. + No comment provided by engineer. + Your credentials may be sent unencrypted. - Ваши учетные данные могут быть отправлены в незашифрованном виде. + Ваши учётные данные могут быть отправлены в незашифрованном виде. No comment provided by engineer. @@ -9551,6 +10600,11 @@ Repeat connection request? Ваша группа No comment provided by engineer. + + Your network + Ваша сеть + No comment provided by engineer. + Your preferences Ваши предпочтения @@ -9566,6 +10620,13 @@ Repeat connection request? Ваш профиль No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + Ваш профиль **%@** будет отправлен чат-релеям и подписчикам канала. +Релеи могут видеть сообщения канала. + No comment provided by engineer. + Your profile **%@** will be shared. Будет отправлен Ваш профиль **%@**. @@ -9578,19 +10639,34 @@ Repeat connection request? Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. - Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю. + Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. Серверы SimpleX не могут получить доступ к Вашему профилю. No comment provided by engineer. Your profile was changed. If you save it, the updated profile will be sent to all your contacts. - Ваш профиль был изменен. Если вы сохраните его, обновленный профиль будет отправлен всем вашим контактам. + Ваш профиль был изменен. Если вы сохраните его, обновлённый профиль будет отправлен всем вашим контактам. alert message + + Your public address + Ваш публичный адрес + No comment provided by engineer. + Your random profile Случайный профиль No comment provided by engineer. + + Your relay address + Ваш адрес релея + No comment provided by engineer. + + + Your relay name + Ваше имя релея + No comment provided by engineer. + Your server address Адрес Вашего сервера @@ -9606,21 +10682,11 @@ Repeat connection request? Настройки No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Внести свой вклад](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Отправить email](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Поставить звездочку в GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_курсив_ @@ -9636,6 +10702,11 @@ Repeat connection request? наверху, затем выберите: No comment provided by engineer. + + accepted + принят(а) + No comment provided by engineer. + accepted %@ принят %@ @@ -9656,6 +10727,11 @@ Repeat connection request? Вы приняты rcv group event chat item + + active + активный + No comment provided by engineer. + admin админ @@ -9723,7 +10799,7 @@ Repeat connection request? bad message hash - ошибка хэш сообщения + ошибка хэша сообщения integrity error chat item @@ -9767,6 +10843,11 @@ marked deleted chat item preview text входящий звонок… call status + + can't broadcast + нельзя публиковать + No comment provided by engineer. + can't send messages нельзя отправлять @@ -9802,6 +10883,16 @@ marked deleted chat item preview text смена адреса… chat item text + + channel + канал + shown as sender role for channel messages + + + channel profile updated + профиль канала обновлён + snd group event chat item + colored цвет @@ -9819,7 +10910,7 @@ marked deleted chat item preview text connected - соединение установлено + соединен(а) No comment provided by engineer. @@ -9874,7 +10965,7 @@ marked deleted chat item preview text contact deleted - контакт удален + контакт удалён No comment provided by engineer. @@ -9948,6 +11039,11 @@ pref value удалено deleted chat item + + deleted channel + удалил(а) канал + rcv group event chat item + deleted contact удалил(а) контакт @@ -9970,7 +11066,7 @@ pref value disabled - выключено + выключен No comment provided by engineer. @@ -10058,11 +11154,21 @@ pref value ошибка No comment provided by engineer. + + error: %@ + ошибка: %@ + receive error chat item + expired истекло No comment provided by engineer. + + failed + ошибка + No comment provided by engineer. + forwarded переслано @@ -10085,7 +11191,7 @@ pref value group profile updated - профиль группы обновлен + профиль группы обновлён snd group event chat item @@ -10100,7 +11206,7 @@ pref value iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. - Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления. + Пароль базы данных будет безопасно сохранён в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления. No comment provided by engineer. @@ -10183,6 +11289,11 @@ pref value покинул(а) группу rcv group event chat item + + link + ссылка + No comment provided by engineer. + marked deleted помечено к удалению @@ -10205,7 +11316,7 @@ pref value member has old version - член имеет старую версию + член группы имеет старую версию No comment provided by engineer. @@ -10253,6 +11364,11 @@ pref value никогда delete after time + + new + новый + No comment provided by engineer. + new message новое сообщение @@ -10268,6 +11384,11 @@ pref value нет e2e шифрования No comment provided by engineer. + + no subscription + нет подписки + No comment provided by engineer. + no text нет текста @@ -10371,6 +11492,11 @@ time to disappear отклонённый звонок call status + + relay + релей + member role + removed удален(а) @@ -10381,6 +11507,16 @@ time to disappear удалил(а) %@ rcv group event chat item + + removed (%d attempts) + удалено (%d попыток) + receive error chat item + + + removed by operator + удалено оператором + No comment provided by engineer. + removed contact address удалён адрес контакта @@ -10388,7 +11524,7 @@ time to disappear removed from group - удален из группы + удалён из группы No comment provided by engineer. @@ -10492,7 +11628,7 @@ last received msg: %2$@ standard end-to-end encryption - стандартное end-to-end шифрование + стандартное сквозное шифрование chat item text @@ -10535,6 +11671,11 @@ last received msg: %2$@ незащищённый No comment provided by engineer. + + updated channel profile + обновлён профиль канала + rcv group event chat item + updated group profile обновил(а) профиль группы @@ -10555,6 +11696,11 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + через %@ + relay hostname + via contact address link через ссылку-контакт @@ -10572,7 +11718,7 @@ last received msg: %2$@ via relay - через relay сервер + через релей-сервер No comment provided by engineer. @@ -10622,7 +11768,7 @@ last received msg: %2$@ you accepted this member - Вы приняли этого члена + Вы приняли этого члена группы snd group event chat item @@ -10630,6 +11776,11 @@ last received msg: %2$@ только чтение сообщений No comment provided by engineer. + + you are subscriber + Вы подписчик + No comment provided by engineer. + you blocked %@ Вы заблокировали %@ @@ -10690,6 +11841,11 @@ last received msg: %2$@ \~зачеркнуть~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + ⚠️ Ошибка проверки подписи: %@. + owner verification + @@ -10862,7 +12018,7 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. - Пароль базы данных отличается от сохраненного в keychain. + Пароль базы данных отличается от сохранённого в keychain. No comment provided by engineer. @@ -10942,7 +12098,7 @@ last received msg: %2$@ Please create a profile in the SimpleX app - Пожалуйста, создайте профиль в приложении SimpleX. + Пожалуйста, создайте профиль в приложении SimpleX No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index a0ab509388..04c51bbf43 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -167,6 +167,21 @@ %d เดือน time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d วินาที @@ -181,11 +196,53 @@ %d ข้อความที่ถูกข้าม integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d สัปดาห์ time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -196,6 +253,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected % ผู้ติดต่อ LLD ที่เลือกไว้ @@ -290,10 +351,18 @@ %u ข้อความที่ถูกข้าม No comment provided by engineer. + + (from owner) + chat link info line + (new) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) No comment provided by engineer. @@ -334,6 +403,10 @@ **Scan / Paste link**: to connect via a link you received. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **คำเตือน**: การแจ้งเตือนแบบพุชทันทีจำเป็นต้องบันทึกรหัสผ่านไว้ใน Keychain @@ -373,6 +446,12 @@ - และอื่น ๆ! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -464,6 +543,10 @@ time interval อีกสองสามอย่าง No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact ผู้ติดต่อใหม่ @@ -576,9 +659,8 @@ swipe action Active connections No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - เพิ่มที่อยู่ลงในโปรไฟล์ของคุณ เพื่อให้ผู้ติดต่อของคุณสามารถแชร์กับผู้อื่นได้ การอัปเดตโปรไฟล์จะถูกส่งไปยังผู้ติดต่อของคุณ + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -638,6 +720,10 @@ swipe action Added message servers No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent No comment provided by engineer. @@ -718,6 +804,10 @@ swipe action สมาชิกในกลุ่มทุกคนจะยังคงเชื่อมต่ออยู่. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -739,6 +829,14 @@ swipe action All profiles profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. No comment provided by engineer. @@ -793,6 +891,10 @@ swipe action อนุญาตให้ลบข้อความแบบถาวรเฉพาะในกรณีที่ผู้ติดต่อของคุณอนุญาตให้คุณเท่านั้น No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. อนุญาตการแสดงปฏิกิริยาต่อข้อความเฉพาะเมื่อผู้ติดต่อของคุณอนุญาตเท่านั้น @@ -808,6 +910,10 @@ swipe action อนุญาตการส่งข้อความโดยตรงไปยังสมาชิก No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. อนุญาตให้ส่งข้อความที่จะหายไปหลังปิดแชท (disappearing message) @@ -817,6 +923,10 @@ swipe action Allow sharing No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) อนุญาตให้ลบข้อความที่ส่งไปแล้วอย่างถาวร @@ -915,11 +1025,6 @@ swipe action รับสาย No comment provided by engineer. - - Anybody can host servers. - โปรโตคอลและโค้ดโอเพ่นซอร์ส – ใคร ๆ ก็สามารถเปิดใช้เซิร์ฟเวอร์ได้ - No comment provided by engineer. - App build: %@ รุ่นแอป: %@ @@ -1034,6 +1139,10 @@ swipe action การโทรด้วยเสียงและวิดีโอ No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls การโทรด้วยเสียง/วิดีโอ @@ -1102,6 +1211,19 @@ swipe action แฮชข้อความไม่ดี No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls No comment provided by engineer. @@ -1179,6 +1301,10 @@ swipe action Block member? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin No comment provided by engineer. @@ -1224,13 +1350,21 @@ swipe action ทั้งคุณและผู้ติดต่อของคุณสามารถส่งข้อความเสียงได้ No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. Business address - No comment provided by engineer. + chat link info line Business chats @@ -1249,12 +1383,6 @@ swipe action ตามโปรไฟล์แชท (ค่าเริ่มต้น) หรือ [โดยการเชื่อมต่อ](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (เบต้า) No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - No comment provided by engineer. - Call already ended! สิ้นสุดการโทรแล้ว! @@ -1391,6 +1519,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat No comment provided by engineer. @@ -1467,6 +1656,22 @@ set passcode view โปรไฟล์ผู้ใช้ No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme No comment provided by engineer. @@ -1481,7 +1686,8 @@ set passcode view Chat with admins - chat toolbar + chat feature +chat toolbar Chat with member @@ -1496,10 +1702,22 @@ set passcode view แชท No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -1508,6 +1726,14 @@ set passcode view Check messages when allowed. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. ตรวจสอบที่อยู่เซิร์ฟเวอร์แล้วลองอีกครั้ง @@ -1616,7 +1842,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1635,8 +1861,8 @@ set passcode view กำหนดค่าเซิร์ฟเวอร์ ICE No comment provided by engineer. - - Configure server operators + + Configure relays No comment provided by engineer. @@ -1691,7 +1917,8 @@ set passcode view Connect เชื่อมต่อ - server test step + relay test step +server test step Connect automatically @@ -1728,6 +1955,10 @@ This is your own one-time link! เชื่อมต่อผ่านลิงก์ new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link new chat sheet title @@ -1795,6 +2026,10 @@ This is your own one-time link! Connection error (AUTH) การเชื่อมต่อผิดพลาด (AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -1840,6 +2075,10 @@ This is your own one-time link! Connections No comment provided by engineer. + + Contact address + chat link info line + Contact allows ผู้ติดต่ออนุญาต @@ -1905,6 +2144,11 @@ This is your own one-time link! ดำเนินการต่อ No comment provided by engineer. + + Contribute + มีส่วนร่วม + No comment provided by engineer. + Conversation deleted! No comment provided by engineer. @@ -1929,12 +2173,7 @@ This is your own one-time link! Correct name to %@? - No comment provided by engineer. - - - Create - สร้าง - No comment provided by engineer. + alert message Create 1-time link @@ -1980,6 +2219,14 @@ This is your own one-time link! Create profile No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue สร้างคิว @@ -1989,11 +2236,19 @@ This is your own one-time link! Create your address No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile สร้างโปรไฟล์ของคุณ No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created No comment provided by engineer. @@ -2010,6 +2265,10 @@ This is your own one-time link! Creating archive link No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… No comment provided by engineer. @@ -2161,10 +2420,9 @@ This is your own one-time link! Debug delivery No comment provided by engineer. - - Decentralized - กระจายอำนาจแล้ว - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2209,6 +2467,14 @@ swipe action Delete and notify contact No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat No comment provided by engineer. @@ -2317,6 +2583,14 @@ swipe action ลบข้อความสมาชิก? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? ลบข้อความ? @@ -2325,7 +2599,8 @@ swipe action Delete messages ลบข้อความ - alert button + alert action +alert button Delete messages after @@ -2361,6 +2636,10 @@ swipe action ลบคิว server test step + + Delete relay + No comment provided by engineer. + Delete report No comment provided by engineer. @@ -2507,6 +2786,14 @@ swipe action ข้อความโดยตรงระหว่างสมาชิกเป็นสิ่งต้องห้ามในกลุ่มนี้ No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) ปิดใช้งาน (เก็บการแทนที่) @@ -2603,6 +2890,10 @@ swipe action Do not send history to new members. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. No comment provided by engineer. @@ -2691,11 +2982,19 @@ chat item action E2E encrypted notifications. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit แก้ไข chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile แก้ไขโปรไฟล์กลุ่ม @@ -2708,7 +3007,7 @@ chat item action Enable เปิดใช้งาน - No comment provided by engineer. + alert button Enable (keep overrides) @@ -2729,6 +3028,10 @@ chat item action เปิดใช้งาน TCP Keep-alive No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? เปิดใช้งานการลบข้อความอัตโนมัติ? @@ -2738,6 +3041,10 @@ chat item action Enable camera access No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. No comment provided by engineer. @@ -2756,16 +3063,15 @@ chat item action เปิดใช้งานการแจ้งเตือนทันที? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock เปิดใช้งานการล็อค No comment provided by engineer. - - Enable notifications - เปิดใช้งานการแจ้งเตือน - No comment provided by engineer. - Enable periodic notifications? เปิดใช้การแจ้งเตือนเป็นระยะๆ ไหม? @@ -2863,6 +3169,10 @@ chat item action ใส่รหัสผ่าน No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. ใส่รหัสผ่านที่ถูกต้อง @@ -2886,6 +3196,14 @@ chat item action ใส่รหัสผ่านด้านบนเพื่อแสดง! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually ใส่เซิร์ฟเวอร์ด้วยตนเอง @@ -2912,7 +3230,7 @@ chat item action Error ผิดพลาด - No comment provided by engineer. + conn error description Error aborting address change @@ -2937,6 +3255,10 @@ chat item action เกิดข้อผิดพลาดในการเพิ่มสมาชิก No comment provided by engineer. + + Error adding relay + alert title + Error adding server alert title @@ -2980,11 +3302,19 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address เกิดข้อผิดพลาดในการสร้างที่อยู่ No comment provided by engineer. + + Error creating channel + alert title + Error creating group เกิดข้อผิดพลาดในการสร้างกลุ่ม @@ -3109,10 +3439,6 @@ chat item action Error opening chat No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file เกิดข้อผิดพลาดในการรับไฟล์ @@ -3152,6 +3478,10 @@ chat item action เกิดข้อผิดพลาดในการบันทึกเซิร์ฟเวอร์ ICE No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list alert title @@ -3211,6 +3541,10 @@ chat item action เกิดข้อผิดพลาดในการตั้งค่าใบตอบรับการจัดส่ง! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat เกิดข้อผิดพลาดในการเริ่มแชท @@ -3285,7 +3619,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3464,6 +3799,10 @@ snd error text ไฟล์และสื่อต้องห้าม! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. กรองแชทที่ยังไม่อ่านและแชทโปรด @@ -3498,7 +3837,8 @@ snd error text Fingerprint in server address does not match certificate. อาจเป็นไปได้ว่าลายนิ้วมือของ certificate ในที่อยู่เซิร์ฟเวอร์ไม่ถูกต้อง - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3538,9 +3878,14 @@ snd error text For all moderators No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: - servers error + servers error +servers warning For console @@ -3659,10 +4004,18 @@ Error: %2$@ GIFs และสติกเกอร์ No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! message preview @@ -3717,7 +4070,7 @@ Error: %2$@ Group link ลิงค์กลุ่ม - No comment provided by engineer. + chat link info line Group links @@ -3825,6 +4178,10 @@ Error: %2$@ History is not sent to new members. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works วิธีการ SimpleX ทํางานอย่างไร @@ -3885,6 +4242,10 @@ Error: %2$@ หากคุณใส่รหัสผ่านทำลายตัวเองขณะเปิดแอป: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). หากคุณจำเป็นต้องใช้แชทตอนนี้ ให้แตะ **ทำในภายหลัง** ด้านล่าง (ระบบจะเสนอให้คุณย้ายฐานข้อมูลเมื่อคุณรีสตาร์ทแอป) @@ -3905,16 +4266,15 @@ Error: %2$@ จะได้รับรูปภาพเมื่อผู้ติดต่อของคุณออนไลน์ โปรดรอหรือตรวจสอบในภายหลัง! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately โดยทันที No comment provided by engineer. - - Immune to spam - มีภูมิคุ้มกันต่อสแปมและการละเมิด - No comment provided by engineer. - Import นำเข้า @@ -4044,9 +4404,9 @@ More improvements are coming soon! บทบาทเริ่มต้น No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - ติดตั้ง [SimpleX Chat สำหรับเทอร์มินัล](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + ติดตั้ง SimpleX Chat สำหรับเทอร์มินัล No comment provided by engineer. @@ -4097,7 +4457,7 @@ More improvements are coming soon! Invalid connection link ลิงค์เชื่อมต่อไม่ถูกต้อง - No comment provided by engineer. + conn error description Invalid display name! @@ -4113,7 +4473,15 @@ More improvements are coming soon! Invalid name! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4138,11 +4506,19 @@ More improvements are coming soon! เชิญเพื่อนๆ No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members เชิญสมาชิก No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat No comment provided by engineer. @@ -4217,6 +4593,10 @@ More improvements are coming soon! เข้าร่วมเป็น %@ No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group เข้าร่วมกลุ่ม @@ -4296,6 +4676,14 @@ This is your link for group %@! ออกจาก swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat No comment provided by engineer. @@ -4318,6 +4706,10 @@ This is your link for group %@! Less traffic on mobile networks. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat มาคุยกันใน SimpleX Chat @@ -4337,6 +4729,10 @@ This is your link for group %@! Link mobile and desktop apps! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options No comment provided by engineer. @@ -4345,6 +4741,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4460,6 +4860,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4480,12 +4884,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! สมาชิกจะถูกลบออกจากกลุ่ม - ไม่สามารถยกเลิกได้! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4496,6 +4900,10 @@ This is your link for group %@! สมาชิกกลุ่มสามารถเพิ่มการแสดงปฏิกิริยาต่อข้อความได้ No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) สมาชิกกลุ่มสามารถลบข้อความที่ส่งแล้วอย่างถาวร @@ -4556,6 +4964,10 @@ This is your link for group %@! ร่างข้อความ No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded item status text @@ -4638,6 +5050,14 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. alert message @@ -4662,12 +5082,12 @@ This is your link for group %@! Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. No comment provided by engineer. - - Migrate device + + Migrate No comment provided by engineer. - - Migrate from another device + + Migrate device No comment provided by engineer. @@ -4780,6 +5200,10 @@ This is your link for group %@! เครือข่ายและเซิร์ฟเวอร์ No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection No comment provided by engineer. @@ -4788,6 +5212,10 @@ This is your link for group %@! Network decentralization No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. snd error text @@ -4800,6 +5228,11 @@ This is your link for group %@! Network operator No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings การตั้งค่าเครือข่าย @@ -4808,12 +5241,16 @@ This is your link for group %@! Network status สถานะเครือข่าย - No comment provided by engineer. + alert title New token status text + + New 1-time link + No comment provided by engineer. + New Passcode รหัสผ่านใหม่ @@ -4835,6 +5272,10 @@ This is your link for group %@! New chat experience 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request คำขอติดต่อใหม่ @@ -4899,11 +5340,28 @@ This is your link for group %@! เลขที่ No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password ไม่มีรหัสผ่านสำหรับแอป Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats No comment provided by engineer. @@ -5029,11 +5487,22 @@ This is your link for group %@! No unread chats No comment provided by engineer. - - No user identifiers. - แพลตฟอร์มแรกที่ไม่มีตัวระบุผู้ใช้ - ถูกออกแบบให้เป็นส่วนตัว + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! No comment provided by engineer. @@ -5083,7 +5552,7 @@ This is your link for group %@! OK - No comment provided by engineer. + alert button Off @@ -5102,11 +5571,19 @@ new chat action ฐานข้อมูลเก่า No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link ลิงก์คำเชิญแบบใช้ครั้งเดียว No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5124,6 +5601,10 @@ Requires compatible VPN. โฮสต์หัวหอมจะไม่ถูกใช้ No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. No comment provided by engineer. @@ -5220,7 +5701,8 @@ Requires compatible VPN. Open - alert action + alert action +alert button Open Settings @@ -5231,6 +5713,10 @@ Requires compatible VPN. Open changes No comment provided by engineer. + + Open channel + new chat action + Open chat เปิดแชท @@ -5249,6 +5735,10 @@ Requires compatible VPN. Open conditions No comment provided by engineer. + + Open external link? + alert title + Open full link alert action @@ -5265,6 +5755,10 @@ Requires compatible VPN. Open migration to another device authentication reason + + Open new channel + new chat action + Open new chat new chat action @@ -5301,6 +5795,13 @@ Requires compatible VPN. Operator server alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file No comment provided by engineer. @@ -5317,6 +5818,10 @@ Requires compatible VPN. Or securely share this file link No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code No comment provided by engineer. @@ -5325,6 +5830,10 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists No comment provided by engineer. @@ -5338,6 +5847,18 @@ Requires compatible VPN. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count จํานวน PING @@ -5391,6 +5912,10 @@ Requires compatible VPN. แปะภาพ No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! No comment provided by engineer. @@ -5529,6 +6054,14 @@ Error: %@ เก็บข้อความที่ร่างไว้ล่าสุดพร้อมไฟล์แนบ No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address ที่อยู่เซิร์ฟเวอร์ที่ตั้งไว้ล่วงหน้า @@ -5560,13 +6093,12 @@ Error: %@ Privacy policy and conditions of use. No comment provided by engineer. - - Privacy redefined - นิยามความเป็นส่วนตัวใหม่ + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. + + Private and secure messaging. No comment provided by engineer. @@ -5602,6 +6134,10 @@ Error: %@ Private routing timeout alert title + + Proceed + alert action + Profile and server connections การเชื่อมต่อโปรไฟล์และเซิร์ฟเวอร์ @@ -5625,9 +6161,8 @@ Error: %@ Profile theme No comment provided by engineer. - - Profile update will be sent to your contacts. - การอัปเดตโปรไฟล์จะถูกส่งไปยังผู้ติดต่อของคุณ + + Profile update will be sent to your SimpleX contacts. alert message @@ -5635,6 +6170,10 @@ Error: %@ ห้ามการโทรด้วยเสียง/วิดีโอ No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. ห้ามการลบข้อความที่ย้อนกลับไม่ได้ @@ -5663,6 +6202,10 @@ Error: %@ ห้ามส่งข้อความโดยตรงถึงสมาชิก No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. ห้ามส่งข้อความที่จะหายไปหลังจากเวลาที่กำหนดหลังการอ่าน (disappearing messages) @@ -5723,6 +6266,10 @@ Enable in *Network & servers* settings. Proxy requires password No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications การแจ้งเตือนแบบทันที @@ -5760,23 +6307,14 @@ Enable in *Network & servers* settings. อ่านเพิ่มเติม No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + อ่านเพิ่มเติมในคู่มือผู้ใช้ No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - อ่านเพิ่มเติมใน[คู่มือผู้ใช้](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses) - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - อ่านเพิ่มเติมใน[คู่มือผู้ใช้](https://simplex.chat/docs/guide/readme.html#connect-to-friends) - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - อ่านเพิ่มเติมใน[พื้นที่เก็บข้อมูล GitHub](https://github.com/simplex-chat/simplex-chat#readme) + + Read more in our GitHub repository. + อ่านเพิ่มเติมในพื้นที่เก็บข้อมูล GitHub No comment provided by engineer. @@ -5797,11 +6335,6 @@ Enable in *Network & servers* settings. ได้รับเมื่อ: %@ copied message info - - Received file event - ได้รับไฟล์ - notification - Received message ได้รับข้อความ @@ -5924,6 +6457,26 @@ swipe action Reject member? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. ใช้เซิร์ฟเวอร์รีเลย์ในกรณีที่จำเป็นเท่านั้น บุคคลอื่นสามารถสังเกตที่อยู่ IP ของคุณได้ @@ -5934,10 +6487,22 @@ swipe action เซิร์ฟเวอร์รีเลย์ปกป้องที่อยู่ IP ของคุณ แต่สามารถสังเกตระยะเวลาของการโทรได้ No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove ลบ - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -5959,13 +6524,21 @@ swipe action Remove member? ลบสมาชิกออก? - No comment provided by engineer. + alert title Remove passphrase from keychain? ลบรหัสผ่านออกจาก keychain หรือไม่? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. No comment provided by engineer. @@ -6174,6 +6747,10 @@ swipe action SOCKS proxy No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files No comment provided by engineer. @@ -6197,6 +6774,10 @@ chat item action Save (and notify members) alert button + + Save (and notify subscribers) + alert button + Save admission settings? alert title @@ -6211,6 +6792,10 @@ chat item action บันทึกและแจ้งให้สมาชิกในกลุ่มทราบ No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect No comment provided by engineer. @@ -6220,6 +6805,14 @@ chat item action บันทึกและอัปเดตโปรไฟล์กลุ่ม No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile บันทึกโปรไฟล์กลุ่ม @@ -6334,10 +6927,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -6361,6 +6974,10 @@ chat item action รหัสความปลอดภัย No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select เลือก @@ -6479,6 +7096,10 @@ chat item action Send request without message No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. ส่งจากแกลเลอรีหรือแป้นพิมพ์แบบกำหนดเอง @@ -6488,6 +7109,10 @@ chat item action Send up to 100 last messages to new members. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. No comment provided by engineer. @@ -6502,6 +7127,10 @@ chat item action ผู้ส่งอาจลบคําขอการเชื่อมต่อแล้ว No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. การส่งใบเสร็จรับการจัดส่งข้อความจะถูกเปิดในโปรไฟล์แชทที่มองเห็นได้ทั้งหมด @@ -6554,11 +7183,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - เหตุการณ์ไฟล์ที่ส่ง - notification - Sent message ข้อความที่ส่งแล้ว @@ -6617,6 +7241,10 @@ chat item action Server protocol changed. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. เซิร์ฟเวอร์ต้องการการอนุญาตในการสร้างคิว โปรดตรวจสอบรหัสผ่าน @@ -6734,6 +7362,14 @@ chat item action Settings were changed. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images No comment provided by engineer. @@ -6766,11 +7402,14 @@ chat item action Share address publicly No comment provided by engineer. - - Share address with contacts? - แชร์ที่อยู่กับผู้ติดต่อ? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. No comment provided by engineer. @@ -6792,6 +7431,10 @@ chat item action Share profile No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link No comment provided by engineer. @@ -6800,9 +7443,12 @@ chat item action Share to SimpleX No comment provided by engineer. - - Share with contacts - แชร์กับผู้ติดต่อ + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -6957,8 +7603,8 @@ chat item action SimpleX protocols reviewed by Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7023,6 +7669,11 @@ report reason Square, circle, or anything in between. No comment provided by engineer. + + Star on GitHub + ติดดาวบน GitHub + No comment provided by engineer. + Start chat เริ่มแชท @@ -7115,6 +7766,63 @@ report reason Subscribed No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors No comment provided by engineer. @@ -7187,6 +7895,10 @@ report reason ถ่ายภาพ No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat No comment provided by engineer. @@ -7199,8 +7911,8 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. + + Tap Join channel No comment provided by engineer. @@ -7231,6 +7943,10 @@ report reason แตะเพื่อเข้าร่วมโหมดไม่ระบุตัวตน No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link No comment provided by engineer. @@ -7246,12 +7962,17 @@ report reason Test failed at step %@. การทดสอบล้มเหลวในขั้นตอน %@ - server test failure + relay test failure +server test failure Test notifications No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server เซิร์ฟเวอร์ทดสอบ @@ -7303,6 +8024,10 @@ It can happen because of some bug or when the connection is compromised.The app protects your privacy by using different operators in each conversation. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). No comment provided by engineer. @@ -7316,6 +8041,10 @@ It can happen because of some bug or when the connection is compromised.The code you scanned is not a SimpleX link QR code. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. No comment provided by engineer. @@ -7340,9 +8069,9 @@ It can happen because of some bug or when the connection is compromised.encryption กำลังทำงานและไม่จำเป็นต้องใช้ข้อตกลง encryption ใหม่ อาจทำให้การเชื่อมต่อผิดพลาดได้! No comment provided by engineer. - - The future of messaging - การส่งข้อความส่วนตัวรุ่นต่อไป + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -7377,6 +8106,10 @@ It can happen because of some bug or when the connection is compromised.ฐานข้อมูลเก่าไม่ได้ถูกลบในระหว่างการย้ายข้อมูล แต่สามารถลบได้ No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7416,6 +8149,14 @@ It can happen because of some bug or when the connection is compromised.Themes No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. No comment provided by engineer. @@ -7473,6 +8214,14 @@ It can happen because of some bug or when the connection is compromised.ไม่มีกลุ่มนี้แล้ว No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. No comment provided by engineer. @@ -7516,6 +8265,10 @@ It can happen because of some bug or when the connection is compromised.To hide unwanted messages. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection เพื่อสร้างการเชื่อมต่อใหม่ @@ -7594,10 +8347,6 @@ You will be prompted to complete authentication before this feature is enabled.< ในการตรวจสอบการเข้ารหัสแบบ encrypt จากต้นจนจบ กับผู้ติดต่อของคุณ ให้เปรียบเทียบ (หรือสแกน) รหัสบนอุปกรณ์ของคุณ No comment provided by engineer. - - Toggle chat list: - No comment provided by engineer. - Toggle incognito when connecting. No comment provided by engineer. @@ -7610,6 +8359,10 @@ You will be prompted to complete authentication before this feature is enabled.< Toolbar opacity No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total No comment provided by engineer. @@ -7623,15 +8376,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %@) - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - พยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -7672,6 +8419,10 @@ You will be prompted to complete authentication before this feature is enabled.< Unblock member? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages No comment provided by engineer. @@ -7767,12 +8518,16 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update อัปเดต @@ -7881,11 +8636,6 @@ To connect, please ask your contact to create another connection link and check Use TCP port 443 for preset servers only. No comment provided by engineer. - - Use chat - ใช้แชท - No comment provided by engineer. - Use current profile new chat action @@ -7898,6 +8648,10 @@ To connect, please ask your contact to create another connection link and check Use for messages No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections ใช้สำหรับการเชื่อมต่อใหม่ @@ -7932,6 +8686,10 @@ To connect, please ask your contact to create another connection link and check Use private routing with unknown servers. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server ใช้เซิร์ฟเวอร์ @@ -7949,6 +8707,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port No comment provided by engineer. @@ -7966,6 +8728,10 @@ To connect, please ask your contact to create another connection link and check กำลังใช้เซิร์ฟเวอร์ SimpleX Chat อยู่ No comment provided by engineer. + + Verify + relay test step + Verify code with desktop No comment provided by engineer. @@ -8020,6 +8786,10 @@ To connect, please ask your contact to create another connection link and check จะได้รับวิดีโอเมื่อผู้ติดต่อของคุณออนไลน์ โปรดรอหรือตรวจสอบในภายหลัง! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb วิดีโอและไฟล์สูงสุด 1gb @@ -8071,6 +8841,18 @@ To connect, please ask your contact to create another connection link and check ข้อความเสียง… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... No comment provided by engineer. @@ -8107,6 +8889,10 @@ To connect, please ask your contact to create another connection link and check คำเตือน: คุณอาจสูญเสียข้อมูลบางส่วน! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers เซิร์ฟเวอร์ WebRTC ICE @@ -8153,6 +8939,10 @@ To connect, please ask your contact to create another connection link and check เมื่อคุณแชร์โปรไฟล์ที่ไม่ระบุตัวตนกับใครสักคน โปรไฟล์นี้จะใช้สำหรับกลุ่มที่พวกเขาเชิญคุณ No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi No comment provided by engineer. @@ -8261,16 +9051,19 @@ To connect, please ask your contact to create another connection link and check Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group คุณได้รับเชิญให้เข้าร่วมกลุ่ม No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -8334,6 +9127,10 @@ Repeat join request? คุณสามารถตั้งค่าแสดงตัวอย่างการแจ้งเตือนบนหน้าจอล็อคผ่านการตั้งค่า No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. คุณสามารถแชร์ลิงก์หรือคิวอาร์โค้ดได้ ทุกคนจะสามารถเข้าร่วมกลุ่มได้ คุณจะไม่สูญเสียสมาชิกของกลุ่มหากคุณลบในภายหลัง @@ -8376,16 +9173,21 @@ Repeat join request? คุณไม่สามารถส่งข้อความได้! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. เราไม่สามารถตรวจสอบคุณได้ กรุณาลองอีกครั้ง. No comment provided by engineer. - - You decide who can connect. - ผู้คนสามารถเชื่อมต่อกับคุณผ่านลิงก์ที่คุณแบ่งปันเท่านั้น - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -8446,6 +9248,10 @@ Repeat connection request? You should receive notifications. token info + + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. No comment provided by engineer. @@ -8479,6 +9285,10 @@ Repeat connection request? คุณจะยังได้รับสายเรียกเข้าและการแจ้งเตือนจากโปรไฟล์ที่ปิดเสียงเมื่อโปรไฟล์ของเขามีการใช้งาน No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. No comment provided by engineer. @@ -8522,6 +9332,10 @@ Repeat connection request? การโทรของคุณ No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database ฐานข้อมูลการแชทของคุณ @@ -8568,6 +9382,10 @@ Repeat connection request? ผู้ติดต่อของคุณจะยังคงเชื่อมต่ออยู่ No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. No comment provided by engineer. @@ -8586,6 +9404,10 @@ Repeat connection request? Your group No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences การตั้งค่าของคุณ @@ -8600,6 +9422,11 @@ Repeat connection request? Your profile No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. No comment provided by engineer. @@ -8618,11 +9445,23 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message + + Your public address + No comment provided by engineer. + Your random profile โปรไฟล์แบบสุ่มของคุณ No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address ที่อยู่เซิร์ฟเวอร์ของคุณ @@ -8637,21 +9476,11 @@ Repeat connection request? การตั้งค่าของคุณ No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [มีส่วนร่วม](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [ส่งอีเมลถึงเรา](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [ติดดาวบน GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_ตัวเอียง_ @@ -8667,6 +9496,10 @@ Repeat connection request? ด้านบน จากนั้นเลือก: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ rcv group event chat item @@ -8684,6 +9517,10 @@ Repeat connection request? accepted you rcv group event chat item + + active + No comment provided by engineer. + admin ผู้ดูแลระบบ @@ -8784,6 +9621,10 @@ marked deleted chat item preview text กำลังโทร… call status + + can't broadcast + No comment provided by engineer. + can't send messages No comment provided by engineer. @@ -8818,6 +9659,14 @@ marked deleted chat item preview text กำลังเปลี่ยนที่อยู่… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored มีสี @@ -8958,6 +9807,10 @@ pref value ลบแล้ว deleted chat item + + deleted channel + rcv group event chat item + deleted contact rcv direct event chat item @@ -9065,10 +9918,18 @@ pref value ผิดพลาด No comment provided by engineer. + + error: %@ + receive error chat item + expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. @@ -9184,6 +10045,10 @@ pref value ออกแล้ว rcv group event chat item + + link + No comment provided by engineer. + marked deleted ทำเครื่องหมายว่าลบแล้ว @@ -9250,6 +10115,10 @@ pref value ไม่เคย delete after time + + new + No comment provided by engineer. + new message ข้อความใหม่ @@ -9265,6 +10134,10 @@ pref value ไม่มีการ encrypt จากต้นจนจบ No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text ไม่มีข้อความ @@ -9359,6 +10232,10 @@ time to disappear สายถูกปฏิเสธ call status + + relay + member role + removed ถูกลบแล้ว @@ -9369,6 +10246,14 @@ time to disappear ถูกลบแล้ว %@ rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address profile update event chat item @@ -9500,6 +10385,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile อัปเดตโปรไฟล์กลุ่มแล้ว @@ -9518,6 +10407,10 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link ผ่านลิงค์ที่อยู่ติดต่อ @@ -9589,6 +10482,10 @@ last received msg: %2$@ คุณเป็นผู้สังเกตการณ์ No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ snd group event chat item @@ -9647,6 +10544,10 @@ last received msg: %2$@ \~ตี~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 8fb8e4ac51..90d537d06c 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -185,6 +185,21 @@ %d ay time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d saniye @@ -200,11 +215,53 @@ %d okunmamış mesaj(lar) integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d hafta time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +272,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld kişi seçildi @@ -315,11 +376,19 @@ %u mesajlar atlandı. No comment provided by engineer. + + (from owner) + chat link info line + (new) (yeni) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (bu cihaz v%@) @@ -365,6 +434,10 @@ edindiğiniz bağlantı aracılığıyla bağlanmak için **Linki tarayın/yapıştırın**. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Dikkat**: Anında iletilen bildirimlere Anahtar Zinciri'nde kaydedilmiş parola gereklidir. @@ -408,6 +481,12 @@ - ve fazlası! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +585,10 @@ time interval Birkaç şey daha No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact Yeni kişi @@ -632,9 +715,8 @@ swipe action Aktif bağlantılar No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Kişilerinizin başkalarıyla paylaşabilmesi için profilinize adres ekleyin. Profil güncellemesi kişilerinize gönderilecek. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -702,6 +784,10 @@ swipe action Mesaj sunucuları eklendi No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent Ek ana renk @@ -792,6 +878,10 @@ swipe action Tüm grup üyeleri bağlı kalacaktır. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte. @@ -817,6 +907,14 @@ swipe action Tüm Profiller profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. Tüm raporlar sizin için arşivlenecek. @@ -877,6 +975,10 @@ swipe action Konuştuğun kişi, kalıcı olarak silinebilen mesajlara izin veriyorsa sen de ver. (24 saat içinde) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Yalnızca kişin mesaj tepkilerine izin veriyorsa sen de ver. @@ -892,6 +994,10 @@ swipe action Üyelere doğrudan mesaj göndermeye izin ver. No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Kendiliğinden yok olan mesajlar göndermeye izin ver. @@ -902,6 +1008,10 @@ swipe action Paylaşıma izin ver No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Gönderilen mesajların kalıcı olarak silinmesine izin ver. (24 saat içinde) @@ -1007,11 +1117,6 @@ swipe action Aramayı cevapla No comment provided by engineer. - - Anybody can host servers. - Açık kaynak protokolü ve kodu - herhangi biri sunucuları çalıştırabilir. - No comment provided by engineer. - App build: %@ Uygulama sürümü: %@ @@ -1142,6 +1247,10 @@ swipe action Sesli ve görüntülü aramalar No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Sesli/görüntülü aramalar @@ -1212,6 +1321,19 @@ swipe action Kötü mesaj karması No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls Daha iyi aramalar @@ -1307,6 +1429,10 @@ swipe action Üyeyi engelle? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin Yönetici tarafından engellendi @@ -1357,6 +1483,14 @@ swipe action Sen ve konuştuğun kişi sesli mesaj gönderebilir. No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Bulgarca, Fince, Tayca ve Ukraynaca - kullanıcılara ve [Weblate] e teşekkürler! (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1365,7 +1499,7 @@ swipe action Business address İş adresi - No comment provided by engineer. + chat link info line Business chats @@ -1387,15 +1521,6 @@ swipe action Sohbet profiline göre (varsayılan) veya [bağlantıya göre](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - SimpleX Chat'i kullanarak şunları kabul etmiş olursunuz: -- herkese açık gruplarda yalnızca yasal içerik göndermek. -- diğer kullanıcılara saygı göstermek – spam yapmamak. - No comment provided by engineer. - Call already ended! Arama çoktan bitti! @@ -1544,6 +1669,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat Sohbet @@ -1629,6 +1815,22 @@ set passcode view Kullanıcı profili No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme Sohbet teması @@ -1647,7 +1849,8 @@ set passcode view Chat with admins Yöneticilerle sohbet et - chat toolbar + chat feature +chat toolbar Chat with member @@ -1664,11 +1867,23 @@ set passcode view Sohbetler No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members Üyelerle sohbetler No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. Her 20 dakikada mesajları kontrol et. @@ -1679,6 +1894,14 @@ set passcode view İzin verildiğinde mesajları kontrol et. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. Sunucu adresini kontrol edip tekrar deneyin. @@ -1802,7 +2025,7 @@ set passcode view Conditions of use Kullanım koşulları - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1824,9 +2047,8 @@ set passcode view ICE sunucularını ayarla No comment provided by engineer. - - Configure server operators - Sunucu operatörlerini yapılandır + + Configure relays No comment provided by engineer. @@ -1887,7 +2109,8 @@ set passcode view Connect Bağlan - server test step + relay test step +server test step Connect automatically @@ -1933,6 +2156,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı aracılığıyla bağlan new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Tek kullanımlık bağlantı aracılığıyla bağlan @@ -2011,6 +2238,10 @@ Bu senin kendi tek kullanımlık bağlantın! Connection error (AUTH) Bağlantı hatası (DOĞRULAMA) + conn error description + + + Connection failed No comment provided by engineer. @@ -2065,6 +2296,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantılar No comment provided by engineer. + + Contact address + chat link info line + Contact allows Kişi izin veriyor @@ -2135,6 +2370,11 @@ Bu senin kendi tek kullanımlık bağlantın! Devam et No comment provided by engineer. + + Contribute + Katkıda bulun + No comment provided by engineer. + Conversation deleted! Sohbet silindi! @@ -2163,12 +2403,7 @@ Bu senin kendi tek kullanımlık bağlantın! Correct name to %@? İsim %@ olarak düzeltilsin mi? - No comment provided by engineer. - - - Create - Oluştur - No comment provided by engineer. + alert message Create 1-time link @@ -2220,6 +2455,14 @@ Bu senin kendi tek kullanımlık bağlantın! Profil oluştur No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue Sıra oluştur @@ -2230,11 +2473,19 @@ Bu senin kendi tek kullanımlık bağlantın! Adresinizi oluşturun No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile Profilini oluştur No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created Yaratıldı @@ -2255,6 +2506,10 @@ Bu senin kendi tek kullanımlık bağlantın! Arşiv bağlantısı oluşturuluyor No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… Link oluşturuluyor… @@ -2413,10 +2668,9 @@ Bu senin kendi tek kullanımlık bağlantın! Hata ayıklama teslimatı No comment provided by engineer. - - Decentralized - Merkezi Olmayan - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2464,6 +2718,14 @@ swipe action Sil ve kişiye bildir No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat Sohbeti sil @@ -2579,6 +2841,14 @@ swipe action Kişinin mesajı silinsin mi? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Mesaj silinsin mi? @@ -2587,7 +2857,8 @@ swipe action Delete messages Mesajları sil - alert button + alert action +alert button Delete messages after @@ -2624,6 +2895,10 @@ swipe action Sırayı sil server test step + + Delete relay + No comment provided by engineer. + Delete report Raporu sil @@ -2789,6 +3064,14 @@ swipe action Bu grupta üyeler arasında direkt mesajlaşma yasaktır. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) Devre dışı bırak (geçersiz kılmaları koru) @@ -2894,6 +3177,10 @@ swipe action Yeni üyelere geçmişi gönderme. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. Kimlik bilgilerini proxy ile kullanmayın. @@ -2995,11 +3282,19 @@ chat item action Uçtan uca şifrelenmiş bildirimler. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit Düzenle chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile Grup profilini düzenle @@ -3013,7 +3308,7 @@ chat item action Enable Etkinleştir - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3035,6 +3330,10 @@ chat item action TCP canlı tutmayı etkinleştir No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Otomatik mesaj silme etkinleştirilsin mi? @@ -3045,6 +3344,10 @@ chat item action Kamera erişimini etkinleştir No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. Varsayılan olarak kaybolan mesajları etkinleştirin. @@ -3065,16 +3368,15 @@ chat item action Anlık bildirimler etkinleştirilsin mi? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock Kilidi etkinleştir No comment provided by engineer. - - Enable notifications - Bildirimleri etkinleştir - No comment provided by engineer. - Enable periodic notifications? Periyodik bildirimler etkinleştirilsin mi? @@ -3180,6 +3482,10 @@ chat item action Şifre gir No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Doğru şifreyi gir. @@ -3205,6 +3511,14 @@ chat item action Göstermek için yukarıdaki şifreyi gir! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually Sunucuya manuel olarak gir @@ -3233,7 +3547,7 @@ chat item action Error Hata - No comment provided by engineer. + conn error description Error aborting address change @@ -3260,6 +3574,10 @@ chat item action Üye(ler) eklenirken hata oluştu No comment provided by engineer. + + Error adding relay + alert title + Error adding server Sunucu eklenirken hata oluştu @@ -3310,11 +3628,19 @@ chat item action Yönlendirme sunucusu %@'ya bağlanırken hata oluştu. Lütfen daha sonra deneyin. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Adres oluşturulurken hata oluştu No comment provided by engineer. + + Error creating channel + alert title + Error creating group Grup oluşturulurken hata oluştu @@ -3450,11 +3776,6 @@ chat item action Kişiyi hazırlama hatası No comment provided by engineer. - - Error opening group - Grubu hazırlama hatası - No comment provided by engineer. - Error receiving file Dosya alınırken sorun oluştu @@ -3500,6 +3821,10 @@ chat item action ICE sunucularını kaydedirken sorun oluştu No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list Sohbet listesini kaydetme hatası @@ -3565,6 +3890,10 @@ chat item action Görüldü ayarlanırken hata oluştu! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat Sohbet başlatılırken hata oluştu @@ -3644,7 +3973,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3845,6 +4175,10 @@ snd error text Dosyalar ve medya yasaklandı! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Favori ve okunmamış sohbetleri filtrele. @@ -3881,7 +4215,8 @@ snd error text Fingerprint in server address does not match certificate. Muhtemelen, sunucu adresindeki parmakizi sertifikası doğru değil - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3922,10 +4257,15 @@ snd error text Tüm moderatörler için No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: Sohbet profili için %@: - servers error + servers error +servers warning For console @@ -4066,11 +4406,19 @@ Hata: %2$@ GİFler ve çıkartmalar No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. Bahsedildiğinde bildirim alın. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! İyi öğlenler! @@ -4129,7 +4477,7 @@ Hata: %2$@ Group link Grup bağlantısı - No comment provided by engineer. + chat link info line Group links @@ -4241,6 +4589,10 @@ Hata: %2$@ Yeni üyelere geçmiş gönderilmedi. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works SimpleX nasıl çalışır @@ -4306,6 +4658,10 @@ Hata: %2$@ Uygulamayı açarken kendi kendini imha eden şifrenizi girerseniz: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Sohbeti şimdi kullanmanız gerekiyorsa aşağıdaki **Daha sonra yap** seçeneğine dokunun (uygulamayı yeniden başlattığınızda veritabanını taşımanız önerilecektir). @@ -4326,16 +4682,15 @@ Hata: %2$@ Kişi çevrimiçi olduğunda fotoğraf alınacaktır, lütfen bekleyin veya daha sonra kontrol et! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Hemen No comment provided by engineer. - - Immune to spam - Spam ve kötüye kullanıma karşı bağışıklı - No comment provided by engineer. - Import İçe aktar @@ -4478,9 +4833,9 @@ Daha fazla iyileştirme yakında geliyor! Başlangıç rolü No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - [Terminal için SimpleX Chat]i indir(https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Terminal için SimpleX Chat'i indir No comment provided by engineer. @@ -4538,7 +4893,7 @@ Daha fazla iyileştirme yakında geliyor! Invalid connection link Geçersiz bağlanma bağlantısı - No comment provided by engineer. + conn error description Invalid display name! @@ -4558,7 +4913,15 @@ Daha fazla iyileştirme yakında geliyor! Invalid name! Geçersiz isim! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4585,11 +4948,19 @@ Daha fazla iyileştirme yakında geliyor! Arkadaşları davet et No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Üyeleri davet et No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat Sohbete davet et @@ -4666,6 +5037,10 @@ Daha fazla iyileştirme yakında geliyor! %@ olarak katıl No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group Gruba katıl @@ -4753,6 +5128,14 @@ Bu senin grup için bağlantın %@! Ayrıl swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat Sohbetten ayrıl @@ -4778,6 +5161,10 @@ Bu senin grup için bağlantın %@! Mobil ağlarda daha az trafik. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat Hadi SimpleX Chat'te konuşalım @@ -4798,6 +5185,10 @@ Bu senin grup için bağlantın %@! Telefon ve bilgisayar uygulamalarını bağla! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options Bağlanmış bilgisayar ayarları @@ -4808,6 +5199,10 @@ Bu senin grup için bağlantın %@! Bağlanmış bilgisayarlar No comment provided by engineer. + + Links + No comment provided by engineer. + List Liste @@ -4933,6 +5328,10 @@ Bu senin grup için bağlantın %@! Üye silinmiş - istek kabul edilemez No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Üye raporları @@ -4956,12 +5355,12 @@ Bu senin grup için bağlantın %@! Member will be removed from chat - this cannot be undone! Üye sohbetten kaldırılacak - bu geri alınamaz! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Üye gruptan çıkarılacaktır - bu geri alınamaz! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4973,6 +5372,10 @@ Bu senin grup için bağlantın %@! Grup üyeleri mesaj tepkileri ekleyebilir. No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Grup üyeleri, gönderilen mesajları kalıcı olarak silebilir. (24 saat içinde) @@ -5038,6 +5441,10 @@ Bu senin grup için bağlantın %@! Mesaj taslağı No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded Mesaj iletildi @@ -5133,6 +5540,14 @@ Bu senin grup için bağlantın %@! %@ den gelen mesajlar gösterilecektir! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. Bu sohbetteki mesajlar asla silinmeyecek. @@ -5163,16 +5578,15 @@ Bu senin grup için bağlantın %@! Mesajlar, dosyalar ve aramalar **kuantum dirençli e2e şifreleme** ile mükemmel ileri gizlilik, inkar ve zorla girme kurtarma ile korunur. No comment provided by engineer. + + Migrate + No comment provided by engineer. + Migrate device Cihazı taşıma No comment provided by engineer. - - Migrate from another device - Başka bir cihazdan geçiş yapın - No comment provided by engineer. - Migrate here Buraya göç edin @@ -5293,6 +5707,10 @@ Bu senin grup için bağlantın %@! Ağ & sunucular No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection Ağ bağlantısı @@ -5303,6 +5721,10 @@ Bu senin grup için bağlantın %@! Ağ merkeziyetsizliği No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. Ağ sorunları - birçok gönderme denemesinden sonra mesajın süresi doldu. @@ -5318,6 +5740,11 @@ Bu senin grup için bağlantın %@! Ağ operatörü No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings Ağ ayarları @@ -5326,13 +5753,17 @@ Bu senin grup için bağlantın %@! Network status Ağ durumu - No comment provided by engineer. + alert title New Yeni token status text + + New 1-time link + No comment provided by engineer. + New Passcode Yeni şifre @@ -5358,6 +5789,10 @@ Bu senin grup için bağlantın %@! Yeni bir sohbet deneyimi 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request Yeni bağlantı isteği @@ -5428,11 +5863,28 @@ Bu senin grup için bağlantın %@! Hayır No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password Uygulama şifresi yok Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats Hiç sohbet yok @@ -5578,11 +6030,22 @@ Bu senin grup için bağlantın %@! Okunmamış sohbet yok No comment provided by engineer. - - No user identifiers. - Herhangi bir kullanıcı tanımlayıcısı yok. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! Uyumlu değil! @@ -5640,7 +6103,7 @@ Bu senin grup için bağlantın %@! OK TAMAM - No comment provided by engineer. + alert button Off @@ -5659,11 +6122,19 @@ new chat action Eski veritabanı No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link Tek zamanlı bağlantı daveti No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5683,6 +6154,10 @@ VPN'nin etkinleştirilmesi gerekir. Onion ana bilgisayarları kullanılmayacaktır. No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. Yalnızca sohbet sahipleri tercihleri değiştirebilir. @@ -5786,7 +6261,8 @@ VPN'nin etkinleştirilmesi gerekir. Open - alert action + alert action +alert button Open Settings @@ -5798,6 +6274,10 @@ VPN'nin etkinleştirilmesi gerekir. Açık değişiklikler No comment provided by engineer. + + Open channel + new chat action + Open chat Sohbeti aç @@ -5818,6 +6298,10 @@ VPN'nin etkinleştirilmesi gerekir. Açık koşullar No comment provided by engineer. + + Open external link? + alert title + Open full link Tam linki aç @@ -5838,6 +6322,10 @@ VPN'nin etkinleştirilmesi gerekir. Başka bir cihaza açık geçiş authentication reason + + Open new channel + new chat action + Open new chat Yeni sohbet aç @@ -5883,6 +6371,13 @@ VPN'nin etkinleştirilmesi gerekir. Operatör sunucusu alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file Veya arşiv dosyasını içe aktar @@ -5903,6 +6398,10 @@ VPN'nin etkinleştirilmesi gerekir. Veya bu dosya bağlantısını güvenli bir şekilde paylaşın No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code Veya bu kodu göster @@ -5913,6 +6412,10 @@ VPN'nin etkinleştirilmesi gerekir. Veya özel olarak paylaşmak için No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists Sohbetleri listelere ayır @@ -5930,6 +6433,18 @@ VPN'nin etkinleştirilmesi gerekir. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count PING sayısı @@ -5985,6 +6500,10 @@ VPN'nin etkinleştirilmesi gerekir. Fotoğraf yapıştır No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! Bağlanmak için bağlantıyı yapıştır! @@ -6139,6 +6658,14 @@ Hata: %@ Son mesaj taslağını ekleriyle birlikte koru. No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address Ön ayarlı sunucu adresi @@ -6174,14 +6701,12 @@ Hata: %@ Gizlilik politikası ve kullanım koşulları. No comment provided by engineer. - - Privacy redefined - Gizlilik yeniden tanımlandı + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - Özel sohbetler, gruplar ve kişilerinize sunucu operatörleri tarafından erişilemez. + + Private and secure messaging. No comment provided by engineer. @@ -6224,6 +6749,10 @@ Hata: %@ Özel yönlendirme zaman aşımı alert title + + Proceed + alert action + Profile and server connections Profil ve sunucu bağlantıları @@ -6249,9 +6778,8 @@ Hata: %@ Profil teması No comment provided by engineer. - - Profile update will be sent to your contacts. - Profil güncellemesi kişilerinize gönderilecektir. + + Profile update will be sent to your SimpleX contacts. alert message @@ -6259,6 +6787,10 @@ Hata: %@ Sesli/görüntülü aramaları yasakla. No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Geri dönüşsüz mesaj silme işlemini yasakla. @@ -6289,6 +6821,10 @@ Hata: %@ Üyelere doğrudan mesaj göndermeyi yasakla. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Kaybolan mesajların gönderimini yasakla. @@ -6356,6 +6892,10 @@ Enable in *Network & servers* settings. Proxy şifre gerektirir No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Anında bildirimler @@ -6396,24 +6936,14 @@ Enable in *Network & servers* settings. Dahasını oku No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - [Kullanıcı Rehberi]nde daha fazlasını okuyun(https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Kullanıcı Rehberinde daha fazlasını okuyun. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - [Kullanıcı Rehberi]nde daha fazlasını okuyun(https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - [Kullanıcı Rehberi]nde daha fazlasını okuyun(https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - [GitHub deposu]nda daha fazlasını okuyun(https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + GitHub deposunda daha fazlasını okuyun. No comment provided by engineer. @@ -6436,11 +6966,6 @@ Enable in *Network & servers* settings. Şuradan alındı: %@ copied message info - - Received file event - Dosya etkinliği alındı - notification - Received message Mesaj alındı @@ -6578,6 +7103,26 @@ swipe action Üyeyi reddet? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Yönlendirici sunucusu yalnızca gerekli olduğunda kullanılır. Başka bir taraf IP adresinizi gözlemleyebilir. @@ -6588,10 +7133,22 @@ swipe action Yönlendirici sunucu IP adresinizi korur, ancak aramanın süresini gözlemleyebilir. No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove Sil - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6616,13 +7173,21 @@ swipe action Remove member? Kişi silinsin mi? - No comment provided by engineer. + alert title Remove passphrase from keychain? Anahtar Zinciri'ndeki parola silinsin mi? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. Mesajları kaldırır ve üyeleri engeller. @@ -6858,6 +7423,10 @@ swipe action SOCKS vekili No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files Dosyaları güvenle alın @@ -6884,6 +7453,10 @@ chat item action Kaydet (ve üyelere bildir) alert button + + Save (and notify subscribers) + alert button + Save admission settings? Kabul ayarlarını kaydet? @@ -6899,6 +7472,10 @@ chat item action Kaydet ve grup üyelerine bildir No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect Kayıt et ve yeniden bağlan @@ -6909,6 +7486,14 @@ chat item action Kaydet ve grup profilini güncelle No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile Grup profilini kaydet @@ -7034,11 +7619,31 @@ chat item action Arama çubuğu davet bağlantılarını kabul eder. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Ara veya SimpleX bağlantısını yapıştır No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary İkincil renk @@ -7064,6 +7669,10 @@ chat item action Güvenlik kodu No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select Seç @@ -7194,6 +7803,10 @@ chat item action Mesaj olmadan istek gönder No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Bunları galeriden veya özel klavyelerden gönder. @@ -7204,6 +7817,10 @@ chat item action Yeni üyelere 100 adete kadar son mesajları gönderin. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. Özel geri bildiriminizi gruplara gönderin. @@ -7219,6 +7836,10 @@ chat item action Gönderici bağlantı isteğini silmiş olabilir. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Görüldü bilgisi, tüm görünür sohbet profillerindeki tüm kişiler için etkinleştirilecektir. @@ -7274,11 +7895,6 @@ chat item action Direkt gönderildi No comment provided by engineer. - - Sent file event - Dosya etkinliği gönderildi - notification - Sent message Mesaj gönderildi @@ -7349,6 +7965,10 @@ chat item action Sunucu protokolü değişti. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. Sunucunun sıra oluşturması için yetki gereklidir, şifreyi kontrol edin @@ -7479,6 +8099,14 @@ chat item action Ayarlar değiştirildi. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images Profil resimlerini şekillendir @@ -7515,11 +8143,14 @@ chat item action Adresinizi herkese açık olarak paylaşın No comment provided by engineer. - - Share address with contacts? - Kişilerle adres paylaşılsın mı? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. Diğer uygulamalardan paylaşın. @@ -7545,6 +8176,10 @@ chat item action Profil paylaş No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link Bu tek kullanımlık bağlantı davetini paylaş @@ -7555,9 +8190,12 @@ chat item action SimpleX ile paylaş No comment provided by engineer. - - Share with contacts - Kişilerle paylaş + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -7730,9 +8368,8 @@ chat item action SimpleX protokolleri Trail of Bits tarafından incelenmiştir. No comment provided by engineer. - - SimpleX relay link - SimpleX aktarıcı bağlantısı + + SimpleX relay address simplex link type @@ -7808,6 +8445,11 @@ report reason Kare,daire, veya aralarında herhangi bir şey. No comment provided by engineer. + + Star on GitHub + Bize GitHub'da yıldız verin + No comment provided by engineer. + Start chat Sohbeti başlat @@ -7908,6 +8550,63 @@ report reason Abone olundu No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors Abone olurken hata @@ -7988,6 +8687,10 @@ report reason Fotoğraf çek No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat Sohbet etmek için Bağlan'a dokunun @@ -8003,9 +8706,8 @@ report reason Botu kullanmak için Bağlan tuşuna bas No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Daha sonra oluşturmak için menüden BasitX adresi oluştur'a dokunun. + + Tap Join channel No comment provided by engineer. @@ -8038,6 +8740,10 @@ report reason Gizli katılmak için tıkla No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link Bağlantıyı yapıştırmak için tıkla @@ -8056,13 +8762,18 @@ report reason Test failed at step %@. Test %@ adımında başarısız oldu. - server test failure + relay test failure +server test failure Test notifications Bildirimleri test et No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server Sunucuyu test et @@ -8115,6 +8826,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Uygulama, her sohbette farklı operatörler kullanarak gizliliğinizi korur. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). Uygulama bilinmeyen dosya sunucularından indirmeleri onaylamanızı isteyecektir (.onion hariç). @@ -8130,6 +8845,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Taradığınız kod bir SimpleX bağlantı QR kodu değildir. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. Bağlantı, teslim edilmemiş mesajlar limitine ulaştı, kişiniz çevrimdışı olabilir. @@ -8155,9 +8874,9 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Şifreleme çalışıyor ve yeni şifreleme anlaşması gerekli değil. Bağlantı hatalarına neden olabilir! No comment provided by engineer. - - The future of messaging - Gizli mesajlaşmanın yeni nesli + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -8195,6 +8914,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Eski veritabanı geçiş sırasında kaldırılmadı, silinebilir. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Aynı koşullar operatör **%@** için de geçerli olacaktır. @@ -8240,6 +8963,14 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Temalar No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. Bu koşullar ayrıca şunlar için de geçerli olacaktır: **%@**. @@ -8305,6 +9036,14 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Bu grup artık mevcut değildir. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Bu bağlantı daha yeni bir uygulama sürümü gerektiriyor. Lütfen uygulamayı güncelleyin veya kişinizden uyumlu bir bağlantı göndermesini isteyin. @@ -8355,6 +9094,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. İstenmeyen mesajları gizlemek için. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection Yeni bir bağlantı oluşturmak için @@ -8442,11 +9185,6 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Kişinizle uçtan uca şifrelemeyi doğrulamak için cihazlarınızdaki kodu karşılaştırın (veya tarayın). No comment provided by engineer. - - Toggle chat list: - Sohbet listesini değiştir: - No comment provided by engineer. - Toggle incognito when connecting. Bağlanırken gizli moda geçiş yap. @@ -8462,6 +9200,10 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Araç çubuğu opaklığı No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total Toplam @@ -8477,15 +9219,9 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Taşıma oturumları No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8532,6 +9268,10 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Üyenin engeli kaldırılsın mı? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages Teslim edilmemiş mesajlar @@ -8632,13 +9372,17 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Unsupported connection link Desteklenmeyen bağlantı bağlantısı - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Yeni üyelere 100e kadar en son mesajlar gönderildi. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Güncelle @@ -8764,11 +9508,6 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Sadece ön ayar sunucuları için TCP port 443 kullanın. No comment provided by engineer. - - Use chat - Sohbeti kullan - No comment provided by engineer. - Use current profile Şu anki profili kullan @@ -8784,6 +9523,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Mesajlar için kullan No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections Yeni bağlantılar için kullan @@ -8824,6 +9567,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Bilinmeyen sunucularla gizli yönlendirme kullan. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server Sunucu kullan @@ -8844,6 +9591,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Uygulamayı tek elle kullan. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port Web portunu kullan @@ -8864,6 +9615,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste SimpleX Chat sunucuları kullanılıyor. No comment provided by engineer. + + Verify + relay test step + Verify code with desktop Bilgisayarla kodu doğrula @@ -8924,6 +9679,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Kişiniz çevrimiçi olduğunda video alınacaktır, lütfen bekleyin veya daha sonra kontrol edin! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb 1gb'a kadar videolar ve dosyalar @@ -8979,6 +9738,18 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Sesli mesaj… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... Bilgisayar için bekleniyor... @@ -9019,6 +9790,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Uyarı: Bazı verileri kaybedebilirsin! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICE sunucuları @@ -9069,6 +9844,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Biriyle gizli bir profil paylaştığınızda, bu profil sizi davet ettikleri gruplar için kullanılacaktır. No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi WiFi @@ -9196,16 +9975,19 @@ Repeat join request? Katılma isteği tekrarlansın mı? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Gruba davet edildiniz No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Bu sunuculara bağlı değilsiniz. Mesajları onlara iletmek için özel yönlendirme kullanılır. @@ -9276,6 +10058,10 @@ Katılma isteği tekrarlansın mı? Kilit ekranı bildirim önizlemesini ayarlar üzerinden ayarlayabilirsiniz. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Bir bağlantı veya QR kodu paylaşabilirsiniz - bu durumda herkes gruba katılabilir. Daha sonra silseniz bile grubun üyelerini kaybetmezsiniz. @@ -9321,16 +10107,21 @@ Katılma isteği tekrarlansın mı? Mesajlar gönderemezsiniz! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. Doğrulanamadınız; lütfen tekrar deneyin. No comment provided by engineer. - - You decide who can connect. - Kimin bağlanabileceğine siz karar verirsiniz. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9398,6 +10189,10 @@ Bağlantı isteği tekrarlansın mı? Bildirim almanız gerekiyor. token info + + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. Mesaj gönderebilmek için **isteğinizin kabul edilmesini beklemelisiniz**. @@ -9433,6 +10228,10 @@ Bağlantı isteği tekrarlansın mı? Aktif olduklarında sessize alınmış profillerden arama ve bildirim almaya devam edersiniz. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. Bu sohbetten mesaj almaya son vereceksiniz. Sohbet geçmişi korunacaktır. @@ -9478,6 +10277,10 @@ Bağlantı isteği tekrarlansın mı? Aramaların No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database Sohbet veritabanınız @@ -9528,6 +10331,10 @@ Bağlantı isteği tekrarlansın mı? Kişileriniz bağlı kalacaktır. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. Kimlik bilgileriniz şifrelenmeden gönderilebilir. @@ -9548,6 +10355,10 @@ Bağlantı isteği tekrarlansın mı? Grubunuz No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences Tercihleriniz @@ -9563,6 +10374,11 @@ Bağlantı isteği tekrarlansın mı? Profiliniz No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Profiliniz **%@** paylaşılacaktır. @@ -9583,11 +10399,23 @@ Bağlantı isteği tekrarlansın mı? Profiliniz değiştirildi. Kaydederseniz, güncellenmiş profil tüm kişilerinize gönderilecektir. alert message + + Your public address + No comment provided by engineer. + Your random profile Rasgele profiliniz No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address Sunucu adresiniz @@ -9603,21 +10431,11 @@ Bağlantı isteği tekrarlansın mı? Ayarlarınız No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Katkıda bulun](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Bize e-posta gönder](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Bize GitHub'da yıldız verin](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_italik_ @@ -9633,6 +10451,10 @@ Bağlantı isteği tekrarlansın mı? yukarı çıkın, ardından seçin: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ kabul edildi %@ @@ -9653,6 +10475,10 @@ Bağlantı isteği tekrarlansın mı? seni kabul etti rcv group event chat item + + active + No comment provided by engineer. + admin yönetici @@ -9764,6 +10590,10 @@ marked deleted chat item preview text aranıyor… call status + + can't broadcast + No comment provided by engineer. + can't send messages mesaj gönderilemiyor @@ -9799,6 +10629,14 @@ marked deleted chat item preview text adres değiştiriliyor… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored renklendirilmiş @@ -9945,6 +10783,10 @@ pref value silindi deleted chat item + + deleted channel + rcv group event chat item + deleted contact silinmiş kişi @@ -10055,11 +10897,19 @@ pref value hata No comment provided by engineer. + + error: %@ + receive error chat item + expired süresi dolmuş No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded iletildi @@ -10180,6 +11030,10 @@ pref value ayrıldı rcv group event chat item + + link + No comment provided by engineer. + marked deleted silinmiş olarak işaretlenmiş @@ -10250,6 +11104,10 @@ pref value asla delete after time + + new + No comment provided by engineer. + new message yeni mesaj @@ -10265,6 +11123,10 @@ pref value uçtan uca şifreleme yok No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text metin yok @@ -10368,6 +11230,10 @@ time to disappear geri çevrilmiş çağrı call status + + relay + member role + removed kaldırıldı @@ -10378,6 +11244,14 @@ time to disappear %@ kaldırıldı rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address kişi adresi silindi @@ -10532,6 +11406,10 @@ son alınan msj: %2$@ korumasız No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile grup profili güncellendi @@ -10552,6 +11430,10 @@ son alınan msj: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link bağlantı adres uzantısı ile @@ -10627,6 +11509,10 @@ son alınan msj: %2$@ gözlemcisiniz No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ engelledin %@ @@ -10687,6 +11573,10 @@ son alınan msj: %2$@ \~çizik~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index bfb565fd65..c20b26e029 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -185,6 +185,21 @@ %d місяців time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d сек @@ -200,11 +215,53 @@ %d пропущено повідомлення(ь) integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d тижнів time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +272,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld контакт(и) вибрані @@ -315,11 +376,19 @@ %u повідомлень пропущено. No comment provided by engineer. + + (from owner) + chat link info line + (new) (новий) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (цей пристрій v%@) @@ -365,6 +434,10 @@ **Відсканувати / Вставити посилання**: підключитися за отриманим посиланням. No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **Попередження**: Для отримання миттєвих пуш-сповіщень потрібна парольна фраза, збережена у брелоку. @@ -408,6 +481,12 @@ - і багато іншого! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +585,10 @@ time interval Ще кілька речей No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact Новий контакт @@ -632,9 +715,8 @@ swipe action Активні з'єднання No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Повідомлення про оновлення профілю буде надіслано вашим контактам. + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -702,6 +784,10 @@ swipe action Додано сервери повідомлень No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent Додатковий акцент @@ -792,6 +878,10 @@ swipe action Всі учасники групи залишаться на зв'язку. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Всі повідомлення та файли надсилаються **наскрізним шифруванням**, з пост-квантовим захистом у прямих повідомленнях. @@ -817,6 +907,14 @@ swipe action Всі профілі profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. Всі скарги будуть заархівовані для вас. @@ -876,6 +974,10 @@ swipe action Дозволяйте безповоротне видалення повідомлень, тільки якщо контакт дозволяє вам це зробити. (24 години) No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. Дозволяйте реакції на повідомлення, тільки якщо ваш контакт дозволяє їх. @@ -891,6 +993,10 @@ swipe action Дозволяє надсилати прямі повідомлення користувачам. No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. Дозволити надсилання зникаючих повідомлень. @@ -901,6 +1007,10 @@ swipe action Дозволити спільний доступ No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Дозволяє безповоротно видаляти надіслані повідомлення. (24 години) @@ -1005,11 +1115,6 @@ swipe action Відповісти на дзвінок No comment provided by engineer. - - Anybody can host servers. - Кожен може хостити сервери. - No comment provided by engineer. - App build: %@ Збірка програми: %@ @@ -1140,6 +1245,10 @@ swipe action Аудіо та відеодзвінки No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Аудіо/відео дзвінки @@ -1210,6 +1319,19 @@ swipe action Поганий хеш повідомлення No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + No comment provided by engineer. + Better calls Кращі дзвінки @@ -1305,6 +1427,10 @@ swipe action Заблокувати користувача? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin Заблокований адміністратором @@ -1353,6 +1479,14 @@ swipe action Надсилати голосові повідомлення можете як ви, так і ваш контакт. No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! Болгарською, фінською, тайською та українською мовами - завдяки користувачам та [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1361,7 +1495,7 @@ swipe action Business address Адреса підприємства - No comment provided by engineer. + chat link info line Business chats @@ -1383,15 +1517,6 @@ swipe action Через профіль чату (за замовчуванням) або [за з'єднанням](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - Використовуючи SimpleX Chat, ви погоджуєтеся: -- надсилати лише легальний контент у публічних групах. -- поважати інших користувачів - без спаму. - No comment provided by engineer. - Call already ended! Дзвінок вже закінчився! @@ -1540,6 +1665,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat Чат @@ -1625,6 +1811,22 @@ set passcode view Профіль користувача No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme Тема чату @@ -1643,7 +1845,8 @@ set passcode view Chat with admins Чат з адміністраторами - chat toolbar + chat feature +chat toolbar Chat with member @@ -1660,11 +1863,23 @@ set passcode view Чати No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members Чати з учасниками No comment provided by engineer. + + Chats with members are disabled + No comment provided by engineer. + Check messages every 20 min. Перевіряйте повідомлення кожні 20 хв. @@ -1675,6 +1890,14 @@ set passcode view Перевірте повідомлення, коли це дозволено. No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. Перевірте адресу сервера та спробуйте ще раз. @@ -1798,7 +2021,7 @@ set passcode view Conditions of use Умови використання - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1820,9 +2043,8 @@ set passcode view Налаштування серверів ICE No comment provided by engineer. - - Configure server operators - Налаштувати операторів сервера + + Configure relays No comment provided by engineer. @@ -1883,7 +2105,8 @@ set passcode view Connect Підключіться - server test step + relay test step +server test step Connect automatically @@ -1929,6 +2152,10 @@ This is your own one-time link! Підключіться за посиланням new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link Під'єднатися за одноразовим посиланням @@ -2007,6 +2234,10 @@ This is your own one-time link! Connection error (AUTH) Помилка підключення (AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -2061,6 +2292,10 @@ This is your own one-time link! З'єднання No comment provided by engineer. + + Contact address + chat link info line + Contact allows Контакт дозволяє @@ -2130,6 +2365,11 @@ This is your own one-time link! Продовжуйте No comment provided by engineer. + + Contribute + Внесок + No comment provided by engineer. + Conversation deleted! Розмова видалена! @@ -2158,12 +2398,7 @@ This is your own one-time link! Correct name to %@? Виправити ім'я на %@? - No comment provided by engineer. - - - Create - Створити - No comment provided by engineer. + alert message Create 1-time link @@ -2215,6 +2450,14 @@ This is your own one-time link! Створити профіль No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue Створити чергу @@ -2225,11 +2468,19 @@ This is your own one-time link! Створіть свою адресу No comment provided by engineer. + + Create your link + No comment provided by engineer. + Create your profile Створіть свій профіль No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created Створено @@ -2250,6 +2501,10 @@ This is your own one-time link! Створення архівного посилання No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… Створення посилання… @@ -2408,10 +2663,9 @@ This is your own one-time link! Доставка налагодження No comment provided by engineer. - - Decentralized - Децентралізований - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2459,6 +2713,14 @@ swipe action Видалити та повідомити контакт No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat Видалити чат @@ -2574,6 +2836,14 @@ swipe action Видалити повідомлення учасника? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Видалити повідомлення? @@ -2582,7 +2852,8 @@ swipe action Delete messages Видалити повідомлення - alert button + alert action +alert button Delete messages after @@ -2619,6 +2890,10 @@ swipe action Видалити чергу server test step + + Delete relay + No comment provided by engineer. + Delete report Видалити скаргу @@ -2783,6 +3058,14 @@ swipe action У цій групі заборонені прямі повідомлення між учасниками. No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) Вимкнути (зберегти перевизначення) @@ -2888,6 +3171,10 @@ swipe action Не надсилайте історію новим користувачам. No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. Не використовуйте облікові дані з проксі. @@ -2989,11 +3276,19 @@ chat item action Зашифровані сповіщення E2E. No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit Редагувати chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile Редагування профілю групи @@ -3007,7 +3302,7 @@ chat item action Enable Увімкнути - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3029,6 +3324,10 @@ chat item action Увімкнути TCP keep-alive No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? Увімкнути автоматичне видалення повідомлень? @@ -3039,6 +3338,10 @@ chat item action Увімкніть доступ до камери No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. Увімкнути зникаючі повідомлення за замовчуванням. @@ -3059,16 +3362,15 @@ chat item action Увімкнути миттєві сповіщення? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock Увімкнути блокування No comment provided by engineer. - - Enable notifications - Увімкнути сповіщення - No comment provided by engineer. - Enable periodic notifications? Увімкнути періодичні сповіщення? @@ -3174,6 +3476,10 @@ chat item action Введіть пароль No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. Введіть правильну парольну фразу. @@ -3199,6 +3505,14 @@ chat item action Введіть пароль вище, щоб показати! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually Увійдіть на сервер вручну @@ -3227,7 +3541,7 @@ chat item action Error Помилка - No comment provided by engineer. + conn error description Error aborting address change @@ -3254,6 +3568,10 @@ chat item action Помилка додавання користувача(ів) No comment provided by engineer. + + Error adding relay + alert title + Error adding server Помилка додавання сервера @@ -3304,11 +3622,19 @@ chat item action Помилка підключення до сервера переадресації %@. Спробуйте пізніше. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Помилка створення адреси No comment provided by engineer. + + Error creating channel + alert title + Error creating group Помилка створення групи @@ -3444,11 +3770,6 @@ chat item action Помилка відкриття чату No comment provided by engineer. - - Error opening group - Помилка відкриття групи - No comment provided by engineer. - Error receiving file Помилка отримання файлу @@ -3494,6 +3815,10 @@ chat item action Помилка збереження серверів ICE No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list Помилка під час збереження списку чатів @@ -3558,6 +3883,10 @@ chat item action Помилка встановлення підтвердження доставлення! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat Помилка запуску чату @@ -3637,7 +3966,8 @@ snd error text Error: %@. - server test error + relay test error +server test error Error: URL is invalid @@ -3837,6 +4167,10 @@ snd error text Файли та медіа заборонені! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Фільтруйте непрочитані та улюблені чати. @@ -3872,8 +4206,9 @@ snd error text Fingerprint in server address does not match certificate. - Можливо, в адресі сервера неправильно вказано відбиток сертифіката - server test error + Відбиток в адресі сервера не співпадає з сертифікатом. + relay test error +server test error Fingerprint in server address does not match certificate: %@. @@ -3914,10 +4249,15 @@ snd error text Для всіх модераторів No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: Для профілю чату %@: - servers error + servers error +servers warning For console @@ -4058,11 +4398,19 @@ Error: %2$@ GIF-файли та наклейки No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. Отримуйте сповіщення, коли вас згадують. No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! Доброго дня! @@ -4121,7 +4469,7 @@ Error: %2$@ Group link Посилання на групу - No comment provided by engineer. + chat link info line Group links @@ -4233,6 +4581,10 @@ Error: %2$@ Історія не надсилається новим учасникам. No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works Як працює SimpleX @@ -4298,6 +4650,10 @@ Error: %2$@ Якщо ви введете пароль самознищення під час відкриття програми: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Якщо вам потрібно скористатися чатом зараз, натисніть **Зробити це пізніше** нижче (вам буде запропоновано перенести базу даних при перезапуску програми). @@ -4318,16 +4674,15 @@ Error: %2$@ Зображення буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Негайно No comment provided by engineer. - - Immune to spam - Імунітет до спаму та зловживань - No comment provided by engineer. - Import Імпорт @@ -4470,9 +4825,9 @@ More improvements are coming soon! Початкова роль No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - Встановіть [SimpleX Chat для терміналу](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + Встановіть SimpleX Chat для терміналу No comment provided by engineer. @@ -4530,7 +4885,7 @@ More improvements are coming soon! Invalid connection link Неправильне посилання для підключення - No comment provided by engineer. + conn error description Invalid display name! @@ -4550,7 +4905,15 @@ More improvements are coming soon! Invalid name! Неправильне ім'я! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4577,11 +4940,19 @@ More improvements are coming soon! Запросити друзів No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Запросити учасників No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat Запросити в чат @@ -4658,6 +5029,10 @@ More improvements are coming soon! приєднатися як %@ No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group Приєднуйтесь до групи @@ -4745,6 +5120,14 @@ This is your link for group %@! Залишити swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat Вийти з чату @@ -4770,6 +5153,10 @@ This is your link for group %@! Менше трафіку в мобільних мережах. No comment provided by engineer. + + Let someone connect to you + No comment provided by engineer. + Let's talk in SimpleX Chat Поговоримо в чаті SimpleX @@ -4790,6 +5177,10 @@ This is your link for group %@! Зв'яжіть мобільні та десктопні додатки! 🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options Параметри пов'язаного робочого столу @@ -4800,6 +5191,10 @@ This is your link for group %@! Пов'язані робочі столи No comment provided by engineer. + + Links + No comment provided by engineer. + List Список @@ -4923,6 +5318,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Повідомлення учасників @@ -4946,12 +5345,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! Учасника буде видалено з чату – це неможливо скасувати! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Учасник буде видалений з групи - це неможливо скасувати! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -4963,6 +5362,10 @@ This is your link for group %@! Учасники групи можуть додавати реакції на повідомлення. No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) Учасники групи можуть безповоротно видаляти надіслані повідомлення. (24 години) @@ -5028,6 +5431,10 @@ This is your link for group %@! Чернетка повідомлення No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded Повідомлення переслано @@ -5123,6 +5530,14 @@ This is your link for group %@! Повідомлення від %@ будуть показані! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. Повідомлення в цьому чаті ніколи не будуть видалені. @@ -5153,16 +5568,15 @@ This is your link for group %@! Повідомлення, файли та дзвінки захищені **квантово-стійким шифруванням e2e** з ідеальною секретністю переадресації, відмовою та відновленням після злому. No comment provided by engineer. + + Migrate + No comment provided by engineer. + Migrate device Перенести пристрій No comment provided by engineer. - - Migrate from another device - Перехід з іншого пристрою - No comment provided by engineer. - Migrate here Мігруйте сюди @@ -5283,6 +5697,10 @@ This is your link for group %@! Мережа та сервери No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection Підключення до мережі @@ -5293,6 +5711,10 @@ This is your link for group %@! Децентралізація мережі No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. Проблеми з мережею - термін дії повідомлення закінчився після багатьох спроб надіслати його. @@ -5308,6 +5730,11 @@ This is your link for group %@! Мережевий оператор No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings Налаштування мережі @@ -5316,13 +5743,17 @@ This is your link for group %@! Network status Стан мережі - No comment provided by engineer. + alert title New Новий token status text + + New 1-time link + No comment provided by engineer. + New Passcode Новий пароль @@ -5348,6 +5779,10 @@ This is your link for group %@! Новий досвід спілкування в чаті 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request Новий запит на контакт @@ -5418,11 +5853,28 @@ This is your link for group %@! Ні No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password Немає пароля програми Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats Без чатів @@ -5568,11 +6020,22 @@ This is your link for group %@! Немає непрочитаних чатів No comment provided by engineer. - - No user identifiers. - Ніяких ідентифікаторів користувачів. + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! Не сумісні! @@ -5630,7 +6093,7 @@ This is your link for group %@! OK ОК - No comment provided by engineer. + alert button Off @@ -5649,11 +6112,19 @@ new chat action Стара база даних No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link Посилання на одноразове запрошення No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5673,6 +6144,10 @@ Requires compatible VPN. Onion хости не будуть використовуватися. No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. Лише власники чату можуть змінювати налаштування. @@ -5774,7 +6249,8 @@ Requires compatible VPN. Open Відкрито - alert action + alert action +alert button Open Settings @@ -5786,6 +6262,10 @@ Requires compatible VPN. Відкриті зміни No comment provided by engineer. + + Open channel + new chat action + Open chat Відкритий чат @@ -5805,6 +6285,10 @@ Requires compatible VPN. Відкриті умови No comment provided by engineer. + + Open external link? + alert title + Open full link alert action @@ -5824,6 +6308,10 @@ Requires compatible VPN. Відкрита міграція на інший пристрій authentication reason + + Open new channel + new chat action + Open new chat Відкрити новий чат @@ -5868,6 +6356,13 @@ Requires compatible VPN. Сервер оператора alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file Або імпортуйте архівний файл @@ -5888,6 +6383,10 @@ Requires compatible VPN. Або безпечно поділіться цим посиланням на файл No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code Або покажіть цей код @@ -5898,6 +6397,10 @@ Requires compatible VPN. Або поділитися приватно No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists Організовуйте чати в списки @@ -5915,6 +6418,18 @@ Requires compatible VPN. %@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count Кількість PING @@ -5970,6 +6485,10 @@ Requires compatible VPN. Вставити зображення No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! Вставте посилання для підключення! @@ -6124,6 +6643,14 @@ Error: %@ Зберегти чернетку останнього повідомлення з вкладеннями. No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address Попередньо встановлена адреса сервера @@ -6159,14 +6686,12 @@ Error: %@ Політика конфіденційності та умови використання. No comment provided by engineer. - - Privacy redefined - Конфіденційність переглянута + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - Приватні чати, групи та ваші контакти недоступні для операторів сервера. + + Private and secure messaging. No comment provided by engineer. @@ -6209,6 +6734,10 @@ Error: %@ Тайм-аут приватної маршрутизації alert title + + Proceed + alert action + Profile and server connections З'єднання профілю та сервера @@ -6234,9 +6763,8 @@ Error: %@ Тема профілю No comment provided by engineer. - - Profile update will be sent to your contacts. - Оновлення профілю буде надіслано вашим контактам. + + Profile update will be sent to your SimpleX contacts. alert message @@ -6244,6 +6772,10 @@ Error: %@ Заборонити аудіо/відеодзвінки. No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. Заборонити незворотне видалення повідомлень. @@ -6274,6 +6806,10 @@ Error: %@ Заборонити надсилати прямі повідомлення учасникам. No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. Заборонити надсилання зникаючих повідомлень. @@ -6341,6 +6877,10 @@ Enable in *Network & servers* settings. Проксі вимагає пароль No comment provided by engineer. + + Public channels - speak freely 🚀 + No comment provided by engineer. + Push notifications Push-сповіщення @@ -6381,24 +6921,14 @@ Enable in *Network & servers* settings. Читати далі No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - Читайте більше в [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). + + Read more in User Guide. + Читайте більше в User Guide. No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - Читайте більше в нашому [GitHub репозиторії](https://github.com/simplex-chat/simplex-chat#readme). + + Read more in our GitHub repository. + Читайте більше в нашому GitHub репозиторії. No comment provided by engineer. @@ -6421,11 +6951,6 @@ Enable in *Network & servers* settings. Отримано за: %@ copied message info - - Received file event - Подія отримання файлу - notification - Received message Отримано повідомлення @@ -6563,6 +7088,26 @@ swipe action Відхилити учасника? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. Релейний сервер використовується тільки в разі потреби. Інша сторона може бачити вашу IP-адресу. @@ -6573,10 +7118,22 @@ swipe action Сервер ретрансляції захищає вашу IP-адресу, але він може спостерігати за тривалістю дзвінка. No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove Видалити - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6600,13 +7157,21 @@ swipe action Remove member? Видалити учасника? - No comment provided by engineer. + alert title Remove passphrase from keychain? Видалити парольну фразу з брелока? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. Видаляє повідомлення та блокує користувачів. @@ -6842,6 +7407,10 @@ swipe action Проксі SOCKS No comment provided by engineer. + + Safe web links + No comment provided by engineer. + Safely receive files Безпечне отримання файлів @@ -6868,6 +7437,10 @@ chat item action Зберегти (і повідомити учасникам) alert button + + Save (and notify subscribers) + alert button + Save admission settings? Зберегти налаштування входу? @@ -6883,6 +7456,10 @@ chat item action Зберегти та повідомити учасників групи No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect Збережіть і підключіться знову @@ -6893,6 +7470,14 @@ chat item action Збереження та оновлення профілю групи No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile Зберегти профіль групи @@ -7018,11 +7603,31 @@ chat item action Рядок пошуку приймає посилання-запрошення. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Знайдіть або вставте посилання SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Вторинний @@ -7048,6 +7653,10 @@ chat item action Код безпеки No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select Виберіть @@ -7178,6 +7787,10 @@ chat item action Надіслати запит без повідомлення No comment provided by engineer. + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + Send them from gallery or custom keyboards. Надсилайте їх із галереї чи власних клавіатур. @@ -7188,6 +7801,10 @@ chat item action Надішліть до 100 останніх повідомлень новим користувачам. No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. Надсилайте свої приватні відгуки до груп. @@ -7203,6 +7820,10 @@ chat item action Можливо, відправник видалив запит на підключення. No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. Надсилання підтверджень доставки буде ввімкнено для всіх контактів у всіх видимих профілях чату. @@ -7258,11 +7879,6 @@ chat item action Відправлено напряму No comment provided by engineer. - - Sent file event - Подія надісланого файлу - notification - Sent message Надіслано повідомлення @@ -7333,14 +7949,18 @@ chat item action Протокол сервера змінено. alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. - Сервер вимагає авторизації для створення черг, перевірте пароль + Сервер вимагає авторизації для створення черг, перевірте пароль. server test error Server requires authorization to upload, check password. - Сервер вимагає авторизації для завантаження, перевірте пароль + Сервер вимагає авторизації для завантаження, перевірте пароль. server test error @@ -7463,6 +8083,14 @@ chat item action Налаштування були змінені. alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images Сформуйте зображення профілю @@ -7499,11 +8127,14 @@ chat item action Поділіться адресою публічно No comment provided by engineer. - - Share address with contacts? - Поділіться адресою з контактами? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. Діліться з інших програм. @@ -7529,6 +8160,10 @@ chat item action Поділіться профілем No comment provided by engineer. + + Share relay address + No comment provided by engineer. + Share this 1-time invite link Поділіться цим одноразовим посиланням-запрошенням @@ -7539,9 +8174,12 @@ chat item action Поділіться з SimpleX No comment provided by engineer. - - Share with contacts - Поділіться з контактами + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. @@ -7714,8 +8352,8 @@ chat item action Протоколи SimpleX, розглянуті Trail of Bits. No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7791,6 +8429,11 @@ report reason Квадрат, коло або щось середнє між ними. No comment provided by engineer. + + Star on GitHub + Зірка на GitHub + No comment provided by engineer. + Start chat Почати чат @@ -7891,6 +8534,63 @@ report reason Підписано No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors Помилки підписки @@ -7971,6 +8671,10 @@ report reason Сфотографуйте No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat Натисніть Підключитися до чату @@ -7985,9 +8689,8 @@ report reason Tap Connect to use bot No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. - Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше. + + Tap Join channel No comment provided by engineer. @@ -8020,6 +8723,10 @@ report reason Натисніть, щоб приєднатися інкогніто No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link Натисніть, щоб вставити посилання @@ -8038,13 +8745,18 @@ report reason Test failed at step %@. Тест завершився невдало на кроці %@. - server test failure + relay test failure +server test failure Test notifications Тестові сповіщення No comment provided by engineer. + + Test relay + No comment provided by engineer. + Test server Тестовий сервер @@ -8097,6 +8809,10 @@ It can happen because of some bug or when the connection is compromised.Додаток захищає вашу конфіденційність, використовуючи різних операторів у кожній розмові. No comment provided by engineer. + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + The app will ask to confirm downloads from unknown file servers (except .onion). Програма попросить підтвердити завантаження з невідомих файлових серверів (крім .onion). @@ -8112,6 +8828,10 @@ It can happen because of some bug or when the connection is compromised.Відсканований вами код не є QR-кодом посилання SimpleX. No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. З'єднання досягло ліміту недоставлених повідомлень, ваш контакт може бути офлайн. @@ -8137,9 +8857,9 @@ It can happen because of some bug or when the connection is compromised.Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з'єднання! No comment provided by engineer. - - The future of messaging - Наступне покоління приватних повідомлень + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -8177,6 +8897,10 @@ It can happen because of some bug or when the connection is compromised.Стара база даних не була видалена під час міграції, її можна видалити. No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Такі ж умови діятимуть і для оператора **%@**. @@ -8222,6 +8946,14 @@ It can happen because of some bug or when the connection is compromised.Теми No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + These conditions will also apply for: **%@**. Ці умови також поширюються на: **%@**. @@ -8287,6 +9019,14 @@ It can happen because of some bug or when the connection is compromised.Цієї групи більше не існує. No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Це посилання вимагає новішої версії додатку. Будь ласка, оновіть додаток або попросіть вашого контакту надіслати сумісне посилання. @@ -8336,6 +9076,10 @@ It can happen because of some bug or when the connection is compromised.Приховати небажані повідомлення. No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection Щоб створити нове з'єднання @@ -8422,11 +9166,6 @@ You will be prompted to complete authentication before this feature is enabled.< Щоб перевірити наскрізне шифрування з вашим контактом, порівняйте (або відскануйте) код на ваших пристроях. No comment provided by engineer. - - Toggle chat list: - Перемикання списку чату: - No comment provided by engineer. - Toggle incognito when connecting. Увімкніть інкогніто при підключенні. @@ -8442,6 +9181,10 @@ You will be prompted to complete authentication before this feature is enabled.< Непрозорість панелі інструментів No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total Всього @@ -8457,15 +9200,9 @@ You will be prompted to complete authentication before this feature is enabled.< Транспортні сесії No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту (помилка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8512,6 +9249,10 @@ You will be prompted to complete authentication before this feature is enabled.< Розблокувати учасника? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages Недоставлені повідомлення @@ -8612,13 +9353,17 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link Несумісне посилання для підключення - No comment provided by engineer. + conn error description Up to 100 last messages are sent to new members. Новим користувачам надсилається до 100 останніх повідомлень. No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update Оновлення @@ -8744,11 +9489,6 @@ To connect, please ask your contact to create another connection link and check Використовуйте TCP порт 443 лише для попередньо налаштованих серверів. No comment provided by engineer. - - Use chat - Використовуйте чат - No comment provided by engineer. - Use current profile Використовувати поточний профіль @@ -8764,6 +9504,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте для повідомлень No comment provided by engineer. + + Use for new channels + No comment provided by engineer. + Use for new connections Використовуйте для нових з'єднань @@ -8804,6 +9548,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте приватну маршрутизацію з невідомими серверами. No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server Використовувати сервер @@ -8824,6 +9572,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте додаток однією рукою. No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port Використовувати веб-порт @@ -8844,6 +9596,10 @@ To connect, please ask your contact to create another connection link and check Використання серверів SimpleX Chat. No comment provided by engineer. + + Verify + relay test step + Verify code with desktop Перевірте код на робочому столі @@ -8904,6 +9660,10 @@ To connect, please ask your contact to create another connection link and check Відео буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Відео та файли до 1 Гб @@ -8959,6 +9719,18 @@ To connect, please ask your contact to create another connection link and check Голосове повідомлення… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... Чекаємо на десктопну версію... @@ -8999,6 +9771,10 @@ To connect, please ask your contact to create another connection link and check Попередження: ви можете втратити деякі дані! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers Сервери WebRTC ICE @@ -9049,6 +9825,10 @@ To connect, please ask your contact to create another connection link and check Коли ви ділитеся з кимось своїм профілем інкогніто, цей профіль буде використовуватися для груп, до яких вас запрошують. No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi WiFi @@ -9176,16 +9956,19 @@ Repeat join request? Повторити запит на приєднання? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Запрошуємо вас до групи No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Не підключені до цих серверів. Для доставлення повідомлень до них використовується приватна маршрутизація. @@ -9256,6 +10039,10 @@ Repeat join request? Ви можете налаштувати попередній перегляд сповіщень на екрані блокування за допомогою налаштувань. No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите учасників групи, якщо згодом видалите її. @@ -9301,16 +10088,21 @@ Repeat join request? Ви не можете надсилати повідомлення! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. Вас не вдалося верифікувати, спробуйте ще раз. No comment provided by engineer. - - You decide who can connect. - Ви вирішуєте, хто може під'єднатися. - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9378,6 +10170,10 @@ Repeat connection request? Ви повинні отримувати сповіщення. token info + + You were born without an account + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. Ви зможете надсилати повідомлення **тільки після того, як ваш запит буде прийнято**. @@ -9413,6 +10209,10 @@ Repeat connection request? Ви все одно отримуватимете дзвінки та сповіщення від вимкнених профілів, якщо вони активні. No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. Ви більше не будете отримувати повідомлення з цього чату. Історія чату буде збережена. @@ -9458,6 +10258,10 @@ Repeat connection request? Твої дзвінки No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database Ваша база даних чату @@ -9508,6 +10312,10 @@ Repeat connection request? Ваші контакти залишаться на зв'язку. No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + Your credentials may be sent unencrypted. Ваші облікові дані можуть бути надіслані незашифрованими. @@ -9528,6 +10336,10 @@ Repeat connection request? Ваша група No comment provided by engineer. + + Your network + No comment provided by engineer. + Your preferences Ваші уподобання @@ -9543,6 +10355,11 @@ Repeat connection request? Ваш профіль No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. Ваш профіль **%@** буде опублікований. @@ -9563,11 +10380,23 @@ Repeat connection request? Ваш профіль було змінено. Якщо ви збережете його, оновлений профіль буде надіслано всім вашим контактам. alert message + + Your public address + No comment provided by engineer. + Your random profile Ваш випадковий профіль No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address Адреса вашого сервера @@ -9583,21 +10412,11 @@ Repeat connection request? Ваші налаштування No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Внесок](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [Напишіть нам електронною поштою](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [Зірка на GitHub](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_курсив_ @@ -9613,6 +10432,10 @@ Repeat connection request? вище, а потім обирайте: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ прийнято %@ @@ -9633,6 +10456,10 @@ Repeat connection request? прийняв(ла) вас rcv group event chat item + + active + No comment provided by engineer. + admin адмін @@ -9744,6 +10571,10 @@ marked deleted chat item preview text дзвоніть… call status + + can't broadcast + No comment provided by engineer. + can't send messages не можна надсилати @@ -9779,6 +10610,14 @@ marked deleted chat item preview text змінює адресу… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored кольоровий @@ -9925,6 +10764,10 @@ pref value видалено deleted chat item + + deleted channel + rcv group event chat item + deleted contact видалений контакт @@ -10035,11 +10878,19 @@ pref value помилка No comment provided by engineer. + + error: %@ + receive error chat item + expired закінчився No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded переслано @@ -10160,6 +11011,10 @@ pref value ліворуч rcv group event chat item + + link + No comment provided by engineer. + marked deleted з позначкою видалено @@ -10230,6 +11085,10 @@ pref value ніколи delete after time + + new + No comment provided by engineer. + new message нове повідомлення @@ -10245,6 +11104,10 @@ pref value без шифрування e2e No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text без тексту @@ -10348,6 +11211,10 @@ time to disappear відхилений виклик call status + + relay + member role + removed видалено @@ -10358,6 +11225,14 @@ time to disappear видалено %@ rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address видалено контактну адресу @@ -10510,6 +11385,10 @@ last received msg: %2$@ незахищені No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile оновлений профіль групи @@ -10530,6 +11409,10 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link за посиланням на контактну адресу @@ -10605,6 +11488,10 @@ last received msg: %2$@ ви спостерігач No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ ви заблокували %@ @@ -10665,6 +11552,10 @@ last received msg: %2$@ \~закреслити~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 0a2a252826..51cbb94bda 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -185,6 +185,21 @@ %d 月 time interval + + %d relays failed + channel relay bar +channel subscriber relay bar + + + %d relays not active + channel relay bar +channel subscriber relay bar + + + %d relays removed + channel relay bar +channel subscriber relay bar + %d sec %d 秒 @@ -200,11 +215,53 @@ 跳过的 %d 条消息 integrity error chat item + + %d subscriber + channel subscriber count + + + %d subscribers + channel subscriber count + %d weeks %d 星期 time interval + + %1$d/%2$d relays active + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + channel relay bar + + + %1$d/%2$d relays connected + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + channel subscriber relay bar + %lld %lld @@ -215,6 +272,10 @@ %lld %@ No comment provided by engineer. + + %lld channel events + No comment provided by engineer. + %lld contact(s) selected %lld 联系人已选择 @@ -315,11 +376,19 @@ 已跳过 %u 条消息。 No comment provided by engineer. + + (from owner) + chat link info line + (new) (新) No comment provided by engineer. + + (signed) + chat link info line + (this device v%@) (此设备 v%@) @@ -365,6 +434,10 @@ **扫描/粘贴链接**:用您收到的链接连接。 No comment provided by engineer. + + **Test relay** to retrieve its name. + No comment provided by engineer. + **Warning**: Instant push notifications require passphrase saved in Keychain. **警告**:及时推送通知需要保存在钥匙串的密码。 @@ -408,6 +481,12 @@ - 以及更多! No comment provided by engineer. + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + No comment provided by engineer. + - optionally notify deleted contacts. - profile names with spaces. @@ -506,6 +585,10 @@ time interval 一些杂项 No comment provided by engineer. + + A link for one person to connect + No comment provided by engineer. + A new contact 新联系人 @@ -568,10 +651,12 @@ swipe action Accept as member + 接受为成员 alert action Accept as observer + 接受为观察员 alert action @@ -586,6 +671,7 @@ swipe action Accept contact request + 接受联络请求 alert title @@ -601,6 +687,7 @@ swipe action Accept member + 接受成员 alert title @@ -628,9 +715,8 @@ swipe action 活动连接 No comment provided by engineer. - - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - 将地址添加到您的个人资料,以便您的联系人可以与其他人共享。个人资料更新将发送给您的联系人。 + + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -645,6 +731,7 @@ swipe action Add message + 添加信息 placeholder for sending contact request @@ -697,6 +784,10 @@ swipe action 已添加消息服务器 No comment provided by engineer. + + Adding relays will be supported later. + No comment provided by engineer. + Additional accent 附加重音 @@ -787,6 +878,11 @@ swipe action 所有群组成员将保持连接。 No comment provided by engineer. + + All messages + 所有消息 + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. 所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。 @@ -812,6 +908,14 @@ swipe action 所有配置文件 profile dropdown + + All relays failed + No comment provided by engineer. + + + All relays removed + No comment provided by engineer. + All reports will be archived for you. 将为你存档所有举报。 @@ -864,6 +968,7 @@ swipe action Allow files and media only if your contact allows them. + 只有你的联系人允许的情况下才允许文件和媒体。 No comment provided by engineer. @@ -871,6 +976,10 @@ swipe action 仅有您的联系人许可后才允许不可撤回消息移除 No comment provided by engineer. + + Allow members to chat with admins. + No comment provided by engineer. + Allow message reactions only if your contact allows them. 只有您的联系人允许时才允许消息回应。 @@ -886,6 +995,10 @@ swipe action 允许向成员发送私信。 No comment provided by engineer. + + Allow sending direct messages to subscribers. + No comment provided by engineer. + Allow sending disappearing messages. 允许发送限时消息。 @@ -896,6 +1009,10 @@ swipe action 允许共享 No comment provided by engineer. + + Allow subscribers to chat with admins. + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) 允许不可撤回地删除已发送消息 @@ -953,6 +1070,7 @@ swipe action Allow your contacts to send files and media. + 允许你的联系人发送文件和媒体。 No comment provided by engineer. @@ -1000,11 +1118,6 @@ swipe action 接听来电 No comment provided by engineer. - - Anybody can host servers. - 任何人都可以托管服务器。 - No comment provided by engineer. - App build: %@ 应用程序构建:%@ @@ -1135,6 +1248,11 @@ swipe action 语音和视频通话 No comment provided by engineer. + + Audio call + 语音通话 + No comment provided by engineer. + Audio/video calls 音频/视频通话 @@ -1205,6 +1323,21 @@ swipe action 错误消息散列 No comment provided by engineer. + + Be free +in your network + No comment provided by engineer. + + + Be free in your network. + 在你的网络中自由畅行。 + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + 因为我们摧毁了知道你是谁的权力,因而您的权利永远不会被夺走。 + No comment provided by engineer. + Better calls 更佳的通话 @@ -1257,10 +1390,12 @@ swipe action Bio + 自我介绍 No comment provided by engineer. Bio too large + 自我介绍过大 alert title @@ -1298,6 +1433,10 @@ swipe action 封禁成员吗? No comment provided by engineer. + + Block subscriber for all? + No comment provided by engineer. + Blocked by admin 由管理员封禁 @@ -1315,6 +1454,7 @@ swipe action Bot + 机器人 No comment provided by engineer. @@ -1339,6 +1479,7 @@ swipe action Both you and your contact can send files and media. + 你和你的联系人都可发送文件和媒体。 No comment provided by engineer. @@ -1346,6 +1487,14 @@ swipe action 您和您的联系人都可以发送语音消息。 No comment provided by engineer. + + Bottom bar + No comment provided by engineer. + + + Broadcast + compose placeholder for channel owner + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! 保加利亚语、芬兰语、泰语和乌克兰语——感谢用户和[Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! @@ -1354,7 +1503,7 @@ swipe action Business address 企业地址 - No comment provided by engineer. + chat link info line Business chats @@ -1363,6 +1512,7 @@ swipe action Business connection + 企业连接 No comment provided by engineer. @@ -1375,15 +1525,6 @@ swipe action 通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。 No comment provided by engineer. - - By using SimpleX Chat you agree to: -- send only legal content in public groups. -- respect other users – no spam. - 使用 SimpleX Chat 代表您同意: -- 在公开群中只发送合法内容 -- 尊重其他用户 – 没有垃圾信息。 - No comment provided by engineer. - Call already ended! 通话已结束! @@ -1416,6 +1557,7 @@ swipe action Can't change profile + 无法更改个人资料 alert title @@ -1531,6 +1673,67 @@ new chat action authentication reason set passcode view + + Channel + No comment provided by engineer. + + + Channel display name + No comment provided by engineer. + + + Channel full name (optional) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + alert message +alert subtitle + + + Channel image + No comment provided by engineer. + + + Channel link + chat link info line + + + Channel preferences + No comment provided by engineer. + + + Channel profile + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + alert message + + + Channel temporarily unavailable + alert title + + + Channel will be deleted for all subscribers - this cannot be undone! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Proceed? + alert message + + + Channels + No comment provided by engineer. + Chat 聊天 @@ -1616,6 +1819,22 @@ set passcode view 用户资料 No comment provided by engineer. + + Chat relay + No comment provided by engineer. + + + Chat relays + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + No comment provided by engineer. + Chat theme 聊天主题 @@ -1633,14 +1852,18 @@ set passcode view Chat with admins - chat toolbar + 和管理员聊天 + chat feature +chat toolbar Chat with member + 和成员聊天 No comment provided by engineer. Chat with members before they join. + 在成员加入前和这些人聊天 No comment provided by engineer. @@ -1648,8 +1871,21 @@ set passcode view 聊天 No comment provided by engineer. + + Chats with admins are prohibited. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + alert message + Chats with members + 和成员聊天 + No comment provided by engineer. + + + Chats with members are disabled No comment provided by engineer. @@ -1662,6 +1898,14 @@ set passcode view 在被允许时检查消息。 No comment provided by engineer. + + Check relay address and try again. + alert message + + + Check relay name and try again. + alert message + Check server address and try again. 检查服务器地址并再试一次。 @@ -1785,7 +2029,7 @@ set passcode view Conditions of use 使用条款 - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1807,9 +2051,8 @@ set passcode view 配置 ICE 服务器 No comment provided by engineer. - - Configure server operators - 配置服务器运营方 + + Configure relays No comment provided by engineer. @@ -1870,7 +2113,8 @@ set passcode view Connect 连接 - server test step + relay test step +server test step Connect automatically @@ -1879,6 +2123,7 @@ set passcode view Connect faster! 🚀 + 更快地连接!🚀 No comment provided by engineer. @@ -1915,6 +2160,10 @@ This is your own one-time link! 通过链接连接 new chat sheet title + + Connect via link or QR code + No comment provided by engineer. + Connect via one-time link 通过一次性链接连接 @@ -1993,6 +2242,10 @@ This is your own one-time link! Connection error (AUTH) 连接错误(AUTH) + conn error description + + + Connection failed No comment provided by engineer. @@ -2046,6 +2299,10 @@ This is your own one-time link! 连接 No comment provided by engineer. + + Contact address + chat link info line + Contact allows 联系人允许 @@ -2088,6 +2345,7 @@ This is your own one-time link! Contact requests from groups + 来自群的联络请求 No comment provided by engineer. @@ -2115,6 +2373,11 @@ This is your own one-time link! 继续 No comment provided by engineer. + + Contribute + 贡献 + No comment provided by engineer. + Conversation deleted! 对话已删除! @@ -2143,12 +2406,7 @@ This is your own one-time link! Correct name to %@? 将名称更正为 %@? - No comment provided by engineer. - - - Create - 创建 - No comment provided by engineer. + alert message Create 1-time link @@ -2200,6 +2458,14 @@ This is your own one-time link! 创建个人资料 No comment provided by engineer. + + Create public channel + No comment provided by engineer. + + + Create public channel (BETA) + No comment provided by engineer. + Create queue 创建队列 @@ -2207,6 +2473,11 @@ This is your own one-time link! Create your address + 创建地址 + No comment provided by engineer. + + + Create your link No comment provided by engineer. @@ -2214,6 +2485,10 @@ This is your own one-time link! 创建您的资料 No comment provided by engineer. + + Create your public address + No comment provided by engineer. + Created 已创建 @@ -2234,6 +2509,10 @@ This is your own one-time link! 正在创建存档链接 No comment provided by engineer. + + Creating channel + No comment provided by engineer. + Creating link… 创建链接中… @@ -2392,10 +2671,9 @@ This is your own one-time link! 调试交付 No comment provided by engineer. - - Decentralized - 分散式 - No comment provided by engineer. + + Decode link + relay test step Decryption error @@ -2443,6 +2721,14 @@ swipe action 删除并通知联系人 No comment provided by engineer. + + Delete channel + No comment provided by engineer. + + + Delete channel? + No comment provided by engineer. + Delete chat 删除聊天 @@ -2465,6 +2751,7 @@ swipe action Delete chat with member? + 删除和成员的聊天吗? alert title @@ -2557,6 +2844,15 @@ swipe action 删除成员消息? No comment provided by engineer. + + Delete member messages + 删除成员消息 + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? 删除消息吗? @@ -2565,7 +2861,8 @@ swipe action Delete messages 删除消息 - alert button + alert action +alert button Delete messages after @@ -2602,6 +2899,10 @@ swipe action 删除队列 server test step + + Delete relay + No comment provided by engineer. + Delete report 删除举报 @@ -2664,6 +2965,7 @@ swipe action Deprecated options + 已废弃的选项 No comment provided by engineer. @@ -2673,6 +2975,7 @@ swipe action Description too large + 描述过大 alert title @@ -2765,6 +3068,14 @@ swipe action 此群禁止成员间私信。 No comment provided by engineer. + + Direct messages between subscribers are prohibited. + No comment provided by engineer. + + + Disable + alert button + Disable (keep overrides) 禁用(保留覆盖) @@ -2870,6 +3181,10 @@ swipe action 不给新成员发送历史消息。 No comment provided by engineer. + + Do not send history to new subscribers. + No comment provided by engineer. + Do not use credentials with proxy. 代理不使用身份验证凭据。 @@ -2971,11 +3286,19 @@ chat item action 端到端加密的通知。 No comment provided by engineer. + + Easier to invite your friends 👋 + No comment provided by engineer. + Edit 编辑 chat item action + + Edit channel profile + No comment provided by engineer. + Edit group profile 编辑群组资料 @@ -2983,12 +3306,13 @@ chat item action Empty message! + 空消息! No comment provided by engineer. Enable 启用 - No comment provided by engineer. + alert button Enable (keep overrides) @@ -3010,6 +3334,10 @@ chat item action 启用 TCP 保持活跃状态 No comment provided by engineer. + + Enable at least one chat relay in Network & Servers. + channel creation warning + Enable automatic message deletion? 启用自动删除消息? @@ -3020,8 +3348,13 @@ chat item action 启用相机访问 No comment provided by engineer. + + Enable chats with admins? + alert title + Enable disappearing messages by default. + 默认启用定时消失消息。 No comment provided by engineer. @@ -3039,16 +3372,15 @@ chat item action 启用即时通知? No comment provided by engineer. + + Enable link previews? + alert title + Enable lock 启用锁定 No comment provided by engineer. - - Enable notifications - 启用通知 - No comment provided by engineer. - Enable periodic notifications? 启用定期通知? @@ -3154,6 +3486,10 @@ chat item action 输入密码 No comment provided by engineer. + + Enter channel name… + No comment provided by engineer. + Enter correct passphrase. 输入正确密码。 @@ -3179,6 +3515,14 @@ chat item action 在上面输入密码以显示! No comment provided by engineer. + + Enter profile name... + No comment provided by engineer. + + + Enter relay name… + No comment provided by engineer. + Enter server manually 手动输入服务器 @@ -3207,7 +3551,7 @@ chat item action Error 错误 - No comment provided by engineer. + conn error description Error aborting address change @@ -3226,6 +3570,7 @@ chat item action Error accepting member + 接受成员出错 alert title @@ -3233,6 +3578,10 @@ chat item action 添加成员错误 No comment provided by engineer. + + Error adding relay + alert title + Error adding server 添加服务器出错 @@ -3240,6 +3589,7 @@ chat item action Error adding short link + 添加短链接出错 No comment provided by engineer. @@ -3249,6 +3599,7 @@ chat item action Error changing chat profile + 更改聊天资料出错 alert title @@ -3273,6 +3624,7 @@ chat item action Error checking token status + 查询token状态出错 No comment provided by engineer. @@ -3280,11 +3632,19 @@ chat item action 连接到转发服务器 %@ 时出错。请稍后尝试。 alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address 创建地址错误 No comment provided by engineer. + + Error creating channel + alert title + Error creating group 创建群组错误 @@ -3327,6 +3687,7 @@ chat item action Error deleting chat + 删除聊天出错 alert title @@ -3419,10 +3780,6 @@ chat item action 打开聊天时出错 No comment provided by engineer. - - Error opening group - No comment provided by engineer. - Error receiving file 接收文件错误 @@ -3445,6 +3802,7 @@ chat item action Error rejecting contact request + 拒绝联络请求出错 alert title @@ -3467,6 +3825,10 @@ chat item action 保存 ICE 服务器错误 No comment provided by engineer. + + Error saving channel profile + No comment provided by engineer. + Error saving chat list 保存聊天列表出错 @@ -3524,6 +3886,7 @@ chat item action Error setting auto-accept + 设置自动接受出错 No comment provided by engineer. @@ -3531,6 +3894,10 @@ chat item action 设置送达回执出错! No comment provided by engineer. + + Error sharing channel + alert title + Error starting chat 启动聊天错误 @@ -3610,7 +3977,9 @@ snd error text Error: %@. - server test error + 错误:%@。 + relay test error +server test error Error: URL is invalid @@ -3793,6 +4162,7 @@ snd error text Files and media are prohibited in this chat. + 此聊天禁止文件和媒体。 No comment provided by engineer. @@ -3810,6 +4180,11 @@ snd error text 禁止文件和媒体! No comment provided by engineer. + + Filter + 过滤器 + No comment provided by engineer. + Filter unread and favorite chats. 过滤未读和收藏的聊天记录。 @@ -3837,19 +4212,23 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + 目的地服务器的指纹与证书不符:%@。 No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + 转发服务器的指纹与证书不符:%@。 No comment provided by engineer. Fingerprint in server address does not match certificate. 服务器地址中的证书指纹可能不正确 - server test error + relay test error +server test error Fingerprint in server address does not match certificate: %@. + 服务器的指纹与证书不符:%@。 No comment provided by engineer. @@ -3887,10 +4266,15 @@ snd error text 所有 moderators No comment provided by engineer. + + For anyone to reach you + No comment provided by engineer. + For chat profile %@: 为聊天资料 %@: - servers error + servers error +servers warning For console @@ -4031,11 +4415,19 @@ Error: %2$@ GIF 和贴纸 No comment provided by engineer. + + Get link + relay test step + Get notified when mentioned. 被提及时收到通知。 No comment provided by engineer. + + Get started + No comment provided by engineer. + Good afternoon! 下午好! @@ -4094,7 +4486,7 @@ Error: %2$@ Group link 群组链接 - No comment provided by engineer. + chat link info line Group links @@ -4128,6 +4520,7 @@ Error: %2$@ Group profile was changed. If you save it, the updated profile will be sent to group members. + 群资料已修改。如果你进行保存,修改后的群资料将发送给其他群成员。 alert message @@ -4205,6 +4598,10 @@ Error: %2$@ 未发送历史消息给新成员。 No comment provided by engineer. + + History is not sent to new subscribers. + No comment provided by engineer. + How SimpleX works SimpleX的工作原理 @@ -4270,6 +4667,10 @@ Error: %2$@ 如果您在打开应用程序时输入自毁密码: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). 如果您现在需要使用聊天,请点击下面的**稍后再做**(当您重新启动应用程序时,系统会提示您迁移数据库)。 @@ -4290,16 +4691,16 @@ Error: %2$@ 图片将在您的联系人在线时收到,请稍等或稍后查看! No comment provided by engineer. + + Images + 图片 + No comment provided by engineer. + Immediately 立即 No comment provided by engineer. - - Immune to spam - 不受垃圾和骚扰消息影响 - No comment provided by engineer. - Import 导入 @@ -4442,9 +4843,9 @@ More improvements are coming soon! 初始角色 No comment provided by engineer. - - Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) - 安装[用于终端的 SimpleX Chat](https://github.com/simplex-chat/simplex-chat) + + Install SimpleX Chat for terminal + 安装用于终端的 SimpleX Chat No comment provided by engineer. @@ -4502,7 +4903,7 @@ More improvements are coming soon! Invalid connection link 无效的连接链接 - No comment provided by engineer. + conn error description Invalid display name! @@ -4522,7 +4923,15 @@ More improvements are coming soon! Invalid name! 无效名称! - No comment provided by engineer. + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title Invalid response @@ -4549,11 +4958,20 @@ More improvements are coming soon! 邀请朋友 No comment provided by engineer. + + Invite member + 邀请成员 + No comment provided by engineer. + Invite members 邀请成员 No comment provided by engineer. + + Invite someone privately + No comment provided by engineer. + Invite to chat 邀请加入聊天 @@ -4630,6 +5048,10 @@ More improvements are coming soon! 以 %@ 身份加入 No comment provided by engineer. + + Join channel + No comment provided by engineer. + Join group 加入群组 @@ -4679,6 +5101,7 @@ This is your link for group %@! Keep your chats clean + 保持聊天洁净 No comment provided by engineer. @@ -4716,6 +5139,14 @@ This is your link for group %@! 离开 swipe action + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + Leave chat 离开聊天 @@ -4738,6 +5169,11 @@ This is your link for group %@! Less traffic on mobile networks. + 消耗更少的移动网络数据。 + No comment provided by engineer. + + + Let someone connect to you No comment provided by engineer. @@ -4760,6 +5196,10 @@ This is your link for group %@! 连接移动端和桌面端应用程序!🔗 No comment provided by engineer. + + Link signature verified. + owner verification + Linked desktop options 已链接桌面选项 @@ -4770,6 +5210,11 @@ This is your link for group %@! 已链接桌面 No comment provided by engineer. + + Links + 链接 + No comment provided by engineer. + List 列表 @@ -4797,6 +5242,7 @@ This is your link for group %@! Loading profile… + 正加载个人资料… in progress text @@ -4876,10 +5322,12 @@ This is your link for group %@! Member %@ + 成员 %@ past/unknown group member Member admission + 成员准入 No comment provided by engineer. @@ -4889,8 +5337,13 @@ This is your link for group %@! Member is deleted - can't accept request + 成员被删除——无法接受请求 No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports 成员举报 @@ -4914,15 +5367,16 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! 将从聊天中删除成员 - 此操作无法撤销! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! 成员将被移出群组——此操作无法撤消! - No comment provided by engineer. + alert message Member will join the group, accept member? + 成员将加入本群,接受成员吗? alert message @@ -4930,6 +5384,10 @@ This is your link for group %@! 群组成员可以添加信息回应。 No comment provided by engineer. + + Members can chat with admins. + No comment provided by engineer. + Members can irreversibly delete sent messages. (24 hours) 群组成员可以不可撤回地删除已发送的消息 @@ -4995,6 +5453,10 @@ This is your link for group %@! 消息草稿 No comment provided by engineer. + + Message error + No comment provided by engineer. + Message forwarded 消息已转发 @@ -5002,6 +5464,7 @@ This is your link for group %@! Message instantly once you tap Connect. + 轻按连接后即刻发消息。 No comment provided by engineer. @@ -5081,6 +5544,7 @@ This is your link for group %@! Messages are protected by **end-to-end encryption**. + 消息已通过**端到端加密**保护。 No comment provided by engineer. @@ -5088,6 +5552,14 @@ This is your link for group %@! 将显示来自 %@ 的消息! No comment provided by engineer. + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + Messages in this chat will never be deleted. 此聊天中的消息永远不会被删除。 @@ -5118,16 +5590,15 @@ This is your link for group %@! 消息、文件和通话受到 **抗量子 e2e 加密** 的保护,具有完全正向保密、否认和闯入恢复。 No comment provided by engineer. + + Migrate + No comment provided by engineer. + Migrate device 迁移设备 No comment provided by engineer. - - Migrate from another device - 从另一台设备迁移 - No comment provided by engineer. - Migrate here 迁移到此处 @@ -5248,6 +5719,10 @@ This is your link for group %@! 网络和服务器 No comment provided by engineer. + + Network commitments + No comment provided by engineer. + Network connection 网络连接 @@ -5258,6 +5733,10 @@ This is your link for group %@! 网络去中心化 No comment provided by engineer. + + Network error + conn error description + Network issues - message expired after many attempts to send it. 网络问题 - 消息在多次尝试发送后过期。 @@ -5273,6 +5752,11 @@ This is your link for group %@! 网络运营方 No comment provided by engineer. + + Network routers cannot know +who talks to whom + No comment provided by engineer. + Network settings 网络设置 @@ -5281,13 +5765,17 @@ This is your link for group %@! Network status 网络状态 - No comment provided by engineer. + alert title New token status text + + New 1-time link + No comment provided by engineer. + New Passcode 新密码 @@ -5313,6 +5801,10 @@ This is your link for group %@! 新的聊天体验 🎉 No comment provided by engineer. + + New chat relay + No comment provided by engineer. + New contact request 新联系人请求 @@ -5340,6 +5832,7 @@ This is your link for group %@! New group role: Moderator + 新的群角色:协管 No comment provided by engineer. @@ -5359,6 +5852,7 @@ This is your link for group %@! New member wants to join the group. + 新成员要加入本群。 rcv group event chat item @@ -5381,11 +5875,28 @@ This is your link for group %@! No comment provided by engineer. + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + No app password 没有应用程序密码 Authentication unavailable + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + No chats 无聊天 @@ -5403,6 +5914,7 @@ This is your link for group %@! No chats with members + 没有和成员的聊天 No comment provided by engineer. @@ -5487,6 +5999,7 @@ This is your link for group %@! No private routing session + 无私密路由会话 alert title @@ -5529,11 +6042,24 @@ This is your link for group %@! 没有未读聊天 No comment provided by engineer. - - No user identifiers. - 没有用户标识符。 + + Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. + 没有人追踪你的谈话内容。没有人绘制你去过的地方的地图。隐私从来都不是一项功能--而是一种生活方式。 No comment provided by engineer. + + Non-profit governance + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + 别人家的门锁再好也比不上这里。房东再好也比不上这里,他既尊重你的隐私,又保留着所有访客的记录。你不是客人,你是家。没有国王能闯入--你是主人。 + No comment provided by engineer. + + + Not all relays connected + alert title + Not compatible! 不兼容! @@ -5591,7 +6117,7 @@ This is your link for group %@! OK 好的 - No comment provided by engineer. + alert button Off @@ -5610,11 +6136,19 @@ new chat action 旧的数据库 No comment provided by engineer. + + On your phone, not on servers. + No comment provided by engineer. + One-time invitation link 一次性邀请链接 No comment provided by engineer. + + One-time link + chat link info line + Onion hosts will be **required** for connection. Requires compatible VPN. @@ -5634,6 +6168,10 @@ Requires compatible VPN. 将不会使用 Onion 主机。 No comment provided by engineer. + + Only channel owners can change channel preferences. + No comment provided by engineer. + Only chat owners can change preferences. 仅聊天所有人可更改首选项。 @@ -5696,6 +6234,7 @@ Requires compatible VPN. Only you can send files and media. + 只有你可以发送文件和媒体。 No comment provided by engineer. @@ -5725,6 +6264,7 @@ Requires compatible VPN. Only your contact can send files and media. + 只有你的联系人可以发送文件和媒体。 No comment provided by engineer. @@ -5735,7 +6275,8 @@ Requires compatible VPN. Open 打开 - alert action + alert action +alert button Open Settings @@ -5747,6 +6288,10 @@ Requires compatible VPN. 打开更改 No comment provided by engineer. + + Open channel + new chat action + Open chat 打开聊天 @@ -5759,6 +6304,7 @@ Requires compatible VPN. Open clean link + 打开干净链接 alert action @@ -5766,8 +6312,13 @@ Requires compatible VPN. 打开条款 No comment provided by engineer. + + Open external link? + alert title + Open full link + 打开完整链接 alert action @@ -5777,6 +6328,7 @@ Requires compatible VPN. Open link? + 打开链接? alert title @@ -5784,28 +6336,38 @@ Requires compatible VPN. 打开迁移到另一台设备 authentication reason + + Open new channel + new chat action + Open new chat + 打开新聊天 new chat action Open new group + 打开新群 new chat action Open to accept + 打开以接受 No comment provided by engineer. Open to connect + 打开以连接 No comment provided by engineer. Open to join + 打开以加入 No comment provided by engineer. Open to use bot + 打开来使用机器人 No comment provided by engineer. @@ -5823,6 +6385,13 @@ Requires compatible VPN. 运营方服务器 alert title + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + Or import archive file 或者导入或者导入压缩文件 @@ -5843,6 +6412,10 @@ Requires compatible VPN. 或安全地分享此文件链接 No comment provided by engineer. + + Or show QR in person or via video call. + No comment provided by engineer. + Or show this code 或者显示此码 @@ -5853,6 +6426,10 @@ Requires compatible VPN. 或者私下分享 No comment provided by engineer. + + Or use this QR - print or show online. + No comment provided by engineer. + Organize chats into lists 将聊天组织到列表 @@ -5866,8 +6443,22 @@ Requires compatible VPN. Other file errors: %@ + 其他文件错误: +%@ alert message + + Owner + No comment provided by engineer. + + + Owners + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + PING count PING 次数 @@ -5923,6 +6514,10 @@ Requires compatible VPN. 粘贴图片 No comment provided by engineer. + + Paste link / Scan + No comment provided by engineer. + Paste link to connect! 粘贴链接以连接! @@ -6044,18 +6639,22 @@ Error: %@ Please try to disable and re-enable notfications. + 请尝试禁用并重新启用通知。 token info Please wait for group moderators to review your request to join the group. + 请等待群的协管审核你加入该群的请求。 snd group event chat item Please wait for token activation to complete. + 请等待token激活完成。 token info Please wait for token to be registered. + 请等待token注册完成。 token info @@ -6065,6 +6664,7 @@ Error: %@ Port + 端口 No comment provided by engineer. @@ -6072,6 +6672,14 @@ Error: %@ 保留最后的消息草稿及其附件。 No comment provided by engineer. + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + Preset server address 预设服务器地址 @@ -6079,6 +6687,7 @@ Error: %@ Preset servers + 预设服务器 No comment provided by engineer. @@ -6098,6 +6707,7 @@ Error: %@ Privacy for your customers. + 客户隐私。 No comment provided by engineer. @@ -6105,14 +6715,12 @@ Error: %@ 隐私政策和使用条款。 No comment provided by engineer. - - Privacy redefined - 重新定义隐私 + + Privacy: for owners and subscribers. No comment provided by engineer. - - Private chats, groups and your contacts are not accessible to server operators. - 服务器运营方无法访问私密聊天、群组和你的联系人。 + + Private and secure messaging. No comment provided by engineer. @@ -6122,6 +6730,7 @@ Error: %@ Private media file names. + 私密媒体文件名。 No comment provided by engineer. @@ -6151,8 +6760,13 @@ Error: %@ Private routing timeout + 私密路由超时 alert title + + Proceed + alert action + Profile and server connections 资料和服务器连接 @@ -6178,9 +6792,8 @@ Error: %@ 个人资料主题 No comment provided by engineer. - - Profile update will be sent to your contacts. - 个人资料更新将被发送给您的联系人。 + + Profile update will be sent to your SimpleX contacts. alert message @@ -6188,6 +6801,10 @@ Error: %@ 禁止音频/视频通话。 No comment provided by engineer. + + Prohibit chats with admins. + No comment provided by engineer. + Prohibit irreversible message deletion. 禁止不可撤回消息删除。 @@ -6205,6 +6822,7 @@ Error: %@ Prohibit reporting messages to moderators. + 禁止向 协管 举报消息。 No comment provided by engineer. @@ -6217,6 +6835,10 @@ Error: %@ 禁止向成员发送私信。 No comment provided by engineer. + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + Prohibit sending disappearing messages. 禁止发送限时消息。 @@ -6256,6 +6878,7 @@ Enable in *Network & servers* settings. Protocol background timeout + 协议后台超时 No comment provided by engineer. @@ -6280,6 +6903,11 @@ Enable in *Network & servers* settings. Proxy requires password + 代理需要密码 + No comment provided by engineer. + + + Public channels - speak freely 🚀 No comment provided by engineer. @@ -6322,24 +6950,14 @@ Enable in *Network & servers* settings. 阅读更多 No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode). - 阅读更多[User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)。 + + Read more in User Guide. + 阅读更多User Guide。 No comment provided by engineer. - - Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). - 在 [用户指南](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses) 中阅读更多内容。 - No comment provided by engineer. - - - Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends). - 在 [用户指南](https://simplex.chat/docs/guide/readme.html#connect-to-friends) 中阅读更多内容。 - No comment provided by engineer. - - - Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). - 在我们的 [GitHub 仓库](https://github.com/simplex-chat/simplex-chat#readme) 中阅读更多信息。 + + Read more in our GitHub repository. + 在我们的 GitHub 仓库 中阅读更多信息。 No comment provided by engineer. @@ -6362,11 +6980,6 @@ Enable in *Network & servers* settings. 已收到于:%@ copied message info - - Received file event - 收到文件项目 - notification - Received message 收到的信息 @@ -6469,6 +7082,7 @@ Enable in *Network & servers* settings. Register + 注册 No comment provided by engineer. @@ -6477,6 +7091,7 @@ Enable in *Network & servers* settings. Registered + 已注册 token status text @@ -6498,8 +7113,29 @@ swipe action Reject member? + 拒绝成员? alert title + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + Relay server is only used if necessary. Another party can observe your IP address. 中继服务器仅在必要时使用。其他人可能会观察到您的IP地址。 @@ -6510,13 +7146,27 @@ swipe action 中继服务器保护您的 IP 地址,但它可以观察通话的持续时间。 No comment provided by engineer. + + Relay test failed! + No comment provided by engineer. + + + Reliability: many relays per channel. + No comment provided by engineer. + Remove 移除 - No comment provided by engineer. + alert action + + + Remove and delete messages + 移除并删除消息 + alert action Remove archive? + 删除存档? No comment provided by engineer. @@ -6526,6 +7176,7 @@ swipe action Remove link tracking + 删除链接跟踪 No comment provided by engineer. @@ -6536,15 +7187,24 @@ swipe action Remove member? 删除成员吗? - No comment provided by engineer. + alert title Remove passphrase from keychain? 从钥匙串中删除密码? No comment provided by engineer. + + Remove subscriber + No comment provided by engineer. + + + Remove subscriber? + alert title + Removes messages and blocks members. + 删除消息并封禁成员。 No comment provided by engineer. @@ -6584,46 +7244,57 @@ swipe action Report + 举报 chat item action Report content: only group moderators will see it. + 举报内容:仅协管会看到。 report reason Report member profile: only group moderators will see it. + 举报成员个人资料:仅协管会看到。 report reason Report other: only group moderators will see it. + 举报其他:仅协管会看到。 report reason Report reason? + 举报理由? No comment provided by engineer. Report sent to moderators + 举报已发送至 协管 alert title Report spam: only group moderators will see it. + 举报垃圾信息:仅协管会看到。 report reason Report violation: only group moderators will see it. + 举报违规:仅协管会看到。 report reason Report: %@ + 举报: %@ report in notification Reporting messages to moderators is prohibited. + 向协管举报消息已被禁止。 No comment provided by engineer. Reports + 举报 No comment provided by engineer. @@ -6718,10 +7389,12 @@ swipe action Review group members + 审核群成员 No comment provided by engineer. Review members + 审核成员 admission stage @@ -6760,6 +7433,11 @@ swipe action SOCKS proxy + SOCKS代理 + No comment provided by engineer. + + + Safe web links No comment provided by engineer. @@ -6785,10 +7463,16 @@ chat item action Save (and notify members) + 保存(并通知成员) + alert button + + + Save (and notify subscribers) alert button Save admission settings? + 保存入群设置? alert title @@ -6801,6 +7485,10 @@ chat item action 保存并通知群组成员 No comment provided by engineer. + + Save and notify subscribers + No comment provided by engineer. + Save and reconnect 保存并重新连接 @@ -6811,6 +7499,14 @@ chat item action 保存和更新组配置文件 No comment provided by engineer. + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + Save group profile 保存群组资料 @@ -6818,6 +7514,7 @@ chat item action Save group profile? + 保存群资料? alert title @@ -6935,11 +7632,36 @@ chat item action 搜索栏接受邀请链接。 No comment provided by engineer. + + Search files + 搜索文件 + No comment provided by engineer. + + + Search images + 搜索图片 + No comment provided by engineer. + + + Search links + 搜索链接 + No comment provided by engineer. + Search or paste SimpleX link 搜索或粘贴 SimpleX 链接 No comment provided by engineer. + + Search videos + 搜索视频 + No comment provided by engineer. + + + Search voice messages + 搜索语音消息 + No comment provided by engineer. + Secondary 二级 @@ -6965,6 +7687,10 @@ chat item action 安全码 No comment provided by engineer. + + Security: owners hold channel keys. + No comment provided by engineer. + Select 选择 @@ -6972,6 +7698,7 @@ chat item action Select chat profile + 选择聊天个人资料 No comment provided by engineer. @@ -7016,6 +7743,7 @@ chat item action Send contact request? + 发送联络请求? No comment provided by engineer. @@ -7070,6 +7798,7 @@ chat item action Send private reports + 发送私下举报 No comment provided by engineer. @@ -7084,10 +7813,16 @@ chat item action Send request + 发送请求 No comment provided by engineer. Send request without message + 发送无消息请求 + No comment provided by engineer. + + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. No comment provided by engineer. @@ -7100,8 +7835,13 @@ chat item action 给新成员发送最多 100 条历史消息。 No comment provided by engineer. + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + Send your private feedback to groups. + 向群发送私密反馈。 No comment provided by engineer. @@ -7114,6 +7854,10 @@ chat item action 发送人可能已删除连接请求。 No comment provided by engineer. + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. 将对所有可见聊天配置文件中的所有联系人启用送达回执功能。 @@ -7169,11 +7913,6 @@ chat item action 直接发送 No comment provided by engineer. - - Sent file event - 已发送文件项目 - notification - Sent message 已发信息 @@ -7206,10 +7945,12 @@ chat item action Server + 服务器 No comment provided by engineer. Server added to operator %@. + 服务器已添加到运营方 %@。 alert message @@ -7229,16 +7970,23 @@ chat item action Server operator changed. + 服务器运营方已更改。 alert title Server operators + 服务器运营方 No comment provided by engineer. Server protocol changed. + 服务器协议已更改。 alert title + + Server requires authorization to connect to relay, check password. + relay test error + Server requires authorization to create queues, check password. 服务器需要授权才能创建队列,检查密码 @@ -7296,6 +8044,7 @@ chat item action Set chat name… + 设置聊天名称… No comment provided by engineer. @@ -7320,10 +8069,12 @@ chat item action Set member admission + 设置成员入群准许 No comment provided by engineer. Set message expiration in chats. + 在聊天中设置消息过期时间。 No comment provided by engineer. @@ -7343,6 +8094,7 @@ chat item action Set profile bio and welcome message. + 设置自我介绍和欢迎消息。 No comment provided by engineer. @@ -7362,8 +8114,17 @@ chat item action Settings were changed. + 设置已修改。 alert message + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + Shape profile images 改变个人资料图形状 @@ -7382,10 +8143,12 @@ chat item action Share 1-time link with a friend + 和一位好友分享一次性链接 No comment provided by engineer. Share SimpleX address on social media. + 在社媒上分享 SimpleX 地址。 No comment provided by engineer. @@ -7395,13 +8158,17 @@ chat item action Share address publicly + 公开分享地址 No comment provided by engineer. - - Share address with contacts? - 与联系人分享地址? + + Share address with SimpleX contacts? alert title + + Share channel + No comment provided by engineer. + Share from other apps. 从其他应用程序共享。 @@ -7414,14 +8181,21 @@ chat item action Share old address + 分享旧地址 alert button Share old link + 分享旧链接 alert button Share profile + 分享资料 + No comment provided by engineer. + + + Share relay address No comment provided by engineer. @@ -7434,25 +8208,32 @@ chat item action 分享到 SimpleX No comment provided by engineer. - - Share with contacts - 与联系人分享 + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts No comment provided by engineer. Share your address + 分享地址 No comment provided by engineer. Short SimpleX address + SimpleX 短地址 No comment provided by engineer. Short description + 短描述 No comment provided by engineer. Short link + 短链接 No comment provided by engineer. @@ -7605,8 +8386,8 @@ chat item action SimpleX 协议由 Trail of Bits 审阅。 No comment provided by engineer. - - SimpleX relay link + + SimpleX relay address simplex link type @@ -7662,6 +8443,8 @@ chat item action Some servers failed the test: %@ + 有服务器测试未通过: +%@ alert message @@ -7671,6 +8454,7 @@ chat item action Spam + 垃圾信息 blocking reason report reason @@ -7679,6 +8463,11 @@ report reason 方形、圆形、或两者之间的任意形状. No comment provided by engineer. + + Star on GitHub + 在 GitHub 上加星 + No comment provided by engineer. + Start chat 开始聊天 @@ -7778,6 +8567,63 @@ report reason 已订阅 No comment provided by engineer. + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + Subscription errors 订阅错误 @@ -7795,10 +8641,12 @@ report reason Switch audio and video during the call. + 通话期间切换音频和视频。 No comment provided by engineer. Switch chat profile for 1-time invitations. + 对一次性邀请切换聊天个人资料。 No comment provided by engineer. @@ -7818,6 +8666,7 @@ report reason TCP connection bg timeout + TCP 连接后台超时 No comment provided by engineer. @@ -7827,6 +8676,7 @@ report reason TCP port for messaging + 用于消息收发的 TCP 端口 No comment provided by engineer. @@ -7846,6 +8696,7 @@ report reason Tail + 尾部 No comment provided by engineer. @@ -7853,24 +8704,32 @@ report reason 拍照 No comment provided by engineer. + + Talk to someone + No comment provided by engineer. + Tap Connect to chat + 轻按连接进行聊天 No comment provided by engineer. Tap Connect to send request + 轻按连接来发送请求 No comment provided by engineer. Tap Connect to use bot + 轻按“连接”使用机器人 No comment provided by engineer. - - Tap Create SimpleX address in the menu to create it later. + + Tap Join channel No comment provided by engineer. Tap Join group + 轻按加入群 No comment provided by engineer. @@ -7898,6 +8757,10 @@ report reason 点击以加入隐身聊天 No comment provided by engineer. + + Tap to open + No comment provided by engineer. + Tap to paste link 轻按粘贴链接 @@ -7916,10 +8779,16 @@ report reason Test failed at step %@. 在步骤 %@ 上测试失败。 - server test failure + relay test failure +server test failure Test notifications + 测试通知 + No comment provided by engineer. + + + Test relay No comment provided by engineer. @@ -7961,6 +8830,7 @@ It can happen because of some bug or when the connection is compromised. The address will be short, and your profile will be shared via the address. + 地址不会长,将通过该简短地址分享个人资料。 alert message @@ -7970,6 +8840,11 @@ It can happen because of some bug or when the connection is compromised. The app protects your privacy by using different operators in each conversation. + 应用通过在每个对话中使用不同运营方保护你的隐私。 + No comment provided by engineer. + + + The app removed this message after %lld attempts to receive it. No comment provided by engineer. @@ -7987,8 +8862,13 @@ It can happen because of some bug or when the connection is compromised.您扫描的码不是 SimpleX 链接的二维码。 No comment provided by engineer. + + The connection reached the limit of undelivered messages + conn error description + The connection reached the limit of undelivered messages, your contact may be offline. + 连接达到了未送达消息上限,你的联系人可能处于离线状态。 No comment provided by engineer. @@ -8011,9 +8891,9 @@ It can happen because of some bug or when the connection is compromised.加密正在运行,不需要新的加密协议。这可能会导致连接错误! No comment provided by engineer. - - The future of messaging - 下一代私密通讯软件 + + The first network where you own +your contacts and groups. No comment provided by engineer. @@ -8023,6 +8903,7 @@ It can happen because of some bug or when the connection is compromised. The link will be short, and group profile will be shared via the link. + 链接不会长,群资料会通过短链接分享。 alert message @@ -8050,12 +8931,18 @@ It can happen because of some bug or when the connection is compromised.旧数据库在迁移过程中没有被移除,可以删除。 No comment provided by engineer. + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + 人类最古老的自由--与他人交谈而不被监视--建立在不会背叛它的基础设施之上。 + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. The second preset operator in the app! + 应用中的第二个预设运营方! No comment provided by engineer. @@ -8084,6 +8971,7 @@ It can happen because of some bug or when the connection is compromised. The uploaded database archive will be permanently removed from the servers. + 已上传的数据库归档将会从服务器中永久移除。 No comment provided by engineer. @@ -8091,8 +8979,19 @@ It can happen because of some bug or when the connection is compromised.主题 No comment provided by engineer. + + Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible. + 然后我们转向线上,每个平台都要求你提供一些信息--你的姓名、电话号码、好友列表。我们接受了这样一个事实:与人交流的代价就是让别人知道我们在和谁交流。每一代人,每一代科技,都遵循着这样的模式--电话、电子邮件、即时通讯、社交媒体。这似乎是唯一可行的方式。 + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + 还有另一种方法。一个没有电话号码、没有用户名、没有账户、没有任何用户身份的网络。一个连接人们并传输加密信息的网络,而无需知道谁连接了。 + No comment provided by engineer. + These conditions will also apply for: **%@**. + 这些条件将同样适用于: **%@**。 No comment provided by engineer. @@ -8117,6 +9016,7 @@ It can happen because of some bug or when the connection is compromised. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + 此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。 alert message @@ -8154,8 +9054,17 @@ It can happen because of some bug or when the connection is compromised.该群组已不存在。 No comment provided by engineer. + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is your link for channel %@! + new chat action + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + 此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。 No comment provided by engineer. @@ -8165,6 +9074,7 @@ It can happen because of some bug or when the connection is compromised. This message was deleted or not received yet. + 此消息被删除或尚未收到。 No comment provided by engineer. @@ -8174,10 +9084,12 @@ It can happen because of some bug or when the connection is compromised. This setting is for your current profile **%@**. + 此设置用于当前个人资料 **%@**。 No comment provided by engineer. Time to disappear is set only for new contacts. + 只为新联系人设置了消失时间。 No comment provided by engineer. @@ -8200,6 +9112,10 @@ It can happen because of some bug or when the connection is compromised.隐藏不需要的信息。 No comment provided by engineer. + + To make SimpleX Network last. + No comment provided by engineer. + To make a new connection 建立新连接 @@ -8207,6 +9123,7 @@ It can happen because of some bug or when the connection is compromised. To protect against your link being replaced, you can compare contact security codes. + 为了防止链接被替换,你可以比较联系人安全代码。 No comment provided by engineer. @@ -8233,14 +9150,17 @@ You will be prompted to complete authentication before this feature is enabled.< To receive + 消息接收 No comment provided by engineer. To record speech please grant permission to use Microphone. + 为了记录语音请授予使用麦克风权限。 No comment provided by engineer. To record video please grant permission to use Camera. + 为了录制视频请授予使用相机权限。 No comment provided by engineer. @@ -8255,10 +9175,12 @@ You will be prompted to complete authentication before this feature is enabled.< To send + 发送 No comment provided by engineer. To send commands you must be connected. + 你必须已连接才能发送命令。 alert message @@ -8268,10 +9190,12 @@ You will be prompted to complete authentication before this feature is enabled.< To use another profile after connection attempt, delete the chat and use the link again. + 要在连接尝试后使用不同的个人资料,请删除聊天并再次使用该链接。 alert message To use the servers of **%@**, accept conditions of use. + 要使用**%@**的服务器,需接受条款。 No comment provided by engineer. @@ -8279,11 +9203,6 @@ You will be prompted to complete authentication before this feature is enabled.< 要与您的联系人验证端到端加密,请比较(或扫描)您设备上的代码。 No comment provided by engineer. - - Toggle chat list: - 切换聊天列表: - No comment provided by engineer. - Toggle incognito when connecting. 在连接时切换隐身模式。 @@ -8298,6 +9217,10 @@ You will be prompted to complete authentication before this feature is enabled.< 工具栏不透明度 No comment provided by engineer. + + Top bar + No comment provided by engineer. + Total 共计 @@ -8313,15 +9236,10 @@ You will be prompted to complete authentication before this feature is enabled.< 传输会话 No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - 正在尝试连接到用于从该联系人接收消息的服务器(错误:%@)。 - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - 正在尝试连接到用于从该联系人接收消息的服务器。 - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + 尝试连接到用于从该连接接收消息的服务器。 + subscription status explanation Turkish interface @@ -8368,8 +9286,13 @@ You will be prompted to complete authentication before this feature is enabled.< 解封成员吗? No comment provided by engineer. + + Unblock subscriber for all? + No comment provided by engineer. + Undelivered messages + 未送达的消息 No comment provided by engineer. @@ -8466,13 +9389,18 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link - No comment provided by engineer. + 不支持的连接链接 + conn error description Up to 100 last messages are sent to new members. 给新成员发送了最多 100 条历史消息。 No comment provided by engineer. + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + Update 更新 @@ -8495,6 +9423,7 @@ To connect, please ask your contact to create another connection link and check Updated conditions + 条款已更新 No comment provided by engineer. @@ -8504,14 +9433,17 @@ To connect, please ask your contact to create another connection link and check Upgrade + 升级 alert button Upgrade address + 升级地址 No comment provided by engineer. Upgrade address? + 升级地址? alert message @@ -8521,14 +9453,17 @@ To connect, please ask your contact to create another connection link and check Upgrade group link? + 升级群链接? alert message Upgrade link + 升级链接 No comment provided by engineer. Upgrade your address + 升级你的地址 No comment provided by engineer. @@ -8563,6 +9498,7 @@ To connect, please ask your contact to create another connection link and check Use %@ + 使用 %@ No comment provided by engineer. @@ -8572,6 +9508,7 @@ To connect, please ask your contact to create another connection link and check Use SOCKS proxy + 使用 SOCKS 代理 No comment provided by engineer. @@ -8581,15 +9518,12 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. + 当未指定端口时使用TCP端口%@。 No comment provided by engineer. Use TCP port 443 for preset servers only. - No comment provided by engineer. - - - Use chat - 使用聊天 + 仅预设服务器使用 TCP 协议 443 端口。 No comment provided by engineer. @@ -8599,10 +9533,16 @@ To connect, please ask your contact to create another connection link and check Use for files + 用于文件 No comment provided by engineer. Use for messages + 用于消息 + No comment provided by engineer. + + + Use for new channels No comment provided by engineer. @@ -8622,6 +9562,7 @@ To connect, please ask your contact to create another connection link and check Use incognito profile + 使用隐身个人资料 No comment provided by engineer. @@ -8644,6 +9585,10 @@ To connect, please ask your contact to create another connection link and check 对未知服务器使用私有路由。 No comment provided by engineer. + + Use relay + No comment provided by engineer. + Use server 使用服务器 @@ -8651,6 +9596,7 @@ To connect, please ask your contact to create another connection link and check Use servers + 使用服务器 No comment provided by engineer. @@ -8663,8 +9609,13 @@ To connect, please ask your contact to create another connection link and check 用一只手使用应用程序。 No comment provided by engineer. + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + Use web port + 使用 web 端口 No comment provided by engineer. @@ -8674,6 +9625,7 @@ To connect, please ask your contact to create another connection link and check Username + 用户名 No comment provided by engineer. @@ -8681,6 +9633,10 @@ To connect, please ask your contact to create another connection link and check 使用 SimpleX Chat 服务器。 No comment provided by engineer. + + Verify + relay test step + Verify code with desktop 用桌面端验证代码 @@ -8741,6 +9697,11 @@ To connect, please ask your contact to create another connection link and check 视频将在您的联系人在线时收到,请稍等或稍后查看! No comment provided by engineer. + + Videos + 视频 + No comment provided by engineer. + Videos and files up to 1gb 最大 1gb 的视频和文件 @@ -8748,6 +9709,7 @@ To connect, please ask your contact to create another connection link and check View conditions + 查看条款 No comment provided by engineer. @@ -8757,6 +9719,7 @@ To connect, please ask your contact to create another connection link and check View updated conditions + 查看更新后的条款 No comment provided by engineer. @@ -8794,6 +9757,18 @@ To connect, please ask your contact to create another connection link and check 语音消息…… No comment provided by engineer. + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + Waiting for desktop... 正在等待桌面... @@ -8834,6 +9809,10 @@ To connect, please ask your contact to create another connection link and check 警告:您可能会丢失部分数据! No comment provided by engineer. + + We made connecting simpler for new users. + No comment provided by engineer. + WebRTC ICE servers WebRTC ICE 服务器 @@ -8856,6 +9835,7 @@ To connect, please ask your contact to create another connection link and check Welcome your contacts 👋 + 欢迎联系人👋 No comment provided by engineer. @@ -8875,6 +9855,7 @@ To connect, please ask your contact to create another connection link and check When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + 当启用了超过一个运营方时,没有一个运营方拥有了解谁和谁联络的元数据。 No comment provided by engineer. @@ -8882,6 +9863,10 @@ To connect, please ask your contact to create another connection link and check 当您与某人共享隐身聊天资料时,该资料将用于他们邀请您加入的群组。 No comment provided by engineer. + + Why SimpleX is built. + No comment provided by engineer. + WiFi WiFi @@ -8974,6 +9959,7 @@ To connect, please ask your contact to create another connection link and check You are already connected with %@. + 你已经与%@保持连接。 No comment provided by engineer. @@ -9008,16 +9994,21 @@ Repeat join request? 重复加入请求? new chat sheet title - - You are connected to the server used to receive messages from this contact. - 您已连接到用于接收该联系人消息的服务器。 - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + 你已连接到用于接收该连接消息的服务器。 + subscription status explanation You are invited to group 您被邀请加入群组 No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + 未连接到用于从该连接接收消息的服务器(无订阅)。 + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. 您未连接到这些服务器。私有路由用于向他们发送消息。 @@ -9035,6 +10026,7 @@ Repeat join request? You can configure servers via settings. + 你可以通过设置配置服务器。 No comment provided by engineer. @@ -9079,6 +10071,7 @@ Repeat join request? You can set connection name, to remember who the link was shared with. + 你可以设置连接名称,用来记住和谁分享了这个链接。 No comment provided by engineer. @@ -9086,6 +10079,10 @@ Repeat join request? 您可以通过设置来设置锁屏通知预览。 No comment provided by engineer. + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. 您可以共享链接或二维码——任何人都可以加入该群组。如果您稍后将其删除,您不会失去该组的成员。 @@ -9123,6 +10120,7 @@ Repeat join request? You can view your reports in Chat with admins. + 你可以在和管理员和聊天中查看你的举报。 alert message @@ -9130,16 +10128,21 @@ Repeat join request? 您无法发送消息! alert title + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + You could not be verified; please try again. 您的身份无法验证,请再试一次。 No comment provided by engineer. - - You decide who can connect. - 你决定谁可以连接。 - No comment provided by engineer. - You have already requested connection! Repeat connection request? @@ -9206,8 +10209,14 @@ Repeat connection request? You should receive notifications. token info + + You were born without an account + 你生来就没有账户。 + No comment provided by engineer. + You will be able to send messages **only after your request is accepted**. + **只有在你的请求被接受后**你才能发送消息。 No comment provided by engineer. @@ -9240,8 +10249,13 @@ Repeat connection request? 当静音配置文件处于活动状态时,您仍会收到来自静音配置文件的电话和通知。 No comment provided by engineer. + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + You will stop receiving messages from this chat. Chat history will be preserved. + 你将停止从这个聊天收到消息。聊天历史将被保留。 No comment provided by engineer. @@ -9276,6 +10290,7 @@ Repeat connection request? Your business contact + 你的企业联系人 No comment provided by engineer. @@ -9283,6 +10298,10 @@ Repeat connection request? 您的通话 No comment provided by engineer. + + Your channel + No comment provided by engineer. + Your chat database 您的聊天数据库 @@ -9295,6 +10314,7 @@ Repeat connection request? Your chat preferences + 你的聊天偏好设置 alert title @@ -9312,6 +10332,7 @@ Repeat connection request? Your contact + 你的联系人 No comment provided by engineer. @@ -9329,8 +10350,14 @@ Repeat connection request? 与您的联系人保持连接。 No comment provided by engineer. + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + 你的对话内容始终属于你,就像互联网出现之前一样。网络不是一个你访问的地方,而是一个你创建并拥有的地方。无论你将其设为私密还是公开,任何人都无法将其夺走。 + No comment provided by engineer. + Your credentials may be sent unencrypted. + 你的凭据可能以未经加密的方式被发送。 No comment provided by engineer. @@ -9345,6 +10372,11 @@ Repeat connection request? Your group + 你的群 + No comment provided by engineer. + + + Your network No comment provided by engineer. @@ -9362,6 +10394,11 @@ Repeat connection request? 您的个人资料 No comment provided by engineer. + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + Your profile **%@** will be shared. 您的个人资料 **%@** 将被共享。 @@ -9379,13 +10416,26 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. + 您的个人资料已修改。如果进行保存,更新后的个人资料将发送到所有联系人。 alert message + + Your public address + No comment provided by engineer. + Your random profile 您的随机资料 No comment provided by engineer. + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + Your server address 您的服务器地址 @@ -9393,6 +10443,7 @@ Repeat connection request? Your servers + 你的服务器 No comment provided by engineer. @@ -9400,21 +10451,11 @@ Repeat connection request? 您的设置 No comment provided by engineer. - - [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [贡献](https://github.com/simplex-chat/simplex-chat#contribute) - No comment provided by engineer. - [Send us email](mailto:chat@simplex.chat) [给我们发电邮](mailto:chat@simplex.chat) No comment provided by engineer. - - [Star on GitHub](https://github.com/simplex-chat/simplex-chat) - [在 GitHub 上加星](https://github.com/simplex-chat/simplex-chat) - No comment provided by engineer. - \_italic_ \_斜体_ @@ -9430,6 +10471,10 @@ Repeat connection request? 上面,然后选择: No comment provided by engineer. + + accepted + No comment provided by engineer. + accepted %@ rcv group event chat item @@ -9441,12 +10486,18 @@ Repeat connection request? accepted invitation + 已接受邀请 chat list item title accepted you + 接受了你 rcv group event chat item + + active + No comment provided by engineer. + admin 管理员 @@ -9469,6 +10520,7 @@ Repeat connection request? all + 全部 member criteria value @@ -9488,6 +10540,7 @@ Repeat connection request? archived report + 已存档的举报 No comment provided by engineer. @@ -9556,8 +10609,13 @@ marked deleted chat item preview text 呼叫中…… call status + + can't broadcast + No comment provided by engineer. + can't send messages + 无法发送消息 No comment provided by engineer. @@ -9590,6 +10648,14 @@ marked deleted chat item preview text 更改地址… chat item text + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + colored 彩色 @@ -9662,10 +10728,12 @@ marked deleted chat item preview text contact deleted + 删除了联系人 No comment provided by engineer. contact disabled + 禁用了联系人 No comment provided by engineer. @@ -9680,10 +10748,12 @@ marked deleted chat item preview text contact not ready + 联系人未就绪 No comment provided by engineer. contact should accept… + 联系人应当接受… No comment provided by engineer. @@ -9732,6 +10802,10 @@ pref value 已删除 deleted chat item + + deleted channel + rcv group event chat item + deleted contact 已删除联系人 @@ -9842,11 +10916,19 @@ pref value 错误 No comment provided by engineer. + + error: %@ + receive error chat item + expired 过期 No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded 已转发 @@ -9854,6 +10936,7 @@ pref value group + shown on group welcome message @@ -9863,6 +10946,7 @@ pref value group is deleted + 群被删除了 No comment provided by engineer. @@ -9965,6 +11049,10 @@ pref value 已离开 rcv group event chat item + + link + No comment provided by engineer. + marked deleted 标记为已删除 @@ -9987,6 +11075,7 @@ pref value member has old version + 成员有旧版本 No comment provided by engineer. @@ -10021,6 +11110,7 @@ pref value moderator + 协管 member role @@ -10033,6 +11123,10 @@ pref value 从不 delete after time + + new + No comment provided by engineer. + new message 新消息 @@ -10048,6 +11142,11 @@ pref value 无端到端加密 No comment provided by engineer. + + no subscription + 无订阅 + No comment provided by engineer. + no text 无文本 @@ -10055,6 +11154,7 @@ pref value not synchronized + 未同步 No comment provided by engineer. @@ -10116,10 +11216,12 @@ time to disappear pending approval + 待批准 No comment provided by engineer. pending review + 待审核 No comment provided by engineer. @@ -10139,6 +11241,7 @@ time to disappear rejected + 被拒绝 No comment provided by engineer. @@ -10146,6 +11249,10 @@ time to disappear 拒接来电 call status + + relay + member role + removed 已删除 @@ -10156,6 +11263,14 @@ time to disappear 已删除 %@ rcv group event chat item + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + removed contact address 删除了联系地址 @@ -10163,6 +11278,7 @@ time to disappear removed from group + 从群被删除了 No comment provided by engineer. @@ -10177,30 +11293,37 @@ time to disappear request is sent + 发送了请求 No comment provided by engineer. request to join rejected + 加入请求被拒绝 No comment provided by engineer. requested connection + 已请求连接 rcv group event chat item requested connection from group %@ + 来自群组%@的已请求连接 rcv direct event chat item requested to connect + 被请求连接 chat list item title review + 审核 No comment provided by engineer. reviewed by admins + 由管理员审核 No comment provided by engineer. @@ -10302,6 +11425,10 @@ last received msg: %2$@ 未受保护 No comment provided by engineer. + + updated channel profile + rcv group event chat item + updated group profile 已更新的群组资料 @@ -10322,6 +11449,10 @@ last received msg: %2$@ v%@ (%@) No comment provided by engineer. + + via %@ + relay hostname + via contact address link 通过联系地址链接 @@ -10389,6 +11520,7 @@ last received msg: %2$@ you accepted this member + 你接受了该成员 snd group event chat item @@ -10396,6 +11528,10 @@ last received msg: %2$@ 您是观察者 No comment provided by engineer. + + you are subscriber + No comment provided by engineer. + you blocked %@ 你阻止了%@ @@ -10456,6 +11592,10 @@ last received msg: %2$@ \~删去~ No comment provided by engineer. + + ⚠️ Signature verification failed: %@. + owner verification + @@ -10524,22 +11664,27 @@ last received msg: %2$@ %d new events + %d条新事件 notification body From %d chat(s) + 来自 %d 条聊天 notification body From: %@ + 来自: %@ notification body New events + 新事件 notification New messages + 新消息 notification diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index efed487739..25df063f82 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import UserNotifications import OSLog @@ -22,6 +23,7 @@ let nseSuspendSchedule: SuspendSchedule = (2, 4) let fastNSESuspendSchedule: SuspendSchedule = (1, 1) +// Spec: spec/services/notifications.md#NSENotificationData public enum NSENotificationData { case connectionEvent(_ user: User, _ connEntity: ConnectionEntity) case contactConnected(_ user: any UserLike, _ contact: Contact) @@ -76,6 +78,7 @@ public enum NSENotificationData { // Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid // background crashes and contention for database with the application (both UI and background fetch triggered either on schedule // or when background notification is received. +// Spec: spec/services/notifications.md#NSEThreads class NSEThreads { static let shared = NSEThreads() private let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") @@ -238,6 +241,7 @@ class NSEThreads { // NotificationEntities for the same connection across multiple NSE instances (NSEThreads) are processed sequentially, so that the earliest NSE instance receives the earliest messages. // The reason for this complexity is to process all required messages within allotted 30 seconds, // accounting for the possibility that multiple notifications may be delivered concurrently. +// Spec: spec/services/notifications.md#NotificationEntity struct NotificationEntity { var ntfConn: NtfConn var entityId: ChatId @@ -279,6 +283,7 @@ struct NotificationEntity { // Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never // more than one process of notification service extension exists at a time. // Soon after notification service delivers the last notification it is either suspended or terminated. +// Spec: spec/services/notifications.md#NotificationService class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? // served as notification if no message attempts (msgBestAttemptNtf) could be produced @@ -291,6 +296,7 @@ class NotificationService: UNNotificationServiceExtension { var appSubscriber: AppSubscriber? var returnedSuspension = false + // Spec: spec/services/notifications.md#didReceive override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") let receivedNtf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } @@ -594,6 +600,7 @@ class NotificationService: UNNotificationServiceExtension { serviceBestAttemptNtf = ntf } + // Spec: spec/services/notifications.md#deliverBestAttemptNtf private func deliverBestAttemptNtf(urgent: Bool = false) { logger.debug("NotificationService.deliverBestAttemptNtf urgent: \(urgent) expectingMoreMessages: \(self.expectingMoreMessages)") if let handler = contentHandler, urgent || !expectingMoreMessages { @@ -770,6 +777,7 @@ class NotificationService: UNNotificationServiceExtension { } // nseStateGroupDefault must not be used in NSE directly, only via this singleton +// Spec: spec/services/notifications.md#NSEChatState class NSEChatState { static let shared = NSEChatState() private var value_ = NSEState.created @@ -824,6 +832,7 @@ var networkConfig: NetCfg = getNetCfg() // startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller // Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active + // Spec: spec/services/notifications.md#startChat-NSE func startChat() -> DBMigrationResult? { logger.debug("NotificationService: startChat") // only skip creating if there is chat controller @@ -848,6 +857,7 @@ func startChat() -> DBMigrationResult? { } } + // Spec: spec/services/notifications.md#doStartChat func doStartChat() -> DBMigrationResult? { logger.debug("NotificationService: doStartChat") haskell_init_nse() @@ -940,6 +950,7 @@ func chatSuspended() { // A single loop is used per Notification service extension process to receive and process all messages depending on the NSE state // If the extension is not active yet, or suspended/suspending, or the app is running, the notifications will not be received. + // Spec: spec/services/notifications.md#receiveMessages func receiveMessages() async { logger.debug("NotificationService receiveMessages") while true { @@ -988,6 +999,7 @@ private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } @inline(__always) + // Spec: spec/services/notifications.md#receivedMsgNtf func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? { logger.debug("NotificationService receivedMsgNtf: \(res.responseType)") switch res { @@ -1196,8 +1208,6 @@ func defaultBestAttemptNtf(_ ntfConn: NtfConn) -> NSENotificationData { groupInfo.chatSettings.enableNtfs == .all ? .connectionEvent(user, connEntity) : .noNtf - case .sndFileConnection: .noNtf - case .rcvFileConnection: .noNtf case let .userContactConnection(_, userContact): userContact.groupId == nil ? .connectionEvent(user, connEntity) diff --git a/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings index 6cc768efe1..2ea2a332d4 100644 --- a/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings +++ b/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings @@ -5,5 +5,5 @@ "CFBundleName" = "SimpleX NSE"; /* Copyright (human-readable) */ -"NSHumanReadableCopyright" = "Copyright © 2024 SimpleX Chat. All rights reserved."; +"NSHumanReadableCopyright" = "Copyright © 2025 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings index 3a577620a0..3da1eb8e9b 100644 --- a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings @@ -1,3 +1,15 @@ /* notification body */ -"New messages in %d chats" = "Nowe wiadomości w %d czatach"; +"%d new events" = "%d nowych wydarzeń"; + +/* notification body */ +"From %d chat(s)" = "Z %d czatu(ów)"; + +/* notification body */ +"From: %@" = "Od: %@"; + +/* notification */ +"New events" = "Nowe wydarzenia"; + +/* notification */ +"New messages" = "Nowe wiadomości"; diff --git a/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings b/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings index 5ef592ec70..4e4b130fa4 100644 --- a/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings @@ -1,7 +1,15 @@ -/* - Localizable.strings - SimpleX +/* notification body */ +"%d new events" = "%d条新事件"; + +/* notification body */ +"From %d chat(s)" = "来自 %d 条聊天"; + +/* notification body */ +"From: %@" = "来自: %@"; + +/* notification */ +"New events" = "新事件"; + +/* notification */ +"New messages" = "新消息"; - Created by EP on 30/07/2024. - Copyright © 2024 SimpleX Chat. All rights reserved. -*/ diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 6495d09b03..52c0405e5e 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -68,6 +68,7 @@ func apiSendMessages( type: chatInfo.chatType, id: chatInfo.apiId, scope: chatInfo.groupChatScope(), + sendAsGroup: chatInfo.sendAsGroup, live: false, ttl: nil, composedMessages: composedMessages @@ -124,7 +125,7 @@ enum SEChatCommand: ChatCmdProtocol { case apiSetEncryptLocalFiles(enable: Bool) case apiGetChats(userId: Int64) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) - case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) var cmdString: String { switch self { @@ -140,10 +141,11 @@ enum SEChatCommand: ChatCmdProtocol { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" - case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" } } diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index fd5c4c990f..18f3e2c344 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -465,8 +465,7 @@ fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil, retryNum: Int32 = 0) -> APIResult { if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl, retryNum: retryNum) { @@ -368,6 +369,15 @@ public struct UpMigration: Decodable, Equatable { // public var withDown: Bool } +public func downMigrationWarnings(_ downMigrations: [String]) -> [String] { + let warnings: [(String, String)] = [ + ("20260222_chat_relays", NSLocalizedString("If you joined or created channels, they will stop working permanently.", comment: "down migration warning")) + ] + return warnings.compactMap { (key, message) in + downMigrations.contains(key) ? message : nil + } +} + public enum MTRError: Decodable, Equatable { case noDown(dbMigrations: [String]) case different(appMigration: String, dbMigration: String) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 34ce1cf84f..5f1d8ef6c2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md import Foundation import SwiftUI @@ -22,6 +23,7 @@ public func onOff(_ b: Bool) -> String { b ? "on" : "off" } +// Spec: spec/api.md#APIResult public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { case result(R) case error(ChatError) @@ -59,6 +61,7 @@ public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { } } +// Spec: spec/api.md#ChatAPIResult public protocol ChatAPIResult: Decodable { var responseType: String { get } var details: String { get } @@ -79,6 +82,7 @@ extension ChatAPIResult { } } +// Spec: spec/api.md#decodeAPIResult public func decodeAPIResult(_ d: Data) -> APIResult { // print("decodeAPIResult \(String(describing: R.self))") do { @@ -545,6 +549,7 @@ public struct ConnectionStats: Decodable, Hashable { public var sndQueuesInfo: [SndQueueInfo] public var ratchetSyncState: RatchetSyncState public var ratchetSyncSupported: Bool + public var subStatus: SubscriptionStatus? public var ratchetSyncAllowed: Bool { ratchetSyncSupported && [.allowed, .required].contains(ratchetSyncState) @@ -559,25 +564,28 @@ public struct ConnectionStats: Decodable, Hashable { } } -public struct RcvQueueInfo: Codable, Hashable { +public struct RcvQueueInfo: Decodable, Hashable { public var rcvServer: String + public var status: QueueStatus public var rcvSwitchStatus: RcvSwitchStatus? public var canAbortSwitch: Bool + public var subStatus: SubscriptionStatus } -public enum RcvSwitchStatus: String, Codable, Hashable { +public enum RcvSwitchStatus: String, Decodable, Hashable { case switchStarted = "switch_started" case sendingQADD = "sending_qadd" case sendingQUSE = "sending_quse" case receivedMessage = "received_message" } -public struct SndQueueInfo: Codable, Hashable { +public struct SndQueueInfo: Decodable, Hashable { public var sndServer: String + public var status: QueueStatus public var sndSwitchStatus: SndSwitchStatus? } -public enum SndSwitchStatus: String, Codable, Hashable { +public enum SndSwitchStatus: String, Decodable, Hashable { case sendingQKEY = "sending_qkey" case sendingQTEST = "sending_qtest" } @@ -606,6 +614,48 @@ public enum RatchetSyncState: String, Decodable { case agreed } +public enum QueueStatus: String, Decodable, Hashable { + case new + case confirmed + case secured + case active + case disabled +} + +public enum SubscriptionStatus: Decodable, Hashable { + case active + case pending + case removed(subError: String) + case noSub + + public var statusString: LocalizedStringKey { + switch self { + case .active: "connected" + case .pending: "connecting" + case .removed: "error" + case .noSub: "no subscription" + } + } + + public var statusExplanation: String { + switch self { + case .active: NSLocalizedString("You are connected to the server used to receive messages from this connection.", comment: "subscription status explanation") + case .pending: NSLocalizedString("Trying to connect to the server used to receive messages from this connection.", comment: "subscription status explanation") + case let .removed(err): String.localizedStringWithFormat(NSLocalizedString("Error connecting to the server used to receive messages from this connection: %@", comment: "subscription status explanation"), err) + case .noSub: NSLocalizedString("You are not connected to the server used to receive messages from this connection (no subscription).", comment: "subscription status explanation") + } + } + + public var imageName: String { + switch self { + case .active: "circle.fill" + case .pending: "ellipsis.circle.fill" + case .removed: "exclamationmark.circle.fill" + case .noSub: "circle.dotted" + } + } +} + public protocol SelectableItem: Identifiable, Equatable { var label: LocalizedStringKey { get } static var values: [Self] { get } @@ -645,6 +695,7 @@ private func encodeCJSON(_ value: T) -> [CChar] { encodeJSON(value).cString(using: .utf8)! } +// Spec: spec/api.md#ChatError public enum ChatError: Decodable, Hashable, Error { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) @@ -667,6 +718,7 @@ public enum ChatError: Decodable, Hashable, Error { } } +// Spec: spec/api.md#ChatErrorType public enum ChatErrorType: Decodable, Hashable { case noActiveUser case noConnectionUser(agentConnId: String) @@ -675,6 +727,7 @@ public enum ChatErrorType: Decodable, Hashable { case userUnknown case activeUserExists case userExists + case chatRelayExists case invalidDisplayName case differentActiveUser(commandUserId: Int64, activeUserId: Int64) case cantDeleteActiveUser(userId: Int64) @@ -714,7 +767,6 @@ public enum ChatErrorType: Decodable, Hashable { case fileCancelled(message: String) case fileCancel(fileId: Int64, message: String) case fileAlreadyExists(filePath: String) - case fileRead(filePath: String, message: String) case fileWrite(filePath: String, message: String) case fileSend(fileId: Int64, agentError: String) case fileRcvChunk(message: String) @@ -743,6 +795,7 @@ public enum ChatErrorType: Decodable, Hashable { case connectionIncognitoChangeProhibited case connectionUserChangeProhibited case peerChatVRangeIncompatible + case relayTestError(message: String) case internalError(message: String) case exception(message: String) } @@ -750,6 +803,7 @@ public enum ChatErrorType: Decodable, Hashable { public enum StoreError: Decodable, Hashable { case duplicateName case userNotFound(userId: Int64) + case relayUserNotFound case userNotFoundByName(contactName: ContactName) case userNotFoundByContactId(contactId: Int64) case userNotFoundByGroupId(groupId: Int64) @@ -774,6 +828,7 @@ public enum StoreError: Decodable, Hashable { case memberContactGroupMemberNotFound(contactId: Int64) case groupWithoutUser case duplicateGroupMember + case duplicateMemberId case groupAlreadyJoined case groupInvitationNotFound case sndFileNotFound(fileId: Int64) @@ -808,6 +863,9 @@ public enum StoreError: Decodable, Hashable { case hostMemberIdNotFound(groupId: Int64) case contactNotFoundByFileId(fileId: Int64) case noGroupSndStatus(itemId: Int64, groupMemberId: Int64) + case userChatRelayNotFound(chatRelayId: Int64) + case groupRelayNotFound(groupRelayId: Int64) + case groupRelayNotFoundByMemberId(groupMemberId: Int64) case dBException(message: String) } @@ -834,6 +892,7 @@ public enum AgentErrorType: Decodable, Hashable { case RCP(rcpErr: RCErrorType) case BROKER(brokerAddress: String, brokerErr: BrokerErrorType) case AGENT(agentErr: SMPAgentError) + case NOTICE(server: String, preset: Bool, expiresAt: Date?) case INTERNAL(internalErr: String) case CRITICAL(offerRestart: Bool, criticalErr: String) case INACTIVE diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 77fff873ea..d8543735b0 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -26,7 +26,7 @@ public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer let GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED = "appLocalAuthEnabled" public let GROUP_DEFAULT_ALLOW_SHARE_EXTENSION = "allowShareExtension" // replaces DEFAULT_PRIVACY_LINK_PREVIEWS -let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" +public let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" public let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "privacyLinkPreviewsShowAlert" public let GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS = "privacySanitizeLinks" // This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES @@ -70,46 +70,48 @@ public let APP_GROUP_NAME = "group.chat.simplex.app" public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)! +public let groupAppDefaults: [String: Any] = [ + GROUP_DEFAULT_NTF_ENABLE_LOCAL: false, + GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false, + GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue, + GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue, + GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue, + GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue, + GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue, + GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpConnectTimeout.backgroundTimeout, + GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpConnectTimeout.interactiveTimeout, + GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpTimeout.backgroundTimeout, + GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpTimeout.interactiveTimeout, + GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb, + GROUP_DEFAULT_NETWORK_RCV_CONCURRENCY: NetCfg.defaults.rcvConcurrency, + GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval, + GROUP_DEFAULT_NETWORK_SMP_PING_COUNT: NetCfg.defaults.smpPingCount, + GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE: NetCfg.defaults.enableKeepAlive, + GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle, + GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl, + GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt, + GROUP_DEFAULT_INCOGNITO: false, + GROUP_DEFAULT_STORE_DB_PASSPHRASE: true, + GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false, + GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED: true, + GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false, + GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: true, + GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT: true, + GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: false, + GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true, + GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false, + GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true, + GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true, + GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, + GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false, + GROUP_DEFAULT_CALL_KIT_ENABLED: true, + GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false, + GROUP_DEFAULT_ONE_HAND_UI: true, + GROUP_DEFAULT_CHAT_BOTTOM_BAR: true +] + public func registerGroupDefaults() { - groupDefaults.register(defaults: [ - GROUP_DEFAULT_NTF_ENABLE_LOCAL: false, - GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false, - GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue, - GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue, - GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue, - GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue, - GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue, - GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpConnectTimeout.backgroundTimeout, - GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpConnectTimeout.interactiveTimeout, - GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpTimeout.backgroundTimeout, - GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpTimeout.interactiveTimeout, - GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb, - GROUP_DEFAULT_NETWORK_RCV_CONCURRENCY: NetCfg.defaults.rcvConcurrency, - GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval, - GROUP_DEFAULT_NETWORK_SMP_PING_COUNT: NetCfg.defaults.smpPingCount, - GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE: NetCfg.defaults.enableKeepAlive, - GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle, - GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl, - GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt, - GROUP_DEFAULT_INCOGNITO: false, - GROUP_DEFAULT_STORE_DB_PASSPHRASE: true, - GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false, - GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED: true, - GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false, - GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: true, - GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT: true, - GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: false, - GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true, - GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false, - GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true, - GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true, - GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, - GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false, - GROUP_DEFAULT_CALL_KIT_ENABLED: true, - GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false, - GROUP_DEFAULT_ONE_HAND_UI: true, - GROUP_DEFAULT_CHAT_BOTTOM_BAR: true - ]) + groupDefaults.register(defaults: groupAppDefaults) } public enum AppState: String, Codable { @@ -235,6 +237,8 @@ public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDef public let privacyAskToApproveRelaysGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS) +public let privacySanitizeLinksGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS) + public let profileImageCornerRadiusGroupDefault = Default(defaults: groupDefaults, forKey: GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT) diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index da1720c134..ece65130e6 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 05/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import Foundation import SwiftUI +// Spec: spec/services/calls.md#WebRTCCallOffer public struct WebRTCCallOffer: Encodable { public init(callType: CallType, rtcSession: WebRTCSession) { self.callType = callType @@ -19,6 +21,7 @@ public struct WebRTCCallOffer: Encodable { public var rtcSession: WebRTCSession } +// Spec: spec/services/calls.md#WebRTCSession public struct WebRTCSession: Codable { public init(rtcSession: String, rtcIceCandidates: String) { self.rtcSession = rtcSession @@ -29,6 +32,7 @@ public struct WebRTCSession: Codable { public var rtcIceCandidates: String } +// Spec: spec/services/calls.md#WebRTCExtraInfo public struct WebRTCExtraInfo: Codable { public init(rtcIceCandidates: String) { self.rtcIceCandidates = rtcIceCandidates @@ -37,6 +41,7 @@ public struct WebRTCExtraInfo: Codable { public var rtcIceCandidates: String } +// Spec: spec/services/calls.md#RcvCallInvitation public struct RcvCallInvitation: Decodable { public var user: User public var contact: Contact @@ -65,6 +70,7 @@ public struct RcvCallInvitation: Decodable { ) } +// Spec: spec/services/calls.md#CallType public struct CallType: Codable { public init(media: CallMediaType, capabilities: CallCapabilities) { self.media = media diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9695e8e911..594f90c4e4 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/state.md | spec/api.md import Foundation import SwiftUI @@ -42,6 +43,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var autoAcceptMemberContacts: Bool public var viewPwdHash: UserPwdHash? public var uiThemes: ThemeModeOverrides? + public var userChatRelay: Bool public var id: Int64 { userId } @@ -67,7 +69,8 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { showNtfs: true, sendRcptsContacts: true, sendRcptsSmallGroups: false, - autoAcceptMemberContacts: false + autoAcceptMemberContacts: false, + userChatRelay: false ) } @@ -864,6 +867,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case simplexLinks case reports case history + case support public var id: Self { self } @@ -885,10 +889,13 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .simplexLinks: true case .reports: false case .history: false + case .support: false } } - public var text: String { + public var text: String { text(isChannel: false) } + + public func text(isChannel: Bool) -> String { switch self { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") case .directMessages: return NSLocalizedString("Direct messages", comment: "chat feature") @@ -897,8 +904,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: return NSLocalizedString("Voice messages", comment: "chat feature") case .files: return NSLocalizedString("Files and media", comment: "chat feature") case .simplexLinks: return NSLocalizedString("SimpleX links", comment: "chat feature") - case .reports: return NSLocalizedString("Member reports", comment: "chat feature") + case .reports: return isChannel + ? NSLocalizedString("Subscriber reports", comment: "chat feature") + : NSLocalizedString("Member reports", comment: "chat feature") case .history: return NSLocalizedString("Visible history", comment: "chat feature") + case .support: return NSLocalizedString("Chat with admins", comment: "chat feature") } } @@ -913,6 +923,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .simplexLinks: return "link.circle" case .reports: return "flag" case .history: return "clock" + case .support: return "questionmark.circle" } } @@ -927,6 +938,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .simplexLinks: return "link.circle.fill" case .reports: return "flag.fill" case .history: return "clock.fill" + case .support: return "questionmark.circle.fill" } } @@ -937,7 +949,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { } } - public func enableDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool) -> LocalizedStringKey { + public func enableDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool, isChannel: Bool = false) -> LocalizedStringKey { if canEdit { switch self { case .timedMessages: @@ -947,8 +959,12 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { } case .directMessages: switch enabled { - case .on: return "Allow sending direct messages to members." - case .off: return "Prohibit sending direct messages to members." + case .on: return isChannel + ? "Allow sending direct messages to subscribers." + : "Allow sending direct messages to members." + case .off: return isChannel + ? "Prohibit sending direct messages to subscribers." + : "Prohibit sending direct messages to members." } case .fullDelete: switch enabled { @@ -982,56 +998,96 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { } case .history: switch enabled { - case .on: return "Send up to 100 last messages to new members." - case .off: return "Do not send history to new members." + case .on: return isChannel + ? "Send up to 100 last messages to new subscribers." + : "Send up to 100 last messages to new members." + case .off: return isChannel + ? "Do not send history to new subscribers." + : "Do not send history to new members." + } + case .support: + switch enabled { + case .on: return isChannel + ? "Allow subscribers to chat with admins." + : "Allow members to chat with admins." + case .off: return "Prohibit chats with admins." } } } else { switch self { case .timedMessages: switch enabled { - case .on: return "Members can send disappearing messages." + case .on: return isChannel + ? "Subscribers can send disappearing messages." + : "Members can send disappearing messages." case .off: return "Disappearing messages are prohibited." } case .directMessages: switch enabled { - case .on: return "Members can send direct messages." - case .off: return "Direct messages between members are prohibited." + case .on: return isChannel + ? "Subscribers can send direct messages." + : "Members can send direct messages." + case .off: return isChannel + ? "Direct messages between subscribers are prohibited." + : "Direct messages between members are prohibited." } case .fullDelete: switch enabled { - case .on: return "Members can irreversibly delete sent messages. (24 hours)" + case .on: return isChannel + ? "Subscribers can irreversibly delete sent messages. (24 hours)" + : "Members can irreversibly delete sent messages. (24 hours)" case .off: return "Irreversible message deletion is prohibited." } case .reactions: switch enabled { - case .on: return "Members can add message reactions." + case .on: return isChannel + ? "Subscribers can add message reactions." + : "Members can add message reactions." case .off: return "Message reactions are prohibited." } case .voice: switch enabled { - case .on: return "Members can send voice messages." + case .on: return isChannel + ? "Subscribers can send voice messages." + : "Members can send voice messages." case .off: return "Voice messages are prohibited." } case .files: switch enabled { - case .on: return "Members can send files and media." + case .on: return isChannel + ? "Subscribers can send files and media." + : "Members can send files and media." case .off: return "Files and media are prohibited." } case .simplexLinks: switch enabled { - case .on: return "Members can send SimpleX links." + case .on: return isChannel + ? "Subscribers can send SimpleX links." + : "Members can send SimpleX links." case .off: return "SimpleX links are prohibited." } case .reports: switch enabled { - case .on: return "Members can report messsages to moderators." + case .on: return isChannel + ? "Subscribers can report messsages to moderators." + : "Members can report messsages to moderators." case .off: return "Reporting messages to moderators is prohibited." } case .history: switch enabled { - case .on: return "Up to 100 last messages are sent to new members." - case .off: return "History is not sent to new members." + case .on: return isChannel + ? "Up to 100 last messages are sent to new subscribers." + : "Up to 100 last messages are sent to new members." + case .off: return isChannel + ? "History is not sent to new subscribers." + : "History is not sent to new members." + } + case .support: + switch enabled { + case .on: return isChannel + ? "Subscribers can chat with admins." + : "Members can chat with admins." + case .off: return "Chats with admins are prohibited." } } } @@ -1187,6 +1243,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { public var simplexLinks: RoleGroupPreference public var reports: GroupPreference public var history: GroupPreference + public var support: GroupPreference public var commands: [ChatBotCommand] public init( @@ -1199,6 +1256,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { simplexLinks: RoleGroupPreference, reports: GroupPreference, history: GroupPreference, + support: GroupPreference, commands: [ChatBotCommand] ) { self.timedMessages = timedMessages @@ -1210,6 +1268,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { self.simplexLinks = simplexLinks self.reports = reports self.history = history + self.support = support self.commands = commands } @@ -1223,6 +1282,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { simplexLinks: RoleGroupPreference(enable: .on, role: nil), reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on), + support: GroupPreference(enable: .on), commands: [] ) } @@ -1237,6 +1297,7 @@ public struct GroupPreferences: Codable, Hashable { public var simplexLinks: RoleGroupPreference? public var reports: GroupPreference? public var history: GroupPreference? + public var support: GroupPreference? public var commands: [ChatBotCommand]? public init( @@ -1249,6 +1310,7 @@ public struct GroupPreferences: Codable, Hashable { simplexLinks: RoleGroupPreference? = nil, reports: GroupPreference? = nil, history: GroupPreference? = nil, + support: GroupPreference? = nil, commands: [ChatBotCommand]? = nil ) { self.timedMessages = timedMessages @@ -1260,6 +1322,7 @@ public struct GroupPreferences: Codable, Hashable { self.simplexLinks = simplexLinks self.reports = reports self.history = history + self.support = support self.commands = commands } @@ -1273,6 +1336,7 @@ public struct GroupPreferences: Codable, Hashable { simplexLinks: RoleGroupPreference(enable: .on, role: nil), reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on), + support: GroupPreference(enable: .on), commands: nil ) } @@ -1367,6 +1431,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable { } } +// Spec: spec/state.md#ChatInfo public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) @@ -1559,8 +1624,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } - public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { - get { + public func userCantSendReason(allRelaysBroken: Bool = false) -> (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { switch self { case let .direct(contact): if contact.sendMsgToConnect { return nil } @@ -1574,8 +1638,11 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { if groupInfo.membership.memberActive { switch(groupChatScope) { case .none: + if allRelaysBroken && groupInfo.useRelays { return ("can't broadcast", nil) } if groupInfo.membership.memberPending { return ("reviewed by admins", "Please contact group admin.") } - if groupInfo.membership.memberRole == .observer { return ("you are observer", "Please contact group admin.") } + if groupInfo.membership.memberRole == .observer { + return groupInfo.useRelays ? ("you are subscriber", nil) : ("you are observer", "Please contact group admin.") + } return nil case let .some(.memberSupport(groupMember_: .some(supportMember))): if supportMember.versionRange.maxVersion < GROUP_KNOCKING_VERSION && !supportMember.memberPending { @@ -1607,10 +1674,9 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .invalidJSON: return ("can't send messages", nil) } - } } - public var sendMsgEnabled: Bool { userCantSendReason == nil } + public var sendMsgEnabled: Bool { userCantSendReason() == nil } public var incognito: Bool { get { @@ -1646,6 +1712,10 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var isChannel: Bool { + groupInfo?.useRelays == true + } + // this works for features that are common for contacts and groups public func featureEnabled(_ feature: ChatFeature) -> Bool { switch self { @@ -1751,6 +1821,18 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var sendAsGroup: Bool { + if let g = groupInfo, g.useRelays && g.membership.memberRole >= .owner { + switch groupChatScope() { + case .none: true + case .memberSupport: false + case .reports: false + } + } else { + false + } + } + public func ntfsEnabled(chatItem: ChatItem) -> Bool { ntfsEnabled(chatItem.meta.userMention) } @@ -1871,6 +1953,7 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } } +// Spec: spec/state.md#ChatStats public struct ChatStats: Decodable, Hashable { public init( unreadCount: Int = 0, @@ -2086,11 +2169,6 @@ public struct ContactRef: Decodable, Equatable, Hashable { public var id: ChatId { get { "@\(contactId)" } } } -public struct ContactSubStatus: Decodable, Hashable { - public var contact: Contact - public var contactError: ChatError? -} - public struct Connection: Decodable, Hashable { public var connId: Int64 public var agentConnId: String @@ -2115,6 +2193,11 @@ public struct Connection: Decodable, Hashable { public var id: ChatId { get { ":\(connId)" } } + public var connFailedErr: String? { + if case let .failed(err) = connStatus { return err } + return nil + } + public var connDisabled: Bool { authErrCounter >= 10 // authErrDisableCount in core } @@ -2300,15 +2383,16 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable { } } -public enum ConnStatus: String, Decodable, Hashable { - case new = "new" - case prepared = "prepared" - case joined = "joined" - case requested = "requested" - case accepted = "accepted" - case sndReady = "snd-ready" - case ready = "ready" - case deleted = "deleted" +public enum ConnStatus: Decodable, Hashable { + case new + case prepared + case joined + case requested + case accepted + case sndReady + case ready + case deleted + case failed(connError: String) var initiated: Bool? { get { @@ -2321,6 +2405,7 @@ public enum ConnStatus: String, Decodable, Hashable { case .sndReady: return nil case .ready: return nil case .deleted: return nil + case .failed: return nil } } } @@ -2338,6 +2423,8 @@ public struct Group: Decodable, Hashable { public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var groupId: Int64 + public var useRelays: Bool + public var relayOwnStatus: RelayStatus? = nil var localDisplayName: GroupName public var groupProfile: GroupProfile public var businessChat: BusinessChatInfo? @@ -2349,6 +2436,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { var chatTs: Date? public var preparedGroup: PreparedGroup? public var uiThemes: ThemeModeOverrides? + public var groupSummary: GroupSummary public var membersRequireAttention: Int public var id: ChatId { get { "#\(groupId)" } } @@ -2356,6 +2444,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var ready: Bool { get { true } } public var nextConnectPrepared: Bool { if let preparedGroup { !preparedGroup.connLinkStartedConnection } else { false } } public var profileChangeProhibited: Bool { preparedGroup?.connLinkPreparedConnection ?? false } + public var isChannel: Bool { groupProfile.isChannel } public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var shortDescr: String? { groupProfile.shortDescr } @@ -2381,15 +2470,20 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { } public var chatIconName: String { - switch businessChat?.chatType { - case .none: "person.2.circle.fill" - case .business: "briefcase.circle.fill" - case .customer: "person.crop.circle.fill" + if useRelays { + "antenna.radiowaves.left.and.right.circle.fill" + } else { + switch businessChat?.chatType { + case .none: "person.2.circle.fill" + case .business: "briefcase.circle.fill" + case .customer: "person.crop.circle.fill" + } } } public static let sampleData = GroupInfo( groupId: 1, + useRelays: false, localDisplayName: "team", groupProfile: GroupProfile.sampleData, fullGroupPreferences: FullGroupPreferences.sampleData, @@ -2397,6 +2491,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, + groupSummary: GroupSummary(currentMembers: 0), membersRequireAttention: 0, chatTags: [], localAlias: "" @@ -2414,6 +2509,34 @@ public struct GroupRef: Decodable, Hashable { var localDisplayName: GroupName } +public enum GroupType: Codable, Hashable { + case channel + case unknown(type: String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let type = try container.decode(String.self) + switch type { + case "channel": self = .channel + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .channel: try container.encode("channel") + case let .unknown(type): try container.encode(type) + } + } +} + +public struct PublicGroupProfile: Codable, Hashable { + public var groupType: GroupType + public var groupLink: String + public var publicGroupId: String +} + public struct GroupProfile: Codable, NamedChat, Hashable { public init( displayName: String, @@ -2421,6 +2544,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { shortDescr: String? = nil, description: String? = nil, image: String? = nil, + publicGroup: PublicGroupProfile? = nil, groupPreferences: GroupPreferences? = nil, memberAdmission: GroupMemberAdmission? = nil ) { @@ -2429,6 +2553,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { self.shortDescr = shortDescr self.description = description self.image = image + self.publicGroup = publicGroup self.groupPreferences = groupPreferences self.memberAdmission = memberAdmission } @@ -2438,6 +2563,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var shortDescr: String? public var description: String? public var image: String? + public var publicGroup: PublicGroupProfile? public var groupPreferences: GroupPreferences? public var memberAdmission: GroupMemberAdmission? public var localAlias: String { "" } @@ -2447,6 +2573,8 @@ public struct GroupProfile: Codable, NamedChat, Hashable { set { memberAdmission = newValue } } + public var isChannel: Bool { publicGroup?.groupType == .channel } + public static let sampleData = GroupProfile( displayName: "team", fullName: "My Team" @@ -2487,8 +2615,108 @@ public struct ContactShortLinkData: Codable, Hashable { public var business: Bool } +public struct GroupSummary: Decodable, Hashable { + public var currentMembers: Int64 + public var publicMemberCount: Int64? + + public init(currentMembers: Int64 = 0, publicMemberCount: Int64? = nil) { + self.currentMembers = currentMembers + self.publicMemberCount = publicMemberCount + } +} + +public struct PublicGroupData: Codable, Hashable { + public var publicMemberCount: Int64 +} + public struct GroupShortLinkData: Codable, Hashable { public var groupProfile: GroupProfile + public var publicGroupData: PublicGroupData? +} + +public enum RelayStatus: String, Decodable, Equatable, Hashable { + case new + case invited + case accepted + case active + case inactive + case rejected +} + +public struct RelayProfile: Codable, Equatable, Hashable { + public var displayName: String + public var fullName: String + public var shortDescr: String? + public var image: String? +} + +public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable { + public var chatRelayId: Int64? + public var address: String + public var relayProfile: RelayProfile + public var domains: [String] + public var preset: Bool + public var tested: Bool? + public var enabled: Bool + public var deleted: Bool + public var createdAt = Date() + + public var displayName: String { + get { relayProfile.displayName } + set { relayProfile.displayName = newValue } + } + + public init(chatRelayId: Int64? = nil, address: String, name: String, domains: [String], preset: Bool, tested: Bool? = nil, enabled: Bool, deleted: Bool, createdAt: Date = Date()) { + self.chatRelayId = chatRelayId + self.address = address + self.relayProfile = RelayProfile(displayName: name, fullName: "", shortDescr: nil, image: nil) + self.domains = domains + self.preset = preset + self.tested = tested + self.enabled = enabled + self.deleted = deleted + self.createdAt = createdAt + } + + public static func == (l: UserChatRelay, r: UserChatRelay) -> Bool { + l.chatRelayId == r.chatRelayId && l.address == r.address && l.relayProfile == r.relayProfile && l.domains == r.domains && + l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled && l.deleted == r.deleted + } + + public var id: String { "\(address) \(createdAt)" } + + public enum CodingKeys: CodingKey { + case chatRelayId + case address + case relayProfile + case domains + case preset + case tested + case enabled + case deleted + } +} + +public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { + public var groupRelayId: Int64 + public var groupMemberId: Int64 + public var userChatRelay: UserChatRelay + public var relayStatus: RelayStatus + public var relayLink: String? + public var id: Int64 { groupRelayId } +} + +extension RelayStatus { + public var text: LocalizedStringKey { + switch self { + case .new: "new" + case .invited: "invited" + case .accepted: "accepted" + case .active: "active" + case .inactive: "inactive" + case .rejected: "rejected" + } + } } public struct BusinessChatInfo: Decodable, Hashable { @@ -2519,6 +2747,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var activeConn: Connection? public var supportChat: GroupSupportChat? public var memberChatVRange: VersionRange + public var relayLink: String? public var id: String { "#\(groupId) @\(groupMemberId)" } public var ready: Bool { get { activeConn?.connStatus == .ready } } @@ -2640,19 +2869,18 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public func canBeRemoved(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole - return memberStatus != .memRemoved && memberStatus != .memLeft - && userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive + return userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive } public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { - if !canBeRemoved(groupInfo: groupInfo) || memberPending { return nil } + if memberRole == .relay || !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil } let userRole = groupInfo.membership.memberRole return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole - return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .moderator + return memberRole != .relay && memberRole < .moderator && userRole >= .moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -2723,6 +2951,7 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { + case relay case observer case author case member @@ -2736,6 +2965,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod public var text: String { switch self { + case .relay: return NSLocalizedString("relay", comment: "member role") case .observer: return NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") @@ -2747,12 +2977,13 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod private var comparisonValue: Int { switch self { - case .observer: 0 - case .author: 1 - case .member: 2 - case .moderator: 3 - case .admin: 4 - case .owner: 5 + case .relay: 0 + case .observer: 1 + case .author: 2 + case .member: 3 + case .moderator: 4 + case .admin: 5 + case .owner: 6 } } @@ -2866,16 +3097,9 @@ public enum InvitedBy: Decodable, Hashable { case unknown } -public struct MemberSubError: Decodable, Hashable { - var member: GroupMemberIds - var memberError: ChatError -} - public enum ConnectionEntity: Decodable, Hashable { case rcvDirectMsgConnection(entityConnection: Connection, contact: Contact?) case rcvGroupMsgConnection(entityConnection: Connection, groupInfo: GroupInfo, groupMember: GroupMember) - case sndFileConnection(entityConnection: Connection, sndFileTransfer: SndFileTransfer) - case rcvFileConnection(entityConnection: Connection, rcvFileTransfer: RcvFileTransfer) case userContactConnection(entityConnection: Connection, userContact: UserContact) public var id: String? { @@ -2886,8 +3110,6 @@ public enum ConnectionEntity: Decodable, Hashable { groupMember.id case let .userContactConnection(_, userContact): userContact.id - default: - nil } } @@ -2908,8 +3130,6 @@ public enum ConnectionEntity: Decodable, Hashable { switch self { case let .rcvDirectMsgConnection(entityConnection, _): entityConnection case let .rcvGroupMsgConnection(entityConnection, _, _): entityConnection - case let .sndFileConnection(entityConnection, _): entityConnection - case let .rcvFileConnection(entityConnection, _): entityConnection case let .userContactConnection(entityConnection, _): entityConnection } } @@ -3054,11 +3274,13 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var timestampText: Text { meta.timestampText } - public var text: String { - switch (content.text, content.msgContent, file) { + public var text: String { text(isChannel: false) } + + public func text(isChannel: Bool) -> String { + switch (content.text(isChannel: isChannel), content.msgContent, file) { case let ("", .some(.voice(_, duration)), _): return "Voice message (\(durationText(duration)))" case let ("", _, .some(file)): return file.fileName - default: return content.text + default: return content.text(isChannel: isChannel) } } @@ -3087,33 +3309,33 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } public var mergeCategory: CIMergeCategory? { - switch content { - case .rcvChatFeature: .chatFeature - case .sndChatFeature: .chatFeature - case .rcvGroupFeature: .chatFeature - case .sndGroupFeature: .chatFeature - case let.rcvGroupEvent(event): - switch event { - case .userRole: nil - case .userDeleted: nil - case .groupDeleted: nil - case .memberCreatedContact: nil - case .newMemberPendingReview: nil - default: .rcvGroupEvent - } - case let .sndGroupEvent(event): - switch event { - case .userRole: nil - case .userLeft: nil - case .memberAccepted: nil - case .userPendingReview: nil - default: .sndGroupEvent - } - default: - if meta.itemDeleted == nil { + if meta.itemDeleted != nil { + chatDir.sent ? .sndItemDeleted : .rcvItemDeleted + } else { + switch content { + case .rcvChatFeature: .chatFeature + case .sndChatFeature: .chatFeature + case .rcvGroupFeature: .chatFeature + case .sndGroupFeature: .chatFeature + case let.rcvGroupEvent(event): + switch event { + case .userRole: nil + case .userDeleted: nil + case .groupDeleted: nil + case .memberCreatedContact: nil + case .newMemberPendingReview: nil + default: .rcvGroupEvent + } + case let .sndGroupEvent(event): + switch event { + case .userRole: nil + case .userLeft: nil + case .memberAccepted: nil + case .userPendingReview: nil + default: .sndGroupEvent + } + default: nil - } else { - chatDir.sent ? .sndItemDeleted : .rcvItemDeleted } } } @@ -3128,6 +3350,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .rcvCall: return false // notification is shown on .callInvitation instead case .rcvIntegrityError: return false case .rcvDecryptionError: return false + case .rcvMsgError: return false case .rcvGroupInvitation: return true case .sndGroupInvitation: return false case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent): @@ -3231,6 +3454,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case let (.group(groupInfo, _), .groupSnd): let m = groupInfo.membership return m.memberRole >= .moderator ? (groupInfo, nil) : nil + case (.group, .channelRcv): + return nil default: return nil } } @@ -3451,6 +3676,7 @@ public enum CIDirection: Decodable, Hashable { case directRcv case groupSnd case groupRcv(groupMember: GroupMember) + case channelRcv case localSnd case localRcv @@ -3461,6 +3687,7 @@ public enum CIDirection: Decodable, Hashable { case .directRcv: return false case .groupSnd: return true case .groupRcv: return false + case .channelRcv: return false case .localSnd: return true case .localRcv: return false } @@ -3470,6 +3697,7 @@ public enum CIDirection: Decodable, Hashable { public func sameDirection(_ dir: CIDirection) -> Bool { switch (self, dir) { case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId + case (.channelRcv, .channelRcv): true default: sent == dir.sent } } @@ -3865,6 +4093,7 @@ public enum CIDeleteMode: String, Decodable, Hashable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" case cidmInternalMark = "internalMark" + case cidmHistory = "history" } protocol ItemContent { @@ -3880,6 +4109,7 @@ public enum CIContent: Decodable, ItemContent, Hashable { case rcvCall(status: CICallStatus, duration: Int) case rcvIntegrityError(msgError: MsgErrorType) case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32) + case rcvMsgError(rcvMsgError: RcvMsgError) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent) @@ -3905,42 +4135,43 @@ public enum CIContent: Decodable, ItemContent, Hashable { case chatBanner case invalidJSON(json: Data?) - public var text: String { - get { - switch self { - case let .sndMsgContent(mc): return mc.text - case let .rcvMsgContent(mc): return mc.text - case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case let .sndCall(status, duration): return status.text(duration) - case let .rcvCall(status, duration): return status.text(duration) - case let .rcvIntegrityError(msgError): return msgError.text - case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text - case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text - case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text - case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text - case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text - case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text - case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text - case let .sndConnEvent(sndConnEvent): return sndConnEvent.text - case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) - case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) - case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) - case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) - case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) - case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) - case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) - case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) - case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") - case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") - case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") - case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) - case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) - case .sndGroupE2EEInfo: return e2eeInfoNoPQStr - case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr - case .chatBanner: return "" - case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") - } + public var text: String { text(isChannel: false) } + + public func text(isChannel: Bool) -> String { + switch self { + case let .sndMsgContent(mc): return mc.text + case let .rcvMsgContent(mc): return mc.text + case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case let .sndCall(status, duration): return status.text(duration) + case let .rcvCall(status, duration): return status.text(duration) + case let .rcvIntegrityError(msgError): return msgError.text + case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text + case let .rcvMsgError(rcvMsgError): return rcvMsgError.text + case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text + case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text(isChannel: isChannel) + case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text(isChannel: isChannel) + case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text + case let .sndConnEvent(sndConnEvent): return sndConnEvent.text + case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) + case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) + case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) + case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) + case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) + case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) + case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") + case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") + case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") + case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case let .sndGroupE2EEInfo(e2eeInfo): return groupE2EEInfoStr(e2eeInfo) + case let .rcvGroupE2EEInfo(e2eeInfo): return groupE2EEInfoStr(e2eeInfo) + case .chatBanner: return "" + case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") } } @@ -3950,6 +4181,12 @@ public enum CIContent: Decodable, ItemContent, Hashable { : e2eeInfoNoPQStr } + private func groupE2EEInfoStr(_ e2eeInfo: E2EEInfo) -> String { + e2eeInfo.public == true + ? NSLocalizedString("Messages in this channel are not end-to-end encrypted. Chat relays can see these messages.", comment: "E2EE info chat item") + : e2eeInfoNoPQStr + } + private var e2eeInfoNoPQStr: String { NSLocalizedString("This chat is protected by end-to-end encryption.", comment: "E2EE info chat item") } @@ -4008,6 +4245,7 @@ public enum CIContent: Decodable, ItemContent, Hashable { case .rcvCall: return true case .rcvIntegrityError: return true case .rcvDecryptionError: return true + case .rcvMsgError: return true case .rcvGroupInvitation: return true case .rcvModerated: return true case .rcvBlocked: return true @@ -4061,6 +4299,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable { case .directRcv: return nil case .groupSnd: return membership?.displayName ?? "you" case let .groupRcv(member): return member.displayName + case .channelRcv: return nil case .localSnd: return "you" case .localRcv: return nil case nil: return nil @@ -4251,6 +4490,7 @@ public struct CIFile: Decodable, Hashable { } } +// Spec: spec/services/files.md#CryptoFile public struct CryptoFile: Codable, Hashable { public var filePath: String // the name of the file, not a full path public var cryptoArgs: CryptoFileArgs? @@ -4298,6 +4538,7 @@ public struct CryptoFile: Codable, Hashable { static var decryptedUrls = Dictionary() } +// Spec: spec/services/files.md#CryptoFileArgs public struct CryptoFileArgs: Codable, Hashable { public var fileKey: String public var fileNonce: String @@ -4434,10 +4675,14 @@ public enum MsgContent: Equatable, Hashable { case voice(text: String, duration: Int) case file(String) case report(text: String, reason: ReportReason) - case chat(text: String, chatLink: MsgChatLink) + case chat(text: String, chatLink: MsgChatLink, ownerSig: LinkOwnerSig?) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) + public var chatLinkStr: String? { + if case let .chat(_, chatLink, _) = self { chatLink.connLinkStr } else { nil } + } + public var text: String { switch self { case let .text(text): return text @@ -4447,7 +4692,7 @@ public enum MsgContent: Equatable, Hashable { case let .voice(text, _): return text case let .file(text): return text case let .report(text, _): return text - case let .chat(text, _): return text + case let .chat(text, _, _): return text case let .unknown(_, text): return text } } @@ -4510,6 +4755,7 @@ public enum MsgContent: Equatable, Hashable { case duration case reason case chatLink + case ownerSig } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -4521,7 +4767,7 @@ public enum MsgContent: Equatable, Hashable { case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd case let (.file(lf), .file(rf)): return lf == rf case let (.report(lt, lr), .report(rt, rr)): return lt == rt && lr == rr - case let (.chat(lt, ll), .chat(rt, rl)): return lt == rt && ll == rl + case let (.chat(lt, ll, ls), .chat(rt, rl, rs)): return lt == rt && ll == rl && ls == rs case let (.unknown(lType, lt), .unknown(rType, rt)): return lType == rType && lt == rt default: return false } @@ -4564,7 +4810,8 @@ extension MsgContent: Decodable { case "chat": let text = try container.decode(String.self, forKey: CodingKeys.text) let chatLink = try container.decode(MsgChatLink.self, forKey: CodingKeys.chatLink) - self = .chat(text: text, chatLink: chatLink) + let ownerSig = try container.decodeIfPresent(LinkOwnerSig.self, forKey: CodingKeys.ownerSig) + self = .chat(text: text, chatLink: chatLink, ownerSig: ownerSig) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -4606,10 +4853,11 @@ extension MsgContent: Encodable { try container.encode("report", forKey: .type) try container.encode(text, forKey: .text) try container.encode(reason, forKey: .reason) - case let .chat(text, chatLink): + case let .chat(text, chatLink, ownerSig): try container.encode("chat", forKey: .type) try container.encode(text, forKey: .text) try container.encode(chatLink, forKey: .chatLink) + try container.encodeIfPresent(ownerSig, forKey: .ownerSig) // TODO use original JSON and type case let .unknown(_, text): try container.encode("text", forKey: .type) @@ -4618,7 +4866,7 @@ extension MsgContent: Encodable { } } -public enum MsgContentTag: String { +public enum MsgContentTag: Codable, Hashable { case text case link case image @@ -4626,12 +4874,195 @@ public enum MsgContentTag: String { case voice case file case report + case chat + case unknown(type: String) + + public var rawValue: String { + switch self { + case .text: return "text" + case .link: return "link" + case .image: return "image" + case .video: return "video" + case .voice: return "voice" + case .file: return "file" + case .report: return "report" + case .chat: return "chat" + case let .unknown(type): return type + } + } + + public init(from decoder: Decoder) throws { + let s = try decoder.singleValueContainer().decode(String.self) + switch s { + case "text": self = .text + case "link": self = .link + case "image": self = .image + case "video": self = .video + case "voice": self = .voice + case "file": self = .file + case "report": self = .report + case "chat": self = .chat + case "liveText": self = .text + default: self = .unknown(type: s) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } -public enum MsgChatLink: Codable, Equatable, Hashable { +public enum MsgChatLink: Equatable, Hashable { case contact(connLink: String, profile: Profile, business: Bool) case invitation(invLink: String, profile: Profile) case group(connLink: String, groupProfile: GroupProfile) + + public var isPublicGroup: Bool { + if case let .group(_, gp) = self { gp.publicGroup != nil } else { false } + } + + public var connLinkStr: String { + switch self { + case let .group(connLink, _): connLink + case let .contact(connLink, _, _): connLink + case let .invitation(invLink, _): invLink + } + } + + public var image: String? { + switch self { + case let .group(_, groupProfile): groupProfile.image + case let .contact(_, profile, _): profile.image + case let .invitation(_, profile): profile.image + } + } + + public var displayName: String { + switch self { + case let .group(_, groupProfile): groupProfile.displayName + case let .contact(_, profile, _): profile.displayName + case let .invitation(_, profile): profile.displayName + } + } + + public var iconName: String { + switch self { + case let .group(_, groupProfile): + groupProfile.isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill" + case let .contact(_, _, business): + business ? "briefcase.circle.fill" : "person.crop.circle.fill" + case .invitation: + "person.crop.circle.fill" + } + } + + public var smallIconName: String { + switch self { + case let .group(_, groupProfile): + groupProfile.isChannel ? "antenna.radiowaves.left.and.right" : "person.2" + case let .contact(_, _, business): + business ? "briefcase" : "person" + case .invitation: + "person" + } + } + + public var fullName: String { + switch self { + case let .group(_, groupProfile): groupProfile.fullName + case let .contact(_, profile, _): profile.fullName + case let .invitation(_, profile): profile.fullName + } + } + + public var shortDescription: String? { + let s: String? = switch self { + case let .group(_, groupProfile): groupProfile.shortDescr + case let .contact(_, profile, _): profile.shortDescr + case let .invitation(_, profile): profile.shortDescr + } + if let d = s?.trimmingCharacters(in: .whitespacesAndNewlines), !d.isEmpty { return d } + return nil + } + + public func infoLine(signed: Bool) -> String { + var s: String = switch self { + case let .group(_, groupProfile): + groupProfile.isChannel + ? NSLocalizedString("Channel link", comment: "chat link info line") + : NSLocalizedString("Group link", comment: "chat link info line") + case let .contact(_, _, business): + business + ? NSLocalizedString("Business address", comment: "chat link info line") + : NSLocalizedString("Contact address", comment: "chat link info line") + case .invitation: + NSLocalizedString("One-time link", comment: "chat link info line") + } + if signed { + s += " " + ( + self.isPublicGroup + ? NSLocalizedString("(from owner)", comment: "chat link info line") + : NSLocalizedString("(signed)", comment: "chat link info line") + ) + } + return s + } +} + +extension MsgChatLink: Decodable { + private enum CodingKeys: String, CodingKey { + case type, connLink, invLink, profile, business, groupProfile + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "contact": + let connLink = try container.decode(String.self, forKey: .connLink) + let profile = try container.decode(Profile.self, forKey: .profile) + let business = try container.decode(Bool.self, forKey: .business) + self = .contact(connLink: connLink, profile: profile, business: business) + case "invitation": + let invLink = try container.decode(String.self, forKey: .invLink) + let profile = try container.decode(Profile.self, forKey: .profile) + self = .invitation(invLink: invLink, profile: profile) + case "group": + let connLink = try container.decode(String.self, forKey: .connLink) + let groupProfile = try container.decode(GroupProfile.self, forKey: .groupProfile) + self = .group(connLink: connLink, groupProfile: groupProfile) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown MsgChatLink type: \(type)") + } + } +} + +extension MsgChatLink: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .contact(connLink, profile, business): + try container.encode("contact", forKey: .type) + try container.encode(connLink, forKey: .connLink) + try container.encode(profile, forKey: .profile) + try container.encode(business, forKey: .business) + case let .invitation(invLink, profile): + try container.encode("invitation", forKey: .type) + try container.encode(invLink, forKey: .invLink) + try container.encode(profile, forKey: .profile) + case let .group(connLink, groupProfile): + try container.encode("group", forKey: .type) + try container.encode(connLink, forKey: .connLink) + try container.encode(groupProfile, forKey: .groupProfile) + } + } +} + +public struct LinkOwnerSig: Codable, Equatable, Hashable { + public var ownerId: String? + public var chatBinding: String + public var ownerSig: String } public struct FormattedText: Decodable, Hashable { @@ -4668,6 +5099,7 @@ public enum Format: Decodable, Equatable, Hashable { case strikeThrough case snippet case secret + case small case colored(color: FormatColor) case uri case hyperLink(showText: String?, linkUri: String) @@ -4701,7 +5133,7 @@ public enum SimplexLinkType: String, Decodable, Hashable { case .invitation: return NSLocalizedString("SimpleX one-time invitation", comment: "simplex link type") case .group: return NSLocalizedString("SimpleX group link", comment: "simplex link type") case .channel: return NSLocalizedString("SimpleX channel link", comment: "simplex link type") - case .relay: return NSLocalizedString("SimpleX relay link", comment: "simplex link type") + case .relay: return NSLocalizedString("SimpleX relay address", comment: "simplex link type") } } } @@ -4930,6 +5362,20 @@ public enum MsgErrorType: Decodable, Hashable { } } +public enum RcvMsgError: Decodable, Hashable { + case dropped(attempts: Int) + case parseError(parseError: String) + + var text: String { + switch self { + case let .dropped(attempts): + String.localizedStringWithFormat(NSLocalizedString("removed (%d attempts)", comment: "receive error chat item"), attempts) + case let .parseError(parseError): + String.localizedStringWithFormat(NSLocalizedString("error: %@", comment: "receive error chat item"), parseError) + } + } +} + public struct CIGroupInvitation: Decodable, Hashable { public var groupId: Int64 public var groupMemberId: Int64 @@ -4955,6 +5401,7 @@ public enum CIGroupInvitationStatus: String, Decodable, Hashable { public struct E2EEInfo: Decodable, Hashable { public var pqEnabled: Bool? + public var `public`: Bool? } public enum RcvDirectEvent: Decodable, Hashable { @@ -5007,7 +5454,9 @@ public enum RcvGroupEvent: Decodable, Hashable { case memberProfileUpdated(fromProfile: Profile, toProfile: Profile) case newMemberPendingReview - var text: String { + var text: String { text(isChannel: false) } + + func text(isChannel: Bool) -> String { switch self { case let .memberAdded(_, profile): return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName) @@ -5029,8 +5478,12 @@ public enum RcvGroupEvent: Decodable, Hashable { case let .memberDeleted(_, profile): return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName) case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item") - case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item") - case .groupUpdated: return NSLocalizedString("updated group profile", comment: "rcv group event chat item") + case .groupDeleted: return isChannel + ? NSLocalizedString("deleted channel", comment: "rcv group event chat item") + : NSLocalizedString("deleted group", comment: "rcv group event chat item") + case .groupUpdated: return isChannel + ? NSLocalizedString("updated channel profile", comment: "rcv group event chat item") + : NSLocalizedString("updated group profile", comment: "rcv group event chat item") case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item") case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item") case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) @@ -5062,7 +5515,9 @@ public enum SndGroupEvent: Decodable, Hashable { case memberAccepted(groupMemberId: Int64, profile: Profile) case userPendingReview - var text: String { + var text: String { text(isChannel: false) } + + func text(isChannel: Bool) -> String { switch self { case let .memberRole(_, profile, role): return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text) @@ -5077,7 +5532,9 @@ public enum SndGroupEvent: Decodable, Hashable { case let .memberDeleted(_, profile): return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName) case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item") - case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item") + case .groupUpdated: return isChannel + ? NSLocalizedString("channel profile updated", comment: "snd group event chat item") + : NSLocalizedString("group profile updated", comment: "snd group event chat item") case .memberAccepted: return NSLocalizedString("you accepted this member", comment: "snd group event chat item") case .userPendingReview: return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item") diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 451ac8b4ef..7de7f3704d 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -25,6 +25,7 @@ extension ChatLike { case .files: p.files.on(for: groupInfo.membership) case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership) case .history: p.history.on + case .support: p.support.on case .reports: p.reports.on } } else { @@ -52,11 +53,11 @@ extension ChatLike { } } -public func filterChatsToForwardTo(chats: [C]) -> [C] { +public func filterChatsToForwardTo(chats: [C], includeLocal: Bool = true) -> [C] { var filteredChats = chats.filter { c in c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) } - if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + if includeLocal, let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { filteredChats.insert(privateNotes, at: 0) } return filteredChats diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index dfe833f832..5a0d48dced 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -4,6 +4,7 @@ // // Created by Evgeny on 05/09/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. +// Spec: spec/services/files.md // import Foundation @@ -13,6 +14,7 @@ enum WriteFileResult: Decodable { case error(writeError: String) } +// Spec: spec/services/files.md#writeCryptoFile public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { let ptr: UnsafeMutableRawPointer = malloc(data.count) memcpy(ptr, (data as NSData).bytes, data.count) @@ -25,6 +27,7 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { } } +// Spec: spec/services/files.md#readCryptoFile public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> Data { var cPath = path.cString(using: .utf8)! var cKey = cryptoArgs.fileKey.cString(using: .utf8)! @@ -47,6 +50,7 @@ public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> D } } +// Spec: spec/services/files.md#encryptCryptoFile public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs { var cFromPath = fromPath.cString(using: .utf8)! var cToPath = toPath.cString(using: .utf8)! @@ -58,6 +62,7 @@ public func encryptCryptoFile(fromPath: String, toPath: String) throws -> Crypto } } +// Spec: spec/services/files.md#decryptCryptoFile public func decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) throws { var cFromPath = fromPath.cString(using: .utf8)! var cKey = cryptoArgs.fileKey.cString(using: .utf8)! diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 2341eb4a4f..3d0dd663c1 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 15.04.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/files.md import Foundation import OSLog @@ -13,14 +14,19 @@ import UIKit let logger = Logger() // image file size for complession +// Spec: spec/services/files.md#MAX_IMAGE_SIZE public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255KB +// Spec: spec/services/files.md#MAX_IMAGE_SIZE_AUTO_RCV public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 +// Spec: spec/services/files.md#MAX_VOICE_SIZE_AUTO_RCV public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 +// Spec: spec/services/files.md#MAX_VIDEO_SIZE_AUTO_RCV public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB +// Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max @@ -37,10 +43,12 @@ private let CHAT_DB_BAK: String = "_chat.db.bak" private let AGENT_DB_BAK: String = "_agent.db.bak" +// Spec: spec/database.md#getDocumentsDirectory public func getDocumentsDirectory() -> URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } +// Spec: spec/database.md#getGroupContainerDirectory public func getGroupContainerDirectory() -> URL { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)! } @@ -51,12 +59,14 @@ func getAppDirectory() -> URL { : getDocumentsDirectory() } +// Spec: spec/database.md#DB_FILE_PREFIX let DB_FILE_PREFIX = "simplex_v1" func getLegacyDatabasePath() -> URL { getDocumentsDirectory().appendingPathComponent("mobile_v1", isDirectory: false) } +// Spec: spec/database.md#getAppDatabasePath public func getAppDatabasePath() -> URL { dbContainerGroupDefault.get() == .group ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false) @@ -72,6 +82,7 @@ func fileModificationDate(_ path: String) -> Date? { } } +// Spec: spec/services/files.md#deleteAppDatabaseAndFiles public func deleteAppDatabaseAndFiles() { let fm = FileManager.default let dbPath = getAppDatabasePath().path @@ -93,6 +104,7 @@ public func deleteAppDatabaseAndFiles() { storeDBPassphraseGroupDefault.set(true) } +// Spec: spec/services/files.md#deleteAppFiles public func deleteAppFiles() { let fm = FileManager.default do { @@ -183,6 +195,7 @@ public func removeLegacyDatabaseAndFiles() -> Bool { return r1 && r2 } +// Spec: spec/services/files.md#getTempFilesDirectory public func getTempFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("temp_files", isDirectory: true) } @@ -191,6 +204,7 @@ public func getMigrationTempFilesDirectory() -> URL { getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true) } +// Spec: spec/services/files.md#getAppFilesDirectory public func getAppFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("app_files", isDirectory: true) } @@ -199,6 +213,7 @@ public func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } +// Spec: spec/services/files.md#getWallpaperDirectory public func getWallpaperDirectory() -> URL { getAppDirectory().appendingPathComponent("assets", isDirectory: true).appendingPathComponent("wallpapers", isDirectory: true) } @@ -207,6 +222,7 @@ public func getWallpaperFilePath(_ filename: String) -> URL { getWallpaperDirectory().appendingPathComponent(filename) } +// Spec: spec/services/files.md#saveFile public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> CryptoFile? { let filePath = getAppFilePath(fileName) do { @@ -223,6 +239,7 @@ public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> Crypt } } +// Spec: spec/services/files.md#removeFile public func removeFile(_ url: URL) { do { try FileManager.default.removeItem(atPath: url.path) @@ -239,12 +256,14 @@ public func removeFile(_ fileName: String) { } } +// Spec: spec/services/files.md#cleanupDirectFile public func cleanupDirectFile(_ aChatItem: AChatItem) { if aChatItem.chatInfo.chatType == .direct { cleanupFile(aChatItem) } } +// Spec: spec/services/files.md#cleanupFile public func cleanupFile(_ aChatItem: AChatItem) { let cItem = aChatItem.chatItem let mc = cItem.content.msgContent diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index c70ca5edd8..f93b090517 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -402,6 +402,11 @@ extension UIImage { } } +// Max image height/width ratio for chat item display, taller images are cropped +public func heightRatio(_ size: CGSize) -> CGFloat { + size.width > 0 ? min(size.height / size.width, 2.33) : 1 +} + public func imageFromBase64(_ base64Encoded: String?) -> UIImage? { if let base64Encoded { if let img = imageCache.object(forKey: base64Encoded as NSString) { diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 70db4476d5..a40e8eda99 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 28/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UserNotifications @@ -22,6 +23,7 @@ public let appNotificationId = "chat.simplex.app.notification" let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification") +// Spec: spec/services/notifications.md#createContactRequestNtf public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( @@ -40,6 +42,7 @@ public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: User ) } +// Spec: spec/services/notifications.md#createContactConnectedNtf public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( @@ -59,6 +62,7 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, ) } +// Spec: spec/services/notifications.md#createMessageReceivedNtf public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String @@ -70,7 +74,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ return createNotification( categoryIdentifier: ntfCategoryMessageReceived, title: title, - body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"), + body: previewMode == .message ? hideSecrets(cItem, isChannel: cInfo.isChannel) : NSLocalizedString("new message", comment: "notification"), targetContentIdentifier: cInfo.id, userInfo: ["userId": user.userId], // userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] @@ -78,6 +82,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ ) } +// Spec: spec/services/notifications.md#createCallInvitationNtf public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCount: Int) -> UNMutableNotificationContent { let text = invitation.callType.media == .video ? NSLocalizedString("Incoming video call", comment: "notification") @@ -93,6 +98,7 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCoun ) } +// Spec: spec/services/notifications.md#createConnectionEventNtf public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden var title: String @@ -111,10 +117,6 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: hideContent) body = NSLocalizedString("message received", comment: "notification") targetContentIdentifier = groupInfo.id - case .sndFileConnection: - title = NSLocalizedString("Sent file event", comment: "notification") - case .rcvFileConnection: - title = NSLocalizedString("Received file event", comment: "notification") case .userContactConnection: title = NSLocalizedString("New contact request", comment: "notification") } @@ -128,6 +130,7 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit ) } +// Spec: spec/services/notifications.md#createErrorNtf public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> UNMutableNotificationContent { var title: String switch dbStatus { @@ -153,6 +156,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> ) } +// Spec: spec/services/notifications.md#createAppStoppedNtf public func createAppStoppedNtf(_ badgeCount: Int) -> UNMutableNotificationContent { return createNotification( categoryIdentifier: ntfCategoryConnectionEvent, @@ -167,6 +171,7 @@ private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember : "#\(groupInfo.displayName) \(groupMember.chatViewName):" } +// Spec: spec/services/notifications.md#createNotification public func createNotification( categoryIdentifier: String, title: String, @@ -191,7 +196,8 @@ public func createNotification( return content } -func hideSecrets(_ cItem: ChatItem) -> String { +// Spec: spec/services/notifications.md#hideSecrets +func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String { if let md = cItem.formattedText { var res = "" for ft in md { @@ -207,7 +213,7 @@ func hideSecrets(_ cItem: ChatItem) -> String { if case let .report(text, reason) = mc { return String.localizedStringWithFormat(NSLocalizedString("Report: %@", comment: "report in notification"), text.isEmpty ? reason.text : text) } else { - return cItem.text + return cItem.text(isChannel: isChannel) } } } diff --git a/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift b/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift index 662f8b43d1..2b64627dc2 100644 --- a/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +// Spec: spec/services/theme.md#PresetWallpaper public enum PresetWallpaper: CaseIterable { case cats case flowers @@ -306,6 +307,7 @@ public enum WallpaperScaleType: String, Codable, CaseIterable { } } +// Spec: spec/services/theme.md#WallpaperType public enum WallpaperType: Equatable { public var image: SwiftUI.Image? { if let uiImage { diff --git a/apps/ios/SimpleXChat/Theme/Color.swift b/apps/ios/SimpleXChat/Theme/Color.swift index f307eaa5aa..86eefa4482 100644 --- a/apps/ios/SimpleXChat/Theme/Color.swift +++ b/apps/ios/SimpleXChat/Theme/Color.swift @@ -33,6 +33,55 @@ let HighOrLowlight = Color(139, 135, 134, a: 255) //let FileLight = Color(183, 190, 199, a: 255) //let FileDark = Color(101, 101, 106, a: 255) +// Create a Display P3 Color from oklch components. H in degrees +public func oklch(_ L: Double, _ C: Double, _ H: Double, alpha: Double = 1.0) -> Color { + let hRad = H * .pi / 180.0 + let cosH = cos(hRad) + let sinH = sin(hRad) + + func linearP3(C: Double) -> (Double, Double, Double) { + let a = C * cosH + let b = C * sinH + // oklab → LMS (Ottosson 2021) + let l_ = L + 0.3963377774 * a + 0.2158037573 * b + let m_ = L - 0.1055613458 * a - 0.0638541728 * b + let s_ = L - 0.0894841775 * a - 1.2914855480 * b + let l = l_ * l_ * l_ + let m = m_ * m_ * m_ + let s = s_ * s_ * s_ + // LMS → linear Display P3 (direct, no sRGB clamping) + return ( + 3.1281105148 * l - 2.2570749853 * m + 0.1293047593 * s, + -1.0911282009 * l + 2.4132668169 * m - 0.3221681599 * s, + -0.0260136845 * l - 0.5080276339 * m + 1.5333166364 * s + ) + } + + func inGamut(_ r: Double, _ g: Double, _ b: Double) -> Bool { + r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1 + } + + // linear P3 → gamma-encoded P3 (same transfer function as sRGB) + func gammaEncode(_ x: Double) -> Double { + x >= 0.0031308 + ? 1.055 * pow(min(x, 1.0), 1.0 / 2.4) - 0.055 + : 12.92 * max(x, 0) + } + + var (r, g, b) = linearP3(C: C) + if !inGamut(r, g, b) { + var lo = 0.0, hi = C + while hi - lo > 1e-5 { + let mid = (lo + hi) / 2 + let (mr, mg, mb) = linearP3(C: mid) + if inGamut(mr, mg, mb) { lo = mid; r = mr; g = mg; b = mb } + else { hi = mid } + } + } + + return Color(.displayP3, red: gammaEncode(r), green: gammaEncode(g), blue: gammaEncode(b), opacity: alpha) +} + extension Color { public init(_ argb: Int64) { let a = Double((argb & 0xFF000000) >> 24) / 255.0 diff --git a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift index 4074382543..a4e8050c6e 100644 --- a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +// Spec: spec/services/theme.md#DefaultTheme public enum DefaultTheme: String, Codable, Equatable { case LIGHT case DARK @@ -39,6 +40,7 @@ public enum DefaultThemeMode: String, Codable { case dark } +// Spec: spec/services/theme.md#Colors public class Colors: ObservableObject, NSCopying, Equatable { @Published public var primary: Color @Published public var primaryVariant: Color @@ -84,6 +86,7 @@ public class Colors: ObservableObject, NSCopying, Equatable { public func clone() -> Colors { copy() as! Colors } } +// Spec: spec/services/theme.md#AppColors public class AppColors: ObservableObject, NSCopying, Equatable { @Published public var title: Color @Published public var primaryVariant2: Color @@ -135,6 +138,7 @@ public class AppColors: ObservableObject, NSCopying, Equatable { } } +// Spec: spec/services/theme.md#AppWallpaper public class AppWallpaper: ObservableObject, NSCopying, Equatable { public static func == (lhs: AppWallpaper, rhs: AppWallpaper) -> Bool { lhs.background == rhs.background && @@ -222,6 +226,7 @@ public enum ThemeColor { } } +// Spec: spec/services/theme.md#ThemeColors public struct ThemeColors: Codable, Equatable, Hashable { public var primary: String? = nil public var primaryVariant: String? = nil @@ -293,6 +298,7 @@ public struct ThemeColors: Codable, Equatable, Hashable { } } +// Spec: spec/services/theme.md#ThemeWallpaper public struct ThemeWallpaper: Codable, Equatable, Hashable { public var preset: String? public var scale: Float? @@ -375,6 +381,7 @@ public struct ThemeWallpaper: Codable, Equatable, Hashable { /// If you add new properties, make sure they serialized to YAML correctly, see: /// encodeThemeOverrides() +// Spec: spec/services/theme.md#ThemeOverrides public struct ThemeOverrides: Codable, Equatable, Hashable { public var themeId: String = UUID().uuidString public var base: DefaultTheme @@ -559,6 +566,7 @@ extension [ThemeOverrides] { } +// Spec: spec/services/theme.md#ThemeModeOverrides public struct ThemeModeOverrides: Codable, Hashable { public var light: ThemeModeOverride? = nil public var dark: ThemeModeOverride? = nil @@ -573,6 +581,7 @@ public struct ThemeModeOverrides: Codable, Hashable { } } +// Spec: spec/services/theme.md#ThemeModeOverride public struct ThemeModeOverride: Codable, Equatable, Hashable { public var mode: DefaultThemeMode// = CurrentColors.base.mode public var colors: ThemeColors = ThemeColors() diff --git a/apps/ios/SimpleXChat/exported_symbols.txt b/apps/ios/SimpleXChat/exported_symbols.txt new file mode 100644 index 0000000000..52c3bf83e9 --- /dev/null +++ b/apps/ios/SimpleXChat/exported_symbols.txt @@ -0,0 +1,11 @@ +# Swift mangled symbols (Swift 5+ ABI stable prefix) +_$s* + +# ObjC class/metaclass symbols (for NSObject subclasses) +_OBJC_CLASS_$_* +_OBJC_METACLASS_$_* + +# C API (SimpleX.h bridging header) +_chat_* +_haskell_init* +_hs_init* diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 1ce7b53767..17e11d1020 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(това устройство v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Допринеси](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Изпратете ни имейл](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Звезда в GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Добави контакт**: за създаване на нов линк."; @@ -389,9 +383,6 @@ swipe action */ /* No comment provided by engineer. */ "Active connections" = "Активни връзки"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Добавете адрес към вашия профил, така че вашите контакти да могат да го споделят с други хора. Актуализацията на профила ще бъде изпратена до вашите контакти."; - /* No comment provided by engineer. */ "Add friends" = "Добави приятели"; @@ -635,9 +626,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Отговор на повикване"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Протокол и код с отворен код – всеки може да оперира собствени сървъри."; - /* No comment provided by engineer. */ "App build: %@" = "Компилация на приложението: %@"; @@ -873,7 +861,7 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Български, финландски, тайландски и украински - благодарение на потребителите и [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; -/* No comment provided by engineer. */ +/* chat link info line */ "Business address" = "Бизнес адрес"; /* No comment provided by engineer. */ @@ -888,9 +876,6 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "С използването на SimpleX Chat вие се съгласявате със:\n- изпращане само на легално съдържание в публични групи.\n- уважение към другите потребители – без спам."; - /* No comment provided by engineer. */ "Call already ended!" = "Разговорът вече приключи!"; @@ -1068,7 +1053,8 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Чатът ще бъде изтрит за вас - това не може да бъде отменено!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Чат с администраторите"; /* No comment provided by engineer. */ @@ -1167,7 +1153,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Условията вече са приети за тези оператори: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Условия за ползване"; /* No comment provided by engineer. */ @@ -1182,9 +1168,6 @@ set passcode view */ /* No comment provided by engineer. */ "Configure ICE servers" = "Конфигурирай ICE сървъри"; -/* No comment provided by engineer. */ -"Configure server operators" = "Конфигуриране на сървърни оператори"; - /* No comment provided by engineer. */ "Confirm" = "Потвърди"; @@ -1218,7 +1201,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Потвърдено"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Свързване"; /* No comment provided by engineer. */ @@ -1317,7 +1301,7 @@ set passcode view */ /* alert title */ "Connection error" = "Грешка при свързване"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Грешка при свързване (AUTH)"; /* chat list item title (it should not be shown */ @@ -1383,18 +1367,18 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Продължи"; +/* No comment provided by engineer. */ +"Contribute" = "Допринеси"; + /* No comment provided by engineer. */ "Copy" = "Копирай"; /* No comment provided by engineer. */ "Core version: v%@" = "Версия на ядрото: v%@"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Поправи име на %@?"; -/* No comment provided by engineer. */ -"Create" = "Създаване"; - /* No comment provided by engineer. */ "Create 1-time link" = "Създаване на еднократна препратка"; @@ -1521,9 +1505,6 @@ set passcode view */ /* time unit */ "days" = "дни"; -/* No comment provided by engineer. */ -"Decentralized" = "Децентрализиран"; - /* message decrypt error item */ "Decryption error" = "Грешка при декриптиране"; @@ -1613,7 +1594,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Изтрий съобщението?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Изтрий съобщенията"; /* No comment provided by engineer. */ @@ -1800,7 +1782,7 @@ chat item action */ /* No comment provided by engineer. */ "Edit group profile" = "Редактирай групов профил"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Активирай"; /* No comment provided by engineer. */ @@ -1824,9 +1806,6 @@ chat item action */ /* No comment provided by engineer. */ "Enable lock" = "Активирай заключване"; -/* No comment provided by engineer. */ -"Enable notifications" = "Активирай известията"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "Активирай периодични известия?"; @@ -1962,7 +1941,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "грешка"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Грешка при свързване със сървъра"; /* No comment provided by engineer. */ @@ -2210,7 +2189,8 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Намирайте чатове по-бързо"; -/* server test error */ +/* relay test error +server test error */ "Fingerprint in server address does not match certificate." = "Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен"; /* No comment provided by engineer. */ @@ -2303,7 +2283,7 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Груповата покана вече е невалидна, премахната е от подателя."; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Групов линк"; /* No comment provided by engineer. */ @@ -2411,9 +2391,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "Веднага"; -/* No comment provided by engineer. */ -"Immune to spam" = "Защитен от спам и злоупотреби"; - /* No comment provided by engineer. */ "Import" = "Импортиране"; @@ -2499,7 +2476,7 @@ snd error text */ "Initial role" = "Първоначална роля"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Инсталирайте [SimpleX Chat за терминал](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Инсталирайте SimpleX Chat за терминал"; /* No comment provided by engineer. */ "Instant" = "Мигновено"; @@ -2516,7 +2493,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "невалидни данни за чат"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Невалиден линк за връзка"; /* invalid chat item */ @@ -2531,7 +2508,7 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Невалидно потвърждение за мигриране"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Невалидно име!"; /* No comment provided by engineer. */ @@ -2750,7 +2727,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Ролята на члена ще бъде променена на \"%@\". Членът ще получи нова покана."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Членът ще бъде премахнат от групата - това не може да бъде отменено!"; /* No comment provided by engineer. */ @@ -2822,9 +2799,6 @@ snd error text */ /* No comment provided by engineer. */ "Migrate device" = "Мигрирай устройството"; -/* No comment provided by engineer. */ -"Migrate from another device" = "Мигриране от друго устройство"; - /* No comment provided by engineer. */ "Migrate here" = "Мигрирай тук"; @@ -2912,7 +2886,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Мрежови настройки"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Състояние на мрежата"; /* delete after time */ @@ -2999,9 +2973,6 @@ snd error text */ /* copied message info in history */ "no text" = "няма текст"; -/* No comment provided by engineer. */ -"No user identifiers." = "Първата платформа без никакви потребителски идентификатори – поверителна по дизайн."; - /* No comment provided by engineer. */ "Not compatible!" = "Несъвместим!"; @@ -3037,7 +3008,7 @@ alert button new chat action */ "Ok" = "Ок"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "ОК"; /* No comment provided by engineer. */ @@ -3100,7 +3071,8 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Само вашият контакт може да изпраща гласови съобщения."; -/* alert action */ +/* alert action +alert button */ "Open" = "Отвори"; /* new chat action */ @@ -3247,9 +3219,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "Поверителност и сигурност"; -/* No comment provided by engineer. */ -"Privacy redefined" = "Поверителността преосмислена"; - /* No comment provided by engineer. */ "Private filenames" = "Поверителни имена на файлове"; @@ -3268,9 +3237,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile password" = "Профилна парола"; -/* alert message */ -"Profile update will be sent to your contacts." = "Актуализацията на профила ще бъде изпратена до вашите контакти."; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Забрани аудио/видео разговорите."; @@ -3335,16 +3301,10 @@ new chat action */ "Read more" = "Прочетете още"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Прочетете повече в нашето GitHub хранилище."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Прочетете повече в [Ръководство на потребителя](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Прочетете повече в нашето [GitHub хранилище](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Прочетете повече в Ръководство за потребителя."; /* No comment provided by engineer. */ "Receipts are disabled" = "Потвърждениeто за доставка е деактивирано"; @@ -3361,9 +3321,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "получено потвърждение…"; -/* notification */ -"Received file event" = "Събитие за получен файл"; - /* message info title */ "Received message" = "Получено съобщение"; @@ -3420,13 +3377,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relay сървърът защитава вашия IP адрес, но може да наблюдава продължителността на разговора."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Премахване"; /* No comment provided by engineer. */ "Remove member" = "Острани член"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Острани член?"; /* No comment provided by engineer. */ @@ -3712,9 +3669,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Изпратено на: %@"; -/* notification */ -"Sent file event" = "Събитие за изпратен файл"; - /* message info title */ "Sent message" = "Изпратено съобщение"; @@ -3785,18 +3739,12 @@ chat item action */ /* No comment provided by engineer. */ "Share address" = "Сподели адрес"; -/* alert title */ -"Share address with contacts?" = "Сподели адреса с контактите?"; - /* No comment provided by engineer. */ "Share link" = "Сподели линк"; /* No comment provided by engineer. */ "Share this 1-time invite link" = "Сподели този еднократен линк за връзка"; -/* No comment provided by engineer. */ -"Share with contacts" = "Сподели с контактите"; - /* No comment provided by engineer. */ "Show calls in phone history" = "Показване на обажданията в хронологията на телефона"; @@ -3884,6 +3832,9 @@ chat item action */ /* chat item text */ "standard end-to-end encryption" = "стандартно криптиране от край до край"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Звезда в GitHub"; + /* No comment provided by engineer. */ "Start chat" = "Започни чат"; @@ -3980,7 +3931,8 @@ chat item action */ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Тестът е неуспешен на стъпка %@."; /* No comment provided by engineer. */ @@ -4022,9 +3974,6 @@ chat item action */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Криптирането работи и новото споразумение за криптиране не е необходимо. Това може да доведе до грешки при свързване!"; -/* No comment provided by engineer. */ -"The future of messaging" = "Ново поколение поверителни съобщения"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хешът на предишното съобщение е различен."; @@ -4130,12 +4079,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Транспортна изолация"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Опит за свързване със сървъра, използван за получаване на съобщения от този контакт."; - /* No comment provided by engineer. */ "Turkish interface" = "Турски интерфейс"; @@ -4259,9 +4202,6 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = "Използвай .onion хостове"; -/* No comment provided by engineer. */ -"Use chat" = "Използвай чата"; - /* new chat action */ "Use current profile" = "Използвай текущия профил"; @@ -4493,9 +4433,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вече се присъединихте към групата!\nИзпрати отново заявката за присъединяване?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Вие сте свързани към сървъра, използван за получаване на съобщения от този контакт."; - /* No comment provided by engineer. */ "You are invited to group" = "Поканени сте в групата"; @@ -4568,9 +4505,6 @@ chat item action */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "Не можахте да бъдете потвърдени; Моля, опитайте отново."; -/* No comment provided by engineer. */ -"You decide who can connect." = "Хората могат да се свържат с вас само чрез ликовете, които споделяте."; - /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Вече сте направили заявката за връзка!\nИзпрати отново заявката за свързване?"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index f8597fa4a5..f7e90e0c88 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(toto zařízení v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Přispějte](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Pošlete nám e-mail](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Hvězda na GitHubu](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Vytvořit jednorázový odkaz**: pro vytvoření a sdílení nové pozvánky."; @@ -196,6 +190,9 @@ /* No comment provided by engineer. */ "%lld file(s) with total size of %@" = "%lld soubor(y) s celkovou velikostí %@"; +/* No comment provided by engineer. */ +"%lld group events" = "%lld událostí skupiny"; + /* No comment provided by engineer. */ "%lld members" = "%lld členové"; @@ -305,7 +302,7 @@ time interval */ "A new random profile will be shared." = "Nový náhodný profil bude sdílen."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each chat profile you have in the app**." = "Samostatné připojení TCP bude použito **pro každý chat profil, který máte v aplikaci**."; +"A separate TCP connection will be used **for each chat profile you have in the app**." = "Samostatné připojení TCP bude použito **pro každý profil chatu, který máte v aplikaci**."; /* No comment provided by engineer. */ "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**pro každý kontakt a člena skupiny** bude použito samostatné připojení TCP.\n**Upozornění**: Pokud máte mnoho připojení, spotřeba baterie a provozu může být podstatně vyšší a některá připojení mohou selhat."; @@ -371,9 +368,6 @@ swipe action */ /* No comment provided by engineer. */ "Active connections" = "Aktivní spojení"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Přidejte adresu do svého profilu, aby ji vaše kontakty mohly sdílet s dalšími lidmi. Aktualizace profilu bude zaslána vašim kontaktům."; - /* No comment provided by engineer. */ "Add friends" = "Přidat přátele"; @@ -453,7 +447,7 @@ swipe action */ "All chats and messages will be deleted - this cannot be undone!" = "Všechny chaty a zprávy budou smazány – tuto akci nelze vrátit zpět!"; /* alert message */ -"All chats will be removed from the list %@, and the list deleted." = "Všechny chaty budou odstraněny ze seznamu %@ a seznam bude odstraněn."; +"All chats will be removed from the list %@, and the list deleted." = "Všechny chaty budou odstraněny ze seznamu %@ a seznam bude smazán."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Všechna data se při zadání vymažou."; @@ -570,7 +564,7 @@ swipe action */ "Always use relay" = "Spojení přes relé"; /* No comment provided by engineer. */ -"An empty chat profile with the provided name is created, and the app opens as usual." = "Vytvořit prázdný chat profil se zadaným názvem a otevřít aplikaci jako obvykle."; +"An empty chat profile with the provided name is created, and the app opens as usual." = "Vytvořit prázdný profil chatu se zadaným názvem a otevřít aplikaci jako obvykle."; /* report reason */ "Another reason" = "Jiný důvod"; @@ -578,9 +572,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Přijmout hovor"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Servery může provozovat kdokoli."; - /* No comment provided by engineer. */ "App build: %@" = "Sestavení aplikace: %@"; @@ -680,6 +671,12 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "Špatné ID zprávy"; +/* No comment provided by engineer. */ +"Be free in your network." = "Buďte svobodní ve své síti."; + +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "Protože jsme zničili sílu vědět, kdo jste. Aby vám vaši moc nikdo nemohl vzít."; + /* No comment provided by engineer. */ "Better calls" = "Lepší volání"; @@ -743,11 +740,11 @@ swipe action */ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulharský, finský, thajský a ukrajinský - díky uživatelům a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; -/* No comment provided by engineer. */ +/* chat link info line */ "Business address" = "Obchodní adresa"; /* No comment provided by engineer. */ -"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Podle profilu chatu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; /* No comment provided by engineer. */ "Call already ended!" = "Hovor již skončil!"; @@ -821,7 +818,7 @@ new chat action */ "Change automatic message deletion?" = "Změnit automatické mazání zpráv?"; /* authentication reason */ -"Change chat profiles" = "Změnit chat profily"; +"Change chat profiles" = "Změnit profily chatu"; /* No comment provided by engineer. */ "Change database passphrase?" = "Změnit přístupovou frázi databáze?"; @@ -959,7 +956,8 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm upload" = "Potvrdit nahrání"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Připojit"; /* No comment provided by engineer. */ @@ -1013,7 +1011,7 @@ set passcode view */ /* alert title */ "Connection error" = "Chyba připojení"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Chyba spojení (AUTH)"; /* chat list item title (it should not be shown */ @@ -1061,15 +1059,15 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Pokračovat"; +/* No comment provided by engineer. */ +"Contribute" = "Přispějte"; + /* No comment provided by engineer. */ "Copy" = "Kopírovat"; /* No comment provided by engineer. */ "Core version: v%@" = "Verze jádra: v%@"; -/* No comment provided by engineer. */ -"Create" = "Vytvořit"; - /* server test step */ "Create file" = "Vytvořit soubor"; @@ -1175,9 +1173,6 @@ set passcode view */ /* time unit */ "days" = "dní"; -/* No comment provided by engineer. */ -"Decentralized" = "Decentralizované"; - /* message decrypt error item */ "Decryption error" = "Chyba dešifrování"; @@ -1208,10 +1203,10 @@ swipe action */ "Delete all files" = "Odstranit všechny soubory"; /* No comment provided by engineer. */ -"Delete chat profile" = "Smazat chat profil"; +"Delete chat profile" = "Smazat profil chatu"; /* No comment provided by engineer. */ -"Delete chat profile?" = "Smazat chat profil?"; +"Delete chat profile?" = "Smazat profil chatu?"; /* No comment provided by engineer. */ "Delete connection" = "Smazat připojení"; @@ -1258,7 +1253,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Smazat zprávu?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Smazat zprávy"; /* No comment provided by engineer. */ @@ -1411,7 +1407,7 @@ swipe action */ /* No comment provided by engineer. */ "Edit group profile" = "Upravit profil skupiny"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Zapnout"; /* No comment provided by engineer. */ @@ -1429,9 +1425,6 @@ swipe action */ /* No comment provided by engineer. */ "Enable lock" = "Povolit zámek"; -/* No comment provided by engineer. */ -"Enable notifications" = "Povolit upozornění"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "Povolit pravidelná oznámení?"; @@ -1543,7 +1536,7 @@ swipe action */ /* No comment provided by engineer. */ "error" = "chyba"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Chyba"; /* No comment provided by engineer. */ @@ -1752,8 +1745,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Najděte chaty rychleji"; -/* server test error */ -"Fingerprint in server address does not match certificate." = "Je možné, že otisk certifikátu v adrese serveru je nesprávný"; +/* relay test error +server test error */ +"Fingerprint in server address does not match certificate." = "Otisk certifikátu v adrese serveru neodpovídá."; /* No comment provided by engineer. */ "Fix" = "Opravit"; @@ -1818,7 +1812,7 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Skupinová pozvánka již není platná, byla odstraněna odesílatelem."; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Odkaz na skupinu"; /* No comment provided by engineer. */ @@ -1858,7 +1852,7 @@ snd error text */ "Hidden" = "Skryté"; /* No comment provided by engineer. */ -"Hidden chat profiles" = "Skryté chat profily"; +"Hidden chat profiles" = "Skryté profily chatu"; /* No comment provided by engineer. */ "Hidden profile password" = "Hesla skrytých profilů"; @@ -1920,9 +1914,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "Ihned"; -/* No comment provided by engineer. */ -"Immune to spam" = "Odolná vůči spamu a zneužití"; - /* No comment provided by engineer. */ "Import" = "Import"; @@ -1987,7 +1978,7 @@ snd error text */ "Initial role" = "Počáteční role"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Nainstalujte [SimpleX Chat pro terminál](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Nainstalujte SimpleX Chat pro terminál"; /* No comment provided by engineer. */ "Instant" = "Okamžitě"; @@ -2004,7 +1995,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "neplatná chat data"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Neplatný odkaz na spojení"; /* invalid chat item */ @@ -2193,7 +2184,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Role člena se změní na \"%@\". Člen obdrží novou pozvánku."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Člen bude odstraněn ze skupiny - toto nelze vzít zpět!"; /* No comment provided by engineer. */ @@ -2290,7 +2281,7 @@ snd error text */ "Most likely this connection is deleted." = "Pravděpodobně je toto spojení smazáno."; /* No comment provided by engineer. */ -"Multiple chat profiles" = "Více chatovacích profilů"; +"Multiple chat profiles" = "Více profilů chatu"; /* notification label action */ "Mute" = "Ztlumit"; @@ -2307,7 +2298,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Nastavení sítě"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Stav sítě"; /* delete after time */ @@ -2389,7 +2380,10 @@ snd error text */ "no text" = "žádný text"; /* No comment provided by engineer. */ -"No user identifiers." = "Bez uživatelských identifikátorů"; +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Nikdo nesledoval vaše konverzace. Nikdo nevytvořil mapu, kde jste byli. Soukromí nikdy nebylo funkcí - byl to způsob života."; + +/* No comment provided by engineer. */ +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Nejde o to mít lepší zámek na dveřích někoho jiného. Ani o to mít nájemce, který respektuje vaše soukromí, ale vede evidenci všech vašich návštěvníků. Nejste host. Jste doma. Ani král k vám nemůže vstoupit - jste suverén."; /* No comment provided by engineer. */ "Notifications" = "Oznámení"; @@ -2483,7 +2477,8 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Hlasové zprávy může odesílat pouze váš kontakt."; -/* alert action */ +/* alert action +alert button */ "Open" = "Otevřít"; /* new chat action */ @@ -2585,9 +2580,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "Ochrana osobních údajů a zabezpečení"; -/* No comment provided by engineer. */ -"Privacy redefined" = "Nové vymezení soukromí"; - /* No comment provided by engineer. */ "Private filenames" = "Soukromé názvy souborů"; @@ -2600,9 +2592,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile password" = "Heslo profilu"; -/* alert message */ -"Profile update will be sent to your contacts." = "Aktualizace profilu bude zaslána vašim kontaktům."; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Zákaz audio/video hovorů."; @@ -2631,7 +2620,7 @@ new chat action */ "Protect app screen" = "Ochrana obrazovky aplikace"; /* No comment provided by engineer. */ -"Protect your chat profiles with a password!" = "Chraňte své chat profily heslem!"; +"Protect your chat profiles with a password!" = "Chraňte své profily chatu pomocí hesla!"; /* No comment provided by engineer. */ "Protocol timeout" = "Časový limit protokolu"; @@ -2655,13 +2644,10 @@ new chat action */ "Read more" = "Přečíst více"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Další informace naleznete v [Uživatelské příručce](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; +"Read more in our GitHub repository." = "Přečtěte si více v našem GitHub repozitáři."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Přečtěte si více v [Uživatelské příručce](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Přečtěte si více v našem [GitHub repozitáři](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Více informací v průvodci uživatele."; /* No comment provided by engineer. */ "Receipts are disabled" = "Informace o dodání jsou zakázány"; @@ -2678,9 +2664,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "obdržel potvrzení…"; -/* notification */ -"Received file event" = "Událost přijatého souboru"; - /* message info title */ "Received message" = "Přijatá zpráva"; @@ -2731,13 +2714,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Přenosový server chrání vaši IP adresu, ale může sledovat dobu trvání hovoru."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Odstranit"; /* No comment provided by engineer. */ "Remove member" = "Odstranit člena"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Odebrat člena?"; /* No comment provided by engineer. */ @@ -2777,7 +2760,7 @@ swipe action */ "Reset to defaults" = "Obnovení výchozího nastavení"; /* No comment provided by engineer. */ -"Restart the app to create a new chat profile" = "Restartujte aplikaci pro vytvoření nového chat profilu"; +"Restart the app to create a new chat profile" = "Restartujte aplikaci pro vytvoření nového profilu chatu"; /* No comment provided by engineer. */ "Restart the app to use imported chat database" = "Restartujte aplikaci pro použití importované databáze chatu"; @@ -2946,7 +2929,7 @@ chat item action */ "Sender may have deleted the connection request." = "Odesílatel možná smazal požadavek připojení."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných chat profilech."; +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných profilech chatu."; /* No comment provided by engineer. */ "Sending delivery receipts will be enabled for all contacts." = "Odesílání potvrzení o doručení bude povoleno pro všechny kontakty."; @@ -2975,9 +2958,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Posláno v: % @"; -/* notification */ -"Sent file event" = "Odeslaná událost souboru"; - /* message info title */ "Sent message" = "Poslaná zpráva"; @@ -2985,10 +2965,10 @@ chat item action */ "Sent messages will be deleted after set time." = "Odeslané zprávy se po uplynutí nastavené doby odstraní."; /* server test error */ -"Server requires authorization to create queues, check password." = "Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo"; +"Server requires authorization to create queues, check password." = "Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo."; /* server test error */ -"Server requires authorization to upload, check password." = "Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo"; +"Server requires authorization to upload, check password." = "Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo."; /* No comment provided by engineer. */ "Server test failed!" = "Test serveru se nezdařil!"; @@ -3033,15 +3013,9 @@ chat item action */ /* No comment provided by engineer. */ "Share address" = "Sdílet adresu"; -/* alert title */ -"Share address with contacts?" = "Sdílet adresu s kontakty?"; - /* No comment provided by engineer. */ "Share link" = "Sdílet odkaz"; -/* No comment provided by engineer. */ -"Share with contacts" = "Sdílet s kontakty"; - /* No comment provided by engineer. */ "Show calls in phone history" = "Ukaž hovory v historii telefonu"; @@ -3111,6 +3085,9 @@ chat item action */ /* notification title */ "Somebody" = "Někdo"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Hvězda na GitHubu"; + /* No comment provided by engineer. */ "Start chat" = "Začít chat"; @@ -3189,7 +3166,8 @@ chat item action */ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Test selhal v kroku %@."; /* No comment provided by engineer. */ @@ -3228,9 +3206,6 @@ chat item action */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Šifrování funguje a nové povolení šifrování není vyžadováno. To může vyvolat chybu v připojení!"; -/* No comment provided by engineer. */ -"The future of messaging" = "Nová generace soukromých zpráv"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Hash předchozí zprávy se liší."; @@ -3246,6 +3221,9 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Stará databáze nebyla během přenášení odstraněna, lze ji smazat."; +/* No comment provided by engineer. */ +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "Nejstarší lidská svoboda - mluvit s druhým člověkem, aniž by byl sledován - postavena na infrastruktuře, která ji nemůže zradit."; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Druhé zaškrtnutí jsme přehlédli! ✅"; @@ -3253,7 +3231,13 @@ chat item action */ "The sender will NOT be notified" = "Odesílatel NEBUDE informován"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "Servery pro nová připojení vašeho aktuálního chat profilu **%@**."; +"The servers for new connections of your current chat profile **%@**." = "Servery pro nová připojení vašeho aktuálního profilu chatu **%@**."; + +/* No comment provided by engineer. */ +"Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "Pak jsme se přesunuli na internet a každá platforma chtěla o vás něco vědět - vaše jméno, vaše číslo, vaše přátele. Smířili jsme se s tím, že cenou za komunikaci s ostatními je dát někomu vědět, s kým mluvíme. Každá generace, lidská i technická, to tak měla - telefon, e-mail, komunikátory, sociální sítě. Zdálo se, že je to jediný možný způsob."; + +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "Existuje i jiný způsob. Síť bez telefonních čísel. Bez uživatelských jmen. Bez účtů. Bez jakékoli uživatelské identity. Síť, která spojuje lidi a přenáší šifrované zprávy, aniž by bylo známo, kdo je připojen."; /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "Toto nastavení je pro váš aktuální profil **%@**."; @@ -3280,7 +3264,7 @@ chat item action */ "This group no longer exists." = "Tato skupina již neexistuje."; /* No comment provided by engineer. */ -"This setting applies to messages in your current chat profile **%@**." = "Toto nastavení platí pro zprávy ve vašem aktuálním chat profilu **%@**."; +"This setting applies to messages in your current chat profile **%@**." = "Toto nastavení platí pro zprávy ve vašem aktuálním profilu chatu **%@**."; /* No comment provided by engineer. */ "To ask any questions and to receive updates:" = "Chcete-li položit jakékoli dotazy a dostávat aktuality:"; @@ -3304,7 +3288,7 @@ chat item action */ "To record voice message please grant permission to use Microphone." = "Chcete-li nahrávat hlasové zprávy, udělte povolení k použití mikrofonu."; /* No comment provided by engineer. */ -"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce **Chat profily**."; +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce **Profily chatu**."; /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "Pro podporu doručování okamžitých upozornění musí být přenesena chat databáze."; @@ -3318,12 +3302,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Izolace transportu"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Pokus o připojení k serveru používanému k přijímání zpráv od tohoto kontaktu (chyba: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Pokus o připojení k serveru používanému pro příjem zpráv od tohoto kontaktu."; - /* No comment provided by engineer. */ "Turn off" = "Vypnout"; @@ -3343,7 +3321,7 @@ chat item action */ "Unhide" = "Odkrýt"; /* No comment provided by engineer. */ -"Unhide chat profile" = "Odkrýt chat profil"; +"Unhide chat profile" = "Odkrýt profil chatu"; /* No comment provided by engineer. */ "Unhide profile" = "Odkrýt profil"; @@ -3405,9 +3383,6 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = "Použít hostitele .onion"; -/* No comment provided by engineer. */ -"Use chat" = "Použijte chat"; - /* new chat action */ "Use current profile" = "Použít aktuální profil"; @@ -3552,9 +3527,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Již jste připojeni k %@."; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu."; - /* No comment provided by engineer. */ "You are invited to group" = "Jste pozváni do skupiny"; @@ -3583,7 +3555,7 @@ chat item action */ "You can set lock screen notification preview via settings." = "Náhled oznámení na zamykací obrazovce můžete změnit v nastavení."; /* No comment provided by engineer. */ -"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Můžete sdílet odkaz nebo QR kód - ke skupině se bude moci připojit kdokoli. O členy skupiny nepřijdete, pokud ji později odstraníte."; +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Můžete sdílet odkaz nebo QR kód - ke skupině se bude moci připojit kdokoli. O členy skupiny nepřijdete, pokud odkaz později smažete."; /* No comment provided by engineer. */ "You can share this address with your contacts to let them connect with **%@**." = "Tuto adresu můžete sdílet s vašimi kontakty, abyse se mohli spojit s **%@**."; @@ -3615,9 +3587,6 @@ chat item action */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "Nemohli jste být ověřeni; Zkuste to prosím znovu."; -/* No comment provided by engineer. */ -"You decide who can connect." = "Lidé se s vámi mohou spojit pouze prostřednictvím odkazu, který sdílíte."; - /* No comment provided by engineer. */ "You have to enter passphrase every time the app starts - it is not stored on the device." = "Musíte zadat přístupovou frázi při každém spuštění aplikace - není uložena v zařízení."; @@ -3654,6 +3623,9 @@ chat item action */ /* chat list item description */ "you shared one-time link incognito" = "sdíleli jste jednorázový odkaz inkognito"; +/* No comment provided by engineer. */ +"You were born without an account" = "Narodili jste se bez účtu."; + /* No comment provided by engineer. */ "You will be connected to group when the group host's device is online, please wait or check later!" = "Ke skupině budete připojeni, až bude zařízení hostitele skupiny online, vyčkejte prosím nebo se podívejte později!"; @@ -3694,7 +3666,7 @@ chat item action */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Vaše chat databáze není šifrována – nastavte přístupovou frázi pro její šifrování."; /* No comment provided by engineer. */ -"Your chat profiles" = "Vaše chat profily"; +"Your chat profiles" = "Vaše profily chatu"; /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "Kontakt odeslal soubor, který je větší než aktuálně podporovaná maximální velikost (%@)."; @@ -3705,6 +3677,9 @@ chat item action */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "Vaše kontakty zůstanou připojeny."; +/* No comment provided by engineer. */ +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "Vaše konverzace patří vám, jako tomu bylo vždy před internetem. Síť není místo, které navštěvujete. Je to místo, které vytváříte a vlastníte. A nikdo vám ho nemůže vzít, ať už je soukromé, nebo veřejné."; + /* No comment provided by engineer. */ "Your current chat database will be DELETED and REPLACED with the imported one." = "Vaše aktuální chat databáze bude ODSTRANĚNA a NAHRAZENA importovanou."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 3008d29608..d5978b48dc 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -10,6 +10,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilere Zustellung von Nachrichten.\n- ein bisschen verbesserte Gruppen.\n- und mehr!"; +/* No comment provided by engineer. */ +"- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking." = "- Opt‑in zum Senden von Linkvorschauen.\n- Hyperlink‑Phishing verhindern.\n- Link‑Tracking entfernen."; + /* No comment provided by engineer. */ "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- Optionale Benachrichtigung von gelöschten Kontakten.\n- Profilnamen mit Leerzeichen.\n- Und mehr!"; @@ -19,21 +22,21 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 farbig!"; +/* chat link info line */ +"(from owner)" = "(vom Eigentümer)"; + /* No comment provided by engineer. */ "(new)" = "(Neu)"; +/* chat link info line */ +"(signed)" = "(signiert)"; + /* No comment provided by engineer. */ "(this device v%@)" = "(Dieses Gerät hat v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Senden Sie uns eine E-Mail](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Kontakt hinzufügen**: Um einen neuen Einladungslink zu erstellen."; @@ -64,6 +67,9 @@ /* No comment provided by engineer. */ "**Scan / Paste link**: to connect via a link you received." = "**Link scannen / einfügen**: Um eine Verbindung über den Link herzustellen, den Sie erhalten haben."; +/* No comment provided by engineer. */ +"**Test relay** to retrieve its name." = "**Relais testen** um seinen Namen abzurufen."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Warnung**: Sofortige Push-Benachrichtigungen erfordern die Eingabe eines Passworts, welches in Ihrem Schlüsselbund gespeichert ist."; @@ -175,6 +181,18 @@ /* time interval */ "%d months" = "%d Monate"; +/* channel relay bar +channel subscriber relay bar */ +"%d relays failed" = "%d Relais fehlgeschlagen"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays not active" = "%d Relais nicht aktiv"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays removed" = "%d Relais entfernt"; + /* time interval */ "%d sec" = "%d s"; @@ -184,15 +202,50 @@ /* integrity error chat item */ "%d skipped message(s)" = "%d übersprungene Nachricht(en)"; +/* channel subscriber count */ +"%d subscriber" = "%d Abonnent"; + +/* channel subscriber count */ +"%d subscribers" = "%d Abonnenten"; + /* time interval */ "%d weeks" = "%d Wochen"; +/* channel creation progress +channel relay bar progress */ +"%d/%d relays active" = "%1$d/%2$d Relais aktiv"; + +/* channel relay bar */ +"%d/%d relays active, %d errors" = "%1$d/%2$d Relais aktiv, %3$d Fehler"; + +/* channel creation progress with errors +channel relay bar */ +"%d/%d relays active, %d failed" = "%1$d/%2$d Relais aktiv, %3$d fehlgeschlagen"; + +/* channel relay bar */ +"%d/%d relays active, %d removed" = "%1$d/%2$d Relais aktiv, %3$d entfernt"; + +/* channel subscriber relay bar progress */ +"%d/%d relays connected" = "%1$d/%2$d Relais verbunden"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d errors" = "%1$d/%2$d Relais verbunden, %3$d Fehler"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d failed" = "%1$d/%2$d Relais verbunden, %3$d fehlgeschlagen"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d removed" = "%1$d/%2$d Relais verbunden, %3$d entfernt"; + /* No comment provided by engineer. */ "%lld" = "%lld"; /* No comment provided by engineer. */ "%lld %@" = "%lld %@"; +/* No comment provided by engineer. */ +"%lld channel events" = "%lld Kanalereignisse"; + /* No comment provided by engineer. */ "%lld contact(s) selected" = "%lld Kontakt(e) ausgewählt"; @@ -262,6 +315,9 @@ /* No comment provided by engineer. */ "~strike~" = "\\~durchstreichen~"; +/* owner verification */ +"⚠️ Signature verification failed: %@." = "⚠️ Signaturüberprüfung fehlgeschlagen: %@."; + /* time to disappear */ "0 sec" = "0 sek"; @@ -307,6 +363,9 @@ time interval */ /* No comment provided by engineer. */ "A few more things" = "Ein paar weitere Dinge"; +/* No comment provided by engineer. */ +"A link for one person to connect" = "Verbindungs-Link für eine Person"; + /* notification title */ "A new contact" = "Ein neuer Kontakt"; @@ -371,6 +430,9 @@ swipe action */ /* alert title */ "Accept member" = "Mitglied annehmen"; +/* No comment provided by engineer. */ +"accepted" = "Angenommen"; + /* rcv group event chat item */ "accepted %@" = "%@ angenommen"; @@ -392,6 +454,9 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledgement errors" = "Fehler bei der Bestätigung"; +/* No comment provided by engineer. */ +"active" = "Aktiv"; + /* token status text */ "Active" = "Aktiv"; @@ -399,7 +464,7 @@ swipe action */ "Active connections" = "Aktive Verbindungen"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet."; +"Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts." = "Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre SimpleX-Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre SimpleX-Kontakte gesendet."; /* No comment provided by engineer. */ "Add friends" = "Freunde aufnehmen"; @@ -440,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Nachrichtenserver hinzugefügt"; +/* No comment provided by engineer. */ +"Adding relays will be supported later." = "Das Hinzufügen von Relais wird zu einem späteren Zeitpunkt unterstützt."; + /* No comment provided by engineer. */ "Additional accent" = "Erste Akzentfarbe"; @@ -471,7 +539,7 @@ swipe action */ "Admins can block a member for all." = "Administratoren können ein Gruppenmitglied für Alle blockieren."; /* No comment provided by engineer. */ -"Admins can create the links to join groups." = "Administratoren können Links für den Beitritt zu Gruppen erzeugen."; +"Admins can create the links to join groups." = "Administratoren können Links für den Beitritt zu Gruppen erstellen."; /* No comment provided by engineer. */ "Advanced network settings" = "Erweiterte Netzwerkeinstellungen"; @@ -512,6 +580,9 @@ swipe action */ /* feature role */ "all members" = "Alle Mitglieder"; +/* No comment provided by engineer. */ +"All messages" = "Alle Nachrichten"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Alle Nachrichten und Dateien werden **Ende-zu-Ende verschlüsselt** versendet - in Direkt-Nachrichten mit Post-Quantum-Security."; @@ -527,6 +598,12 @@ swipe action */ /* profile dropdown */ "All profiles" = "Alle Profile"; +/* No comment provided by engineer. */ +"All relays failed" = "Alle Relais fehlgeschlagen"; + +/* No comment provided by engineer. */ +"All relays removed" = "Alle Relais entfernt"; + /* No comment provided by engineer. */ "All reports will be archived for you." = "Alle Meldungen werden für Sie archiviert."; @@ -563,6 +640,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Erlauben Sie das unwiederbringliche Löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt. (24 Stunden)"; +/* No comment provided by engineer. */ +"Allow members to chat with admins." = "Mitgliedern den Chat mit Administratoren erlauben."; + /* No comment provided by engineer. */ "Allow message reactions only if your contact allows them." = "Erlauben Sie Reaktionen auf Nachrichten nur dann, wenn es Ihr Kontakt ebenfalls erlaubt."; @@ -572,12 +652,18 @@ swipe action */ /* No comment provided by engineer. */ "Allow sending direct messages to members." = "Das Senden von Direktnachrichten an Gruppenmitglieder erlauben."; +/* No comment provided by engineer. */ +"Allow sending direct messages to subscribers." = "Das Senden von Direktnachrichten an Abonnenten erlauben."; + /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Das Senden von verschwindenden Nachrichten erlauben."; /* No comment provided by engineer. */ "Allow sharing" = "Teilen erlauben"; +/* No comment provided by engineer. */ +"Allow subscribers to chat with admins." = "Abonnenten den Chat mit Administratoren erlauben."; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Unwiederbringliches löschen von gesendeten Nachrichten erlauben. (24 Stunden)"; @@ -647,9 +733,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Anruf annehmen"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Jeder kann seine eigenen Server aufsetzen."; - /* No comment provided by engineer. */ "App build: %@" = "App Build: %@"; @@ -734,6 +817,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Audio- und Videoanrufe"; +/* No comment provided by engineer. */ +"Audio call" = "Audioanruf"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "Audioanruf (nicht E2E verschlüsselt)"; @@ -788,6 +874,15 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "Falsche Nachrichten-ID"; +/* No comment provided by engineer. */ +"Be free\nin your network" = "Seien Sie frei\nin Ihrem Netzwerk"; + +/* No comment provided by engineer. */ +"Be free in your network." = "Genießen Sie die Freiheit in Ihrem Netzwerk."; + +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "Weil wir die Macht zerstört haben, zu wissen, wer Sie sind. Damit Ihnen Ihre Macht niemals genommen werden kann."; + /* No comment provided by engineer. */ "Better calls" = "Verbesserte Anrufe"; @@ -845,11 +940,14 @@ swipe action */ /* No comment provided by engineer. */ "Block member?" = "Mitglied blockieren?"; +/* No comment provided by engineer. */ +"Block subscriber for all?" = "Abonnent für alle blockieren?"; + /* marked deleted chat item preview text */ "blocked" = "Blockiert"; /* rcv group event chat item */ -"blocked %@" = "%@ wurde blockiert"; +"blocked %@" = "hat %@ blockiert"; /* blocked chat item marked deleted chat item preview text */ @@ -889,9 +987,15 @@ marked deleted chat item preview text */ "Both you and your contact can send voice messages." = "Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden."; /* No comment provided by engineer. */ -"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgarisch, Finnisch, Thailändisch und Ukrainisch - Dank der Nutzer und [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +"Bottom bar" = "Untere Leiste"; + +/* compose placeholder for channel owner */ +"Broadcast" = "Broadcast"; /* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgarisch, Finnisch, Thailändisch und Ukrainisch - Dank der Nutzer und [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + +/* chat link info line */ "Business address" = "Geschäftliche Adresse"; /* No comment provided by engineer. */ @@ -906,9 +1010,6 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden.\n- andere Nutzer zu respektieren - kein Spam."; - /* No comment provided by engineer. */ "call" = "Anrufen"; @@ -933,6 +1034,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Camera not available" = "Kamera nicht verfügbar"; +/* No comment provided by engineer. */ +"can't broadcast" = "Broadcast nicht möglich"; + /* No comment provided by engineer. */ "Can't call contact" = "Kontakt kann nicht angerufen werden"; @@ -1032,6 +1136,58 @@ set passcode view */ /* chat item text */ "changing address…" = "Wechsel der Empfängeradresse wurde gestartet…"; +/* shown as sender role for channel messages */ +"channel" = "Kanal"; + +/* No comment provided by engineer. */ +"Channel" = "Kanal"; + +/* No comment provided by engineer. */ +"Channel display name" = "Anzeigename des Kanals"; + +/* No comment provided by engineer. */ +"Channel full name (optional)" = "Vollständiger Kanalname (optional)"; + +/* alert message +alert subtitle */ +"Channel has no active relays. Please try to join later." = "Der Kanal hat keine aktiven Relais. Bitte später erneut versuchen."; + +/* No comment provided by engineer. */ +"Channel image" = "Kanalbild"; + +/* chat link info line */ +"Channel link" = "Kanallink"; + +/* No comment provided by engineer. */ +"Channel preferences" = "Kanal-Präferenzen"; + +/* No comment provided by engineer. */ +"Channel profile" = "Kanalprofil"; + +/* No comment provided by engineer. */ +"Channel profile is stored on subscribers' devices and on the chat relays." = "Das Kanalprofil wird auf den Geräten der Abonnenten und auf den Chat‑Relais gespeichert."; + +/* snd group event chat item */ +"channel profile updated" = "Kanalprofil wurde aktualisiert"; + +/* alert message */ +"Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers." = "Das Kanalprofil wurde geändert. Beim Speichern wird das aktualisierte Profil an die Abonnenten des Kanals gesendet."; + +/* alert title */ +"Channel temporarily unavailable" = "Der Kanal ist vorübergehend nicht erreichbar"; + +/* No comment provided by engineer. */ +"Channel will be deleted for all subscribers - this cannot be undone!" = "Der Kanal wird für alle Abonnenten gelöscht. Dies kann nicht rückgängig gemacht werden!"; + +/* No comment provided by engineer. */ +"Channel will be deleted for you - this cannot be undone!" = "Der Kanal wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden!"; + +/* alert message */ +"Channel will start working with %d of %d relays. Proceed?" = "Der Kanal wird mit %1$d von %2$d Relais gestartet. Fortfahren?"; + +/* No comment provided by engineer. */ +"Channels" = "Kanäle"; + /* No comment provided by engineer. */ "Chat" = "Chat"; @@ -1083,6 +1239,18 @@ set passcode view */ /* No comment provided by engineer. */ "Chat profile" = "Benutzerprofil"; +/* No comment provided by engineer. */ +"Chat relay" = "Chat-Relais"; + +/* No comment provided by engineer. */ +"Chat relays" = "Chat-Relais"; + +/* No comment provided by engineer. */ +"Chat relays forward messages in channels you create." = "Chat‑Relais leiten Nachrichten in den von Ihnen erstellten Kanälen weiter."; + +/* No comment provided by engineer. */ +"Chat relays forward messages to channel subscribers." = "Chat‑Relais leiten Nachrichten an Kanal-Abonnenten weiter."; + /* No comment provided by engineer. */ "Chat theme" = "Chat-Design"; @@ -1092,7 +1260,8 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Der Chat wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Chat mit Administratoren"; /* No comment provided by engineer. */ @@ -1104,15 +1273,30 @@ set passcode view */ /* No comment provided by engineer. */ "Chats" = "Chats"; +/* No comment provided by engineer. */ +"Chats with admins are prohibited." = "Chats mit Administratoren sind nicht erlaubt."; + +/* alert message */ +"Chats with admins in public channels have no E2E encryption - use only with trusted chat relays." = "Chats mit Administratoren in öffentlichen Kanälen sind nicht Ende‑zu‑Ende‑verschlüsselt – bitte nur über vertrauenswürdige Chat‑Relais nutzen."; + /* No comment provided by engineer. */ "Chats with members" = "Chats mit Mitgliedern"; +/* No comment provided by engineer. */ +"Chats with members are disabled" = "Chats mit Mitgliedern sind deaktiviert"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Alle 20min Nachrichten überprüfen."; /* No comment provided by engineer. */ "Check messages when allowed." = "Wenn es erlaubt ist, Nachrichten überprüfen."; +/* alert message */ +"Check relay address and try again." = "Relais-Adresse überprüfen und erneut versuchen."; + +/* alert message */ +"Check relay name and try again." = "Relais-Name überprüfen und erneut versuchen."; + /* alert title */ "Check server address and try again." = "Überprüfen Sie die Serveradresse und versuchen Sie es nochmal."; @@ -1191,7 +1375,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Die Nutzungsbedingungen der/des folgenden Betreiber(s) wurden schon akzeptiert: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Nutzungsbedingungen"; /* No comment provided by engineer. */ @@ -1207,7 +1391,7 @@ set passcode view */ "Configure ICE servers" = "ICE-Server konfigurieren"; /* No comment provided by engineer. */ -"Configure server operators" = "Server-Betreiber konfigurieren"; +"Configure relays" = "Relais konfigurieren"; /* No comment provided by engineer. */ "Confirm" = "Bestätigen"; @@ -1242,7 +1426,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Bestätigt"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Verbinden"; /* No comment provided by engineer. */ @@ -1272,6 +1457,9 @@ set passcode view */ /* new chat sheet title */ "Connect via link" = "Über einen Link verbinden"; +/* No comment provided by engineer. */ +"Connect via link or QR code" = "Über einen Link oder QR-Code verbinden"; + /* new chat sheet title */ "Connect via one-time link" = "Über einen Einmal-Link verbinden"; @@ -1294,7 +1482,7 @@ set passcode view */ "Connected to desktop" = "Mit dem Desktop verbunden"; /* No comment provided by engineer. */ -"connecting" = "verbinde"; +"connecting" = "Verbinde"; /* No comment provided by engineer. */ "Connecting" = "Verbinden"; @@ -1309,7 +1497,7 @@ set passcode view */ "connecting (introduced)" = "Verbindung (erstellt)"; /* No comment provided by engineer. */ -"connecting (introduction invitation)" = "Verbinde (nach einer Einladung)"; +"connecting (introduction invitation)" = "Verbindung (nach einer Einladung)"; /* call status */ "connecting call" = "Anruf wird verbunden…"; @@ -1341,14 +1529,17 @@ set passcode view */ /* alert title */ "Connection error" = "Verbindungsfehler"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Verbindungsfehler (AUTH)"; /* chat list item title (it should not be shown */ "connection established" = "Verbindung hergestellt"; /* No comment provided by engineer. */ -"Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Server-Betreiber blockiert:\n%@"; +"Connection failed" = "Verbindung fehlgeschlagen"; + +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Serverbetreiber blockiert:\n%@"; /* No comment provided by engineer. */ "Connection not ready." = "Verbindung noch nicht bereit."; @@ -1383,6 +1574,9 @@ set passcode view */ /* profile update event chat item */ "contact %@ changed to %@" = "Der Kontaktname wurde von %1$@ auf %2$@ geändert"; +/* chat link info line */ +"Contact address" = "Kontaktadresse"; + /* No comment provided by engineer. */ "Contact allows" = "Der Kontakt erlaubt"; @@ -1443,6 +1637,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Weiter"; +/* No comment provided by engineer. */ +"Contribute" = "Unterstützen Sie uns"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Chat-Inhalte entfernt!"; @@ -1458,17 +1655,14 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Abrundung Ecken"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Richtiger Name für %@?"; -/* No comment provided by engineer. */ -"Create" = "Erstellen"; - /* No comment provided by engineer. */ "Create 1-time link" = "Einmal-Link erstellen"; /* No comment provided by engineer. */ -"Create a group using a random profile." = "Erstellen Sie eine Gruppe mit einem zufälligen Profil."; +"Create a group using a random profile." = "Gruppe mit einem zufälligen Profil erstellen."; /* server test step */ "Create file" = "Datei erstellen"; @@ -1480,7 +1674,7 @@ set passcode view */ "Create group link" = "Gruppenlink erstellen"; /* No comment provided by engineer. */ -"Create link" = "Link erzeugen"; +"Create link" = "Link erstellen"; /* No comment provided by engineer. */ "Create list" = "Liste erstellen"; @@ -1491,8 +1685,14 @@ set passcode view */ /* No comment provided by engineer. */ "Create profile" = "Profil erstellen"; +/* No comment provided by engineer. */ +"Create public channel" = "Öffentlichen Kanal erstellen"; + +/* No comment provided by engineer. */ +"Create public channel (BETA)" = "Öffentlichen Kanal erstellen (BETA)"; + /* server test step */ -"Create queue" = "Erzeuge Warteschlange"; +"Create queue" = "Warteschlange erstellen"; /* No comment provided by engineer. */ "Create SimpleX address" = "SimpleX-Adresse erstellen"; @@ -1501,7 +1701,13 @@ set passcode view */ "Create your address" = "Ihre Adresse erstellen"; /* No comment provided by engineer. */ -"Create your profile" = "Erstellen Sie Ihr Profil"; +"Create your link" = "Ihren Link erstellen"; + +/* No comment provided by engineer. */ +"Create your profile" = "Ihr Profil erstellen"; + +/* No comment provided by engineer. */ +"Create your public address" = "Ihre öffentliche Adresse erstellen"; /* No comment provided by engineer. */ "Created" = "Erstellt"; @@ -1515,6 +1721,9 @@ set passcode view */ /* No comment provided by engineer. */ "Creating archive link" = "Archiv-Link erzeugen"; +/* No comment provided by engineer. */ +"Creating channel" = "Kanal wird erstellt"; + /* No comment provided by engineer. */ "Creating link…" = "Link wird erstellt…"; @@ -1617,8 +1826,8 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Debugging-Zustellung"; -/* No comment provided by engineer. */ -"Decentralized" = "Dezentral"; +/* relay test step */ +"Decode link" = "Link dekodieren"; /* message decrypt error item */ "Decryption error" = "Entschlüsselungsfehler"; @@ -1661,6 +1870,12 @@ swipe action */ /* No comment provided by engineer. */ "Delete and notify contact" = "Kontakt löschen und benachrichtigen"; +/* No comment provided by engineer. */ +"Delete channel" = "Kanal löschen"; + +/* No comment provided by engineer. */ +"Delete channel?" = "Kanal löschen?"; + /* No comment provided by engineer. */ "Delete chat" = "Chat löschen"; @@ -1730,10 +1945,17 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Nachricht des Mitglieds löschen?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Mitgliedsnachrichten löschen"; + +/* alert title */ +"Delete member messages?" = "Mitgliedsnachrichten löschen?"; + /* No comment provided by engineer. */ "Delete message?" = "Die Nachricht löschen?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Nachrichten löschen"; /* No comment provided by engineer. */ @@ -1757,6 +1979,9 @@ swipe action */ /* server test step */ "Delete queue" = "Lösche Warteschlange"; +/* No comment provided by engineer. */ +"Delete relay" = "Relais löschen"; + /* No comment provided by engineer. */ "Delete report" = "Meldung löschen"; @@ -1781,6 +2006,9 @@ swipe action */ /* copied message info */ "Deleted at: %@" = "Gelöscht um: %@"; +/* rcv group event chat item */ +"deleted channel" = "Kanal gelöscht"; + /* rcv direct event chat item */ "deleted contact" = "Gelöschter Kontakt"; @@ -1871,6 +2099,12 @@ swipe action */ /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "In dieser Gruppe sind Direktnachrichten zwischen Mitgliedern nicht erlaubt."; +/* No comment provided by engineer. */ +"Direct messages between subscribers are prohibited." = "Direktnachrichten zwischen Abonnenten sind nicht erlaubt."; + +/* alert button */ +"Disable" = "Deaktivieren"; + /* No comment provided by engineer. */ "Disable (keep overrides)" = "Deaktivieren (vorgenommene Einstellungen bleiben erhalten)"; @@ -1887,7 +2121,7 @@ swipe action */ "Disable SimpleX Lock" = "SimpleX-Sperre deaktivieren"; /* No comment provided by engineer. */ -"disabled" = "deaktiviert"; +"disabled" = "Deaktiviert"; /* No comment provided by engineer. */ "Disabled" = "Deaktiviert"; @@ -1928,6 +2162,9 @@ swipe action */ /* No comment provided by engineer. */ "Do not send history to new members." = "Den Nachrichtenverlauf nicht an neue Mitglieder senden."; +/* No comment provided by engineer. */ +"Do not send history to new subscribers." = "Den Nachrichtenverlauf nicht an neue Abonnenten senden."; + /* No comment provided by engineer. */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "Nachrichten werden nicht direkt versendet, selbst wenn Ihr oder der Zielserver kein privates Routing unterstützt."; @@ -2007,27 +2244,39 @@ chat item action */ /* No comment provided by engineer. */ "E2E encrypted notifications." = "E2E-verschlüsselte Benachrichtigungen."; +/* No comment provided by engineer. */ +"Easier to invite your friends 👋" = "Freunde einladen – jetzt noch einfacher 👋"; + /* chat item action */ "Edit" = "Bearbeiten"; +/* No comment provided by engineer. */ +"Edit channel profile" = "Kanalprofil bearbeiten"; + /* No comment provided by engineer. */ "Edit group profile" = "Gruppenprofil bearbeiten"; /* No comment provided by engineer. */ "Empty message!" = "Leere Nachricht!"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Aktivieren"; /* No comment provided by engineer. */ "Enable (keep overrides)" = "Aktivieren (vorgenommene Einstellungen bleiben erhalten)"; +/* channel creation warning */ +"Enable at least one chat relay in Network & Servers." = "Aktivieren Sie mindestens ein Chat‑Relais unter 'Netzwerk & Server'."; + /* alert title */ "Enable automatic message deletion?" = "Automatisches Löschen von Nachrichten aktivieren?"; /* No comment provided by engineer. */ "Enable camera access" = "Kamera-Zugriff aktivieren"; +/* alert title */ +"Enable chats with admins?" = "Chats mit Administratoren aktivieren?"; + /* No comment provided by engineer. */ "Enable disappearing messages by default." = "Verschwindende Nachrichten sind per Voreinstellung aktiviert."; @@ -2043,11 +2292,11 @@ chat item action */ /* No comment provided by engineer. */ "Enable instant notifications?" = "Sofortige Benachrichtigungen aktivieren?"; -/* No comment provided by engineer. */ -"Enable lock" = "Sperre aktivieren"; +/* alert title */ +"Enable link previews?" = "Linkvorschau aktivieren?"; /* No comment provided by engineer. */ -"Enable notifications" = "Benachrichtigungen aktivieren"; +"Enable lock" = "Sperre aktivieren"; /* No comment provided by engineer. */ "Enable periodic notifications?" = "Periodische Benachrichtigungen aktivieren?"; @@ -2154,6 +2403,9 @@ chat item action */ /* call status */ "ended call %@" = "Anruf beendet %@"; +/* No comment provided by engineer. */ +"Enter channel name…" = "Kanalname eingeben…"; + /* No comment provided by engineer. */ "Enter correct passphrase." = "Geben Sie das korrekte Passwort ein."; @@ -2172,6 +2424,12 @@ chat item action */ /* No comment provided by engineer. */ "Enter password above to show!" = "Für die Anzeige das Passwort im Suchfeld eingeben!"; +/* No comment provided by engineer. */ +"Enter profile name..." = "Profilname eingeben..."; + +/* No comment provided by engineer. */ +"Enter relay name…" = "Relais-Name eingeben…"; + /* No comment provided by engineer. */ "Enter server manually" = "Geben Sie den Server manuell ein"; @@ -2190,7 +2448,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "Fehler"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Fehler"; /* No comment provided by engineer. */ @@ -2208,6 +2466,9 @@ chat item action */ /* No comment provided by engineer. */ "Error adding member(s)" = "Fehler beim Hinzufügen von Mitgliedern"; +/* alert title */ +"Error adding relay" = "Fehler beim Hinzufügen des Relais"; + /* alert title */ "Error adding server" = "Fehler beim Hinzufügen des Servers"; @@ -2238,9 +2499,15 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Fehler beim Herstellen der Verbindung zum Server, der für den Empfang von Nachrichten dieser Verbindung genutzt wird: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Fehler beim Erstellen der Adresse"; +/* alert title */ +"Error creating channel" = "Fehler beim Erstellen des Kanals"; + /* No comment provided by engineer. */ "Error creating group" = "Fehler beim Erzeugen der Gruppe"; @@ -2322,9 +2589,6 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "Fehler beim Öffnen des Chat"; -/* No comment provided by engineer. */ -"Error opening group" = "Fehler beim Vorbereiten der Gruppe"; - /* alert title */ "Error receiving file" = "Fehler beim Herunterladen der Datei"; @@ -2349,6 +2613,9 @@ chat item action */ /* No comment provided by engineer. */ "Error resetting statistics" = "Fehler beim Zurücksetzen der Statistiken"; +/* No comment provided by engineer. */ +"Error saving channel profile" = "Fehler beim Speichern des Kanalprofils"; + /* alert title */ "Error saving chat list" = "Fehler beim Speichern der Chat-Liste"; @@ -2391,6 +2658,9 @@ chat item action */ /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Fehler beim Setzen von Empfangsbestätigungen!"; +/* alert title */ +"Error sharing channel" = "Fehler beim Teilen des Kanals"; + /* No comment provided by engineer. */ "Error starting chat" = "Fehler beim Starten des Chats"; @@ -2433,11 +2703,18 @@ chat item action */ /* No comment provided by engineer. */ "Error: " = "Fehler: "; +/* receive error chat item */ +"error: %@" = "Fehler: %@"; + /* alert message file error text snd error text */ "Error: %@" = "Fehler: %@"; +/* relay test error +server test error */ +"Error: %@." = "Fehler: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Fehler: Keine Datenbankdatei"; @@ -2483,6 +2760,9 @@ snd error text */ /* No comment provided by engineer. */ "Exporting database archive…" = "Exportieren des Datenbank-Archivs…"; +/* No comment provided by engineer. */ +"failed" = "Fehlgeschlagen"; + /* No comment provided by engineer. */ "Failed to remove passphrase" = "Das Entfernen des Passworts ist fehlgeschlagen"; @@ -2511,7 +2791,7 @@ snd error text */ "File errors:\n%@" = "Datei-Fehler:\n%@"; /* file error text */ -"File is blocked by server operator:\n%@." = "Datei wurde vom Server-Betreiber blockiert:\n%@."; +"File is blocked by server operator:\n%@." = "Die Datei wurde vom Serverbetreiber blockiert:\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen."; @@ -2558,6 +2838,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "Dateien und Medien sind nicht erlaubt!"; +/* No comment provided by engineer. */ +"Filter" = "Filter"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Nach ungelesenen und favorisierten Chats filtern."; @@ -2573,8 +2856,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Chats schneller finden"; -/* server test error */ -"Fingerprint in server address does not match certificate." = "Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %@."; + +/* relay test error +server test error */ +"Fingerprint in server address does not match certificate." = "Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein."; /* No comment provided by engineer. */ "Fix" = "Reparieren"; @@ -2597,7 +2890,11 @@ snd error text */ /* No comment provided by engineer. */ "For all moderators" = "Für alle Moderatoren"; -/* servers error */ +/* No comment provided by engineer. */ +"For anyone to reach you" = "Damit Sie jeder erreichen kann"; + +/* servers error +servers warning */ "For chat profile %@:" = "Für das Chat-Profil %@:"; /* No comment provided by engineer. */ @@ -2681,9 +2978,15 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "Weiter reduzierter Batterieverbrauch"; +/* relay test step */ +"Get link" = "Link erhalten"; + /* No comment provided by engineer. */ "Get notified when mentioned." = "Bei Erwähnung benachrichtigt werden."; +/* No comment provided by engineer. */ +"Get started" = "Jetzt starten"; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIFs und Sticker"; @@ -2729,7 +3032,7 @@ snd error text */ /* No comment provided by engineer. */ "group is deleted" = "Gruppe wurde gelöscht"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Gruppen-Link"; /* No comment provided by engineer. */ @@ -2801,6 +3104,9 @@ snd error text */ /* No comment provided by engineer. */ "History is not sent to new members." = "Der Nachrichtenverlauf wird nicht an neue Gruppenmitglieder gesendet."; +/* No comment provided by engineer. */ +"History is not sent to new subscribers." = "Der Nachrichtenverlauf wird nicht an neue Abonnenten gesendet."; + /* time unit */ "hours" = "Stunden"; @@ -2840,6 +3146,9 @@ snd error text */ /* No comment provided by engineer. */ "If you enter your self-destruct passcode while opening the app:" = "Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben:"; +/* down migration warning */ +"If you joined or created channels, they will stop working permanently." = "Kanäle, welche Sie erstellt haben oder denen Sie beigetreten sind, werden dauerhaft deaktiviert."; + /* No comment provided by engineer. */ "If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Tippen Sie unten auf **Später wiederholen**, wenn Sie den Chat jetzt benötigen (es wird Ihnen angeboten, die Datenbank bei einem Neustart der App zu migrieren)."; @@ -2853,10 +3162,10 @@ snd error text */ "Image will be received when your contact is online, please wait or check later!" = "Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; /* No comment provided by engineer. */ -"Immediately" = "Sofort"; +"Images" = "Bilder"; /* No comment provided by engineer. */ -"Immune to spam" = "Immun gegen Spam und Missbrauch"; +"Immediately" = "Sofort"; /* No comment provided by engineer. */ "Import" = "Importieren"; @@ -2958,7 +3267,7 @@ snd error text */ "Initial role" = "Anfängliche Rolle"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Installieren Sie [SimpleX Chat als Terminalanwendung](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Installieren Sie SimpleX Chat als Terminalanwendung"; /* No comment provided by engineer. */ "Instant" = "Sofort"; @@ -2993,7 +3302,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "Ungültige Chat-Daten"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Ungültiger Verbindungslink"; /* invalid chat item */ @@ -3008,12 +3317,18 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Migrations-Bestätigung ungültig"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Ungültiger Name!"; /* No comment provided by engineer. */ "Invalid QR code" = "Ungültiger QR-Code"; +/* alert title */ +"Invalid relay address!" = "Ungültige Relais-Adresse!"; + +/* alert title */ +"Invalid relay name!" = "Ungültiger Relais-Name!"; + /* No comment provided by engineer. */ "Invalid response" = "Ungültige Reaktion"; @@ -3035,9 +3350,15 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Freunde einladen"; +/* No comment provided by engineer. */ +"Invite member" = "Mitglied einladen"; + /* No comment provided by engineer. */ "Invite members" = "Mitglieder einladen"; +/* No comment provided by engineer. */ +"Invite someone privately" = "Für privaten Chat einladen"; + /* No comment provided by engineer. */ "Invite to chat" = "Zum Chat einladen"; @@ -3104,6 +3425,9 @@ snd error text */ /* No comment provided by engineer. */ "Join as %@" = "Als %@ beitreten"; +/* No comment provided by engineer. */ +"Join channel" = "Kanal beitreten"; + /* new chat sheet title */ "Join group" = "Treten Sie der Gruppe bei"; @@ -3152,6 +3476,12 @@ snd error text */ /* swipe action */ "Leave" = "Verlassen"; +/* No comment provided by engineer. */ +"Leave channel" = "Kanal verlassen"; + +/* No comment provided by engineer. */ +"Leave channel?" = "Kanal verlassen?"; + /* No comment provided by engineer. */ "Leave chat" = "Chat verlassen"; @@ -3170,6 +3500,9 @@ snd error text */ /* No comment provided by engineer. */ "Less traffic on mobile networks." = "Weniger Datenverkehr in mobilen Netzen."; +/* No comment provided by engineer. */ +"Let someone connect to you" = "Jemand mit Ihnen verbinden lassen"; + /* email subject */ "Let's talk in SimpleX Chat" = "Lassen Sie uns in SimpleX Chat kommunizieren"; @@ -3179,15 +3512,24 @@ snd error text */ /* No comment provided by engineer. */ "Limitations" = "Einschränkungen"; +/* No comment provided by engineer. */ +"link" = "Link"; + /* No comment provided by engineer. */ "Link mobile and desktop apps! 🔗" = "Verknüpfe Mobiltelefon- und Desktop-Apps! 🔗"; +/* owner verification */ +"Link signature verified." = "Linksignatur erfolgreich überprüft."; + /* No comment provided by engineer. */ "Linked desktop options" = "Verknüpfte Desktop-Optionen"; /* No comment provided by engineer. */ "Linked desktops" = "Verknüpfte Desktops"; +/* No comment provided by engineer. */ +"Links" = "Links"; + /* swipe action */ "List" = "Liste"; @@ -3270,7 +3612,7 @@ snd error text */ "Member admission" = "Aufnahme von Mitgliedern"; /* rcv group event chat item */ -"member connected" = "ist der Gruppe beigetreten"; +"member connected" = "Verbunden"; /* No comment provided by engineer. */ "member has old version" = "Das Mitglied hat eine alte App-Version"; @@ -3281,6 +3623,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Mitglied ist gelöscht - Anfrage kann nicht angenommen werden"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden!"; + /* chat feature */ "Member reports" = "Mitglieder-Meldungen"; @@ -3293,10 +3638,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Die Mitgliederrolle wird auf \"%@\" geändert. Das Mitglied wird eine neue Einladung erhalten."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Das Mitglied wird aus dem Chat entfernt. Dies kann nicht rückgängig gemacht werden!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden!"; /* alert message */ @@ -3305,6 +3650,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can add message reactions." = "Gruppenmitglieder können eine Reaktion auf Nachrichten geben."; +/* No comment provided by engineer. */ +"Members can chat with admins." = "Mitglieder können mit Administratoren chatten."; + /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden)"; @@ -3347,6 +3695,9 @@ snd error text */ /* No comment provided by engineer. */ "Message draft" = "Nachrichtenentwurf"; +/* No comment provided by engineer. */ +"Message error" = "Übertragungsfehler"; + /* item status text */ "Message forwarded" = "Nachricht weitergeleitet"; @@ -3407,6 +3758,12 @@ snd error text */ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Die Nachrichten von %@ werden angezeigt!"; +/* No comment provided by engineer. */ +"Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages." = "Nachrichten in diesem Kanal sind **nicht Ende‑zu‑Ende‑verschlüsselt**. Chat‑Relais können diese Nachrichten sehen."; + +/* E2EE info chat item */ +"Messages in this channel are not end-to-end encrypted. Chat relays can see these messages." = "Nachrichten in diesem Kanal sind nicht Ende‑zu‑Ende‑verschlüsselt. Chat‑Relais können diese Nachrichten sehen."; + /* alert message */ "Messages in this chat will never be deleted." = "Nachrichten in diesem Chat werden nie gelöscht."; @@ -3426,10 +3783,10 @@ snd error text */ "Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt."; /* No comment provided by engineer. */ -"Migrate device" = "Gerät migrieren"; +"Migrate" = "Migrieren"; /* No comment provided by engineer. */ -"Migrate from another device" = "Von einem anderen Gerät migrieren"; +"Migrate device" = "Gerät migrieren"; /* No comment provided by engineer. */ "Migrate here" = "Hierher migrieren"; @@ -3521,12 +3878,18 @@ snd error text */ /* No comment provided by engineer. */ "Network & servers" = "Netzwerk & Server"; +/* No comment provided by engineer. */ +"Network commitments" = "Netzwerk Verpflichtungen"; + /* No comment provided by engineer. */ "Network connection" = "Netzwerkverbindung"; /* No comment provided by engineer. */ "Network decentralization" = "Dezentralisiertes Netzwerk"; +/* conn error description */ +"Network error" = "Netzwerk-Fehler"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Netzwerk-Fehler - die Nachricht ist nach vielen Sende-Versuchen abgelaufen."; @@ -3537,23 +3900,35 @@ snd error text */ "Network operator" = "Netzwerk-Betreiber"; /* No comment provided by engineer. */ -"Network settings" = "Netzwerkeinstellungen"; +"Network routers cannot know\nwho talks to whom" = "Netzwerk‑Router können nicht erkennen,\nwer mit wem kommuniziert"; /* No comment provided by engineer. */ +"Network settings" = "Netzwerkeinstellungen"; + +/* alert title */ "Network status" = "Netzwerkstatus"; /* delete after time */ "never" = "nie"; +/* No comment provided by engineer. */ +"new" = "Neu"; + /* token status text */ "New" = "Neu"; +/* No comment provided by engineer. */ +"New 1-time link" = "Neuer Einmal-Link"; + /* No comment provided by engineer. */ "New chat" = "Neuer Chat"; /* No comment provided by engineer. */ "New chat experience 🎉" = "Neue Chat-Erfahrung 🎉"; +/* No comment provided by engineer. */ +"New chat relay" = "Neues Chat-Relais"; + /* notification */ "New contact request" = "Neue Kontaktanfrage"; @@ -3611,9 +3986,21 @@ snd error text */ /* No comment provided by engineer. */ "No" = "Nein"; +/* No comment provided by engineer. */ +"No account. No phone. No email. No ID.\nThe most secure encryption." = "Kein Account. Keine Telefonnummer. Keine E‑Mail. Keine ID.\nDie sicherste Verschlüsselung."; + +/* No comment provided by engineer. */ +"No active relays" = "Keine aktiven Relais"; + /* Authentication unavailable */ "No app password" = "Kein App-Passwort"; +/* No comment provided by engineer. */ +"No chat relays" = "Keine Chat-Relais"; + +/* servers warning */ +"No chat relays enabled." = "Es sind keine Chat-Relais aktiviert."; + /* No comment provided by engineer. */ "No chats" = "Keine Chats"; @@ -3698,6 +4085,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Keine Server für das Versenden von Dateien."; +/* No comment provided by engineer. */ +"no subscription" = "Kein Abonnement"; + /* copied message info in history */ "no text" = "Kein Text"; @@ -3708,7 +4098,16 @@ snd error text */ "No unread chats" = "Keine ungelesenen Chats"; /* No comment provided by engineer. */ -"No user identifiers." = "Keine Benutzerkennungen."; +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Niemand verfolgte Ihre Gespräche. Niemand erstellte eine Karte, wo Sie sich aufgehalten haben. Privatsphäre war nie ein Feature - sie war selbstverständlich."; + +/* No comment provided by engineer. */ +"Non-profit governance" = "Non‑Profit‑Governance"; + +/* No comment provided by engineer. */ +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Nicht ein besseres Schloss an der Tür eines Anderen. Kein freundlicher Vermieter, der Ihre Privatsphäre respektiert, aber dennoch jeden Besucher registriert. Sie sind kein Gast. Sie sind zu Hause. Kein Vermieter, kein Fremder kann es betreten - Sie sind souverän."; + +/* alert title */ +"Not all relays connected" = "Es sind nicht alle Relais verbunden"; /* No comment provided by engineer. */ "Not compatible!" = "Nicht kompatibel!"; @@ -3766,7 +4165,7 @@ alert button new chat action */ "Ok" = "Ok"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "OK"; /* No comment provided by engineer. */ @@ -3775,9 +4174,15 @@ new chat action */ /* group pref value */ "on" = "Ein"; +/* No comment provided by engineer. */ +"On your phone, not on servers." = "Auf Ihrem Gerät, nicht auf Servern."; + /* No comment provided by engineer. */ "One-time invitation link" = "Einmal-Einladungslink"; +/* chat link info line */ +"One-time link" = "Einmal-Link"; + /* No comment provided by engineer. */ "Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Für diese Verbindung werden Onion-Hosts benötigt.\nDies erfordert die Aktivierung eines VPNs."; @@ -3788,7 +4193,10 @@ new chat action */ "Onion hosts will not be used." = "Onion-Hosts werden nicht verwendet."; /* No comment provided by engineer. */ -"Only chat owners can change preferences." = "Nur Chat-Eigentümer können die Präferenzen ändern."; +"Only channel owners can change channel preferences." = "Kanal-Präferenzen können nur von Kanal-Eigentümern geändert werden."; + +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Präferenzen können nur von Chat-Eigentümern geändert werden."; /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Nur die Endgeräte speichern die Benutzerprofile, Kontakte, Gruppen und Nachrichten, welche über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden."; @@ -3847,12 +4255,16 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Nur Ihr Kontakt kann Sprachnachrichten versenden."; -/* alert action */ +/* alert action +alert button */ "Open" = "Öffnen"; /* No comment provided by engineer. */ "Open changes" = "Änderungen öffnen"; +/* new chat action */ +"Open channel" = "Kanal öffnen"; + /* new chat action */ "Open chat" = "Chat öffnen"; @@ -3865,6 +4277,9 @@ new chat action */ /* No comment provided by engineer. */ "Open conditions" = "Nutzungsbedingungen öffnen"; +/* alert title */ +"Open external link?" = "Externen Link öffnen?"; + /* alert action */ "Open full link" = "Vollständigen Link öffnen"; @@ -3877,6 +4292,9 @@ new chat action */ /* authentication reason */ "Open migration to another device" = "Migration auf ein anderes Gerät öffnen"; +/* new chat action */ +"Open new channel" = "Neuen Kanal öffnen"; + /* new chat action */ "Open new chat" = "Neuen Chat öffnen"; @@ -3907,6 +4325,9 @@ new chat action */ /* alert title */ "Operator server" = "Betreiber-Server"; +/* No comment provided by engineer. */ +"Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code" = "Betreiber verpflichten sich:\n- Unabhängig zu bleiben\n- Metadaten auf ein Minimum zu reduzieren\n- Geprüften Open‑Source‑Code einzusetzen"; + /* No comment provided by engineer. */ "Or import archive file" = "Oder importieren Sie eine Archiv-Datei"; @@ -3919,12 +4340,18 @@ new chat action */ /* No comment provided by engineer. */ "Or securely share this file link" = "Oder teilen Sie diesen Datei-Link sicher"; +/* No comment provided by engineer. */ +"Or show QR in person or via video call." = "Oder den QR‑Code persönlich oder per Videoanruf zeigen."; + /* No comment provided by engineer. */ "Or show this code" = "Oder diesen QR-Code anzeigen"; /* No comment provided by engineer. */ "Or to share privately" = "Oder zum privaten Teilen"; +/* No comment provided by engineer. */ +"Or use this QR - print or show online." = "Oder diesen QR‑Code verwenden – ausgedruckt oder online."; + /* No comment provided by engineer. */ "Organize chats into lists" = "Chats in Listen verwalten"; @@ -3943,9 +4370,18 @@ new chat action */ /* member role */ "owner" = "Eigentümer"; +/* No comment provided by engineer. */ +"Owner" = "Eigentümer"; + /* feature role */ "owners" = "Eigentümer"; +/* No comment provided by engineer. */ +"Owners" = "Eigentümer"; + +/* No comment provided by engineer. */ +"Ownership: you can run your own relays." = "Volle Kontrolle: Sie können Ihre eigenen Relais betreiben."; + /* No comment provided by engineer. */ "Passcode" = "Zugangscode"; @@ -3973,6 +4409,9 @@ new chat action */ /* No comment provided by engineer. */ "Paste image" = "Bild einfügen"; +/* No comment provided by engineer. */ +"Paste link / Scan" = "Link einfügen / Scannen"; + /* No comment provided by engineer. */ "Paste link to connect!" = "Zum Verbinden den Link einfügen!"; @@ -4081,6 +4520,12 @@ new chat action */ /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Den letzten Nachrichtenentwurf, auch mit seinen Anhängen, aufbewahren."; +/* No comment provided by engineer. */ +"Preset relay address" = "Voreingestellte Relais-Adresse"; + +/* No comment provided by engineer. */ +"Preset relay name" = "Voreingestellter Relais-Name"; + /* No comment provided by engineer. */ "Preset server address" = "Voreingestellte Serveradresse"; @@ -4103,10 +4548,10 @@ new chat action */ "Privacy policy and conditions of use." = "Datenschutz- und Nutzungsbedingungen."; /* No comment provided by engineer. */ -"Privacy redefined" = "Datenschutz neu definiert"; +"Privacy: for owners and subscribers." = "Privatsphäre: für Besitzer und Abonnenten."; /* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich."; +"Private and secure messaging." = "Private und sichere Kommunikation."; /* No comment provided by engineer. */ "Private filenames" = "Neutrale Dateinamen"; @@ -4132,6 +4577,9 @@ new chat action */ /* alert title */ "Private routing timeout" = "Zeitüberschreitung der privaten Routing-Sitzung"; +/* alert action */ +"Proceed" = "Fortfahren"; + /* No comment provided by engineer. */ "Profile and server connections" = "Profil und Serververbindungen"; @@ -4148,11 +4596,14 @@ new chat action */ "Profile theme" = "Profil-Design"; /* alert message */ -"Profile update will be sent to your contacts." = "Profil-Aktualisierung wird an Ihre Kontakte gesendet."; +"Profile update will be sent to your SimpleX contacts." = "Profil-Aktualisierung wird an Ihre SimpleX-Kontakte gesendet."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Audio-/Video-Anrufe nicht erlauben."; +/* No comment provided by engineer. */ +"Prohibit chats with admins." = "Chat mit Administratoren nicht erlauben."; + /* No comment provided by engineer. */ "Prohibit irreversible message deletion." = "Unwiederbringliches löschen von Nachrichten nicht erlauben."; @@ -4168,6 +4619,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Das Senden von Direktnachrichten an Gruppenmitglieder nicht erlauben."; +/* No comment provided by engineer. */ +"Prohibit sending direct messages to subscribers." = "Das Senden von Direktnachrichten an Abonnenten nicht erlauben."; + /* No comment provided by engineer. */ "Prohibit sending disappearing messages." = "Das Senden von verschwindenden Nachrichten nicht erlauben."; @@ -4210,6 +4664,9 @@ new chat action */ /* No comment provided by engineer. */ "Proxy requires password" = "Der Proxy benötigt ein Passwort"; +/* No comment provided by engineer. */ +"Public channels - speak freely 🚀" = "Öffentliche Kanäle – frei sprechen 🚀"; + /* No comment provided by engineer. */ "Push notifications" = "Push-Benachrichtigungen"; @@ -4238,16 +4695,10 @@ new chat action */ "Read more" = "Mehr erfahren"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Lesen Sie mehr dazu im [Benutzerhandbuch](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Erfahren Sie in unserem GitHub-Repository mehr dazu."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Mehr dazu in der [Benutzeranleitung](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses) lesen."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Mehr dazu in der [Benutzeranleitung](https://simplex.chat/docs/guide/readme.html#connect-to-friends) lesen."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Erfahren Sie in unserem [GitHub-Repository](https://github.com/simplex-chat/simplex-chat#readme) mehr dazu."; +"Read more in User Guide." = "Lesen Sie mehr dazu im Benutzerhandbuch."; /* No comment provided by engineer. */ "Receipts are disabled" = "Bestätigungen sind deaktiviert"; @@ -4267,9 +4718,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "Bestätigung erhalten…"; -/* notification */ -"Received file event" = "Datei-Ereignis empfangen"; - /* message info title */ "Received message" = "Empfangene Nachricht"; @@ -4359,6 +4807,24 @@ swipe action */ /* call status */ "rejected call" = "Abgelehnter Anruf"; +/* member role */ +"relay" = "Relais"; + +/* No comment provided by engineer. */ +"Relay" = "Relais"; + +/* alert title */ +"Relay address" = "Relais-Adresse"; + +/* alert title */ +"Relay connection failed" = "Relais-Verbindung fehlgeschlagen"; + +/* No comment provided by engineer. */ +"Relay link" = "Relais-Link"; + +/* alert message */ +"Relay results:" = "Relay‑Status:"; + /* No comment provided by engineer. */ "Relay server is only used if necessary. Another party can observe your IP address." = "Relais-Server werden nur genutzt, wenn sie benötigt werden. Ihre IP-Adresse kann von Anderen erfasst werden."; @@ -4366,8 +4832,17 @@ swipe action */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relais-Server schützen Ihre IP-Adresse, aber sie können die Anrufdauer erfassen."; /* No comment provided by engineer. */ +"Relay test failed!" = "Relais-Test fehlgeschlagen!"; + +/* No comment provided by engineer. */ +"Reliability: many relays per channel." = "Zuverlässigkeit: mehrere Relais pro Kanal."; + +/* alert action */ "Remove" = "Entfernen"; +/* alert action */ +"Remove and delete messages" = "Mitglied entfernen und Nachrichten löschen"; + /* No comment provided by engineer. */ "Remove archive?" = "Archiv entfernen?"; @@ -4380,18 +4855,30 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Mitglied entfernen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Das Mitglied entfernen?"; /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Passwort aus dem Schlüsselbund entfernen?"; +/* No comment provided by engineer. */ +"Remove subscriber" = "Abonnent entfernen"; + +/* alert title */ +"Remove subscriber?" = "Abonnent entfernen?"; + /* No comment provided by engineer. */ "removed" = "entfernt"; +/* receive error chat item */ +"removed (%d attempts)" = "Entfernt (%d Versuche)"; + /* rcv group event chat item */ "removed %@" = "hat %@ aus der Gruppe entfernt"; +/* No comment provided by engineer. */ +"removed by operator" = "Vom Betreiber entfernt"; + /* profile update event chat item */ "removed contact address" = "Die Kontaktadresse wurde entfernt"; @@ -4560,6 +5047,9 @@ swipe action */ /* No comment provided by engineer. */ "Run chat" = "Chat starten"; +/* No comment provided by engineer. */ +"Safe web links" = "Sichere Web-Links"; + /* No comment provided by engineer. */ "Safely receive files" = "Dateien sicher herunterladen"; @@ -4576,6 +5066,9 @@ chat item action */ /* alert button */ "Save (and notify members)" = "Speichern (und Mitglieder benachrichtigen)"; +/* alert button */ +"Save (and notify subscribers)" = "Speichern (Abonnenten benachrichtigen)"; + /* alert title */ "Save admission settings?" = "Speichern der Aufnahme-Einstellungen?"; @@ -4585,12 +5078,21 @@ chat item action */ /* No comment provided by engineer. */ "Save and notify group members" = "Speichern und Gruppenmitglieder benachrichtigen"; +/* No comment provided by engineer. */ +"Save and notify subscribers" = "Speichern und Abonnenten benachrichtigen"; + /* No comment provided by engineer. */ "Save and reconnect" = "Speichern und neu verbinden"; /* No comment provided by engineer. */ "Save and update group profile" = "Gruppen-Profil sichern und aktualisieren"; +/* No comment provided by engineer. */ +"Save channel profile" = "Kanalprofil speichern"; + +/* alert title */ +"Save channel profile?" = "Kanalprofil speichern?"; + /* No comment provided by engineer. */ "Save group profile" = "Gruppenprofil speichern"; @@ -4675,9 +5177,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "In der Suchleiste werden nun auch Einladungslinks angenommen."; +/* No comment provided by engineer. */ +"Search files" = "Dateien suchen"; + +/* No comment provided by engineer. */ +"Search images" = "Bilder suchen"; + +/* No comment provided by engineer. */ +"Search links" = "Links suchen"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Suchen oder SimpleX-Link einfügen"; +/* No comment provided by engineer. */ +"Search videos" = "Videos suchen"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Sprachnachrichten suchen"; + /* network option */ "sec" = "sek"; @@ -4697,7 +5214,7 @@ chat item action */ "Secured" = "Abgesichert"; /* No comment provided by engineer. */ -"Security assessment" = "Sicherheits-Gutachten"; +"Security assessment" = "Security-Gutachten"; /* No comment provided by engineer. */ "Security code" = "Sicherheitscode"; @@ -4705,6 +5222,9 @@ chat item action */ /* chat item text */ "security code changed" = "Sicherheitscode wurde geändert"; +/* No comment provided by engineer. */ +"Security: owners hold channel keys." = "Sicherheit: Eigentümer besitzen die Kanalschlüssel."; + /* chat item action */ "Select" = "Auswählen"; @@ -4715,7 +5235,7 @@ chat item action */ "Selected %lld" = "%lld ausgewählt"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt."; +"Selected chat preferences prohibit this message." = "Diese Nachricht ist wegen der gewählten Chat-Präferenzen nicht erlaubt."; /* No comment provided by engineer. */ "Self-destruct" = "Selbstzerstörung"; @@ -4751,7 +5271,7 @@ chat item action */ "Send errors" = "Fehler beim Senden"; /* No comment provided by engineer. */ -"Send link previews" = "Link-Vorschau senden"; +"Send link previews" = "Linkvorschau senden"; /* No comment provided by engineer. */ "Send live message" = "Live Nachricht senden"; @@ -4783,12 +5303,18 @@ chat item action */ /* No comment provided by engineer. */ "Send request without message" = "Anfrage ohne Nachricht senden"; +/* No comment provided by engineer. */ +"Send the link via any messenger - it's secure. Ask to paste into SimpleX." = "Den Link über einen beliebigen Messenger versenden – es ist sicher. Bitte in SimpleX einfügen."; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Senden Sie diese aus dem Fotoalbum oder von individuellen Tastaturen."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Bis zu 100 der letzten Nachrichten an neue Gruppenmitglieder senden."; +/* No comment provided by engineer. */ +"Send up to 100 last messages to new subscribers." = "Bis zu 100 der letzten Nachrichten an neue Abonnenten senden."; + /* No comment provided by engineer. */ "Send your private feedback to groups." = "Senden Sie Ihr privates Feedback an Gruppen."; @@ -4798,6 +5324,9 @@ chat item action */ /* No comment provided by engineer. */ "Sender may have deleted the connection request." = "Der Absender hat möglicherweise die Verbindungsanfrage gelöscht."; +/* alert message */ +"Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later." = "Das Senden einer Link-Vorschau kann Ihre IP‑Adresse an die Website übermitteln. Sie können dies später in den Datenschutzeinstellungen ändern."; + /* No comment provided by engineer. */ "Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Das Senden von Empfangsbestätigungen an alle Kontakte in allen sichtbaren Chat-Profilen wird aktiviert."; @@ -4831,9 +5360,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direkt gesendet"; -/* notification */ -"Sent file event" = "Datei-Ereignis wurde gesendet"; - /* message info title */ "Sent message" = "Gesendete Nachricht"; @@ -4879,11 +5405,14 @@ chat item action */ /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "Server-Warteschlangen-Information: %1$@\n\nZuletzt empfangene Nachricht: %2$@"; -/* server test error */ -"Server requires authorization to create queues, check password." = "Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort"; +/* relay test error */ +"Server requires authorization to connect to relay, check password." = "Der Server erfordert eine Autorisierung, um eine Verbindung zum Relais herzustellen. Bitte Passwort überprüfen."; /* server test error */ -"Server requires authorization to upload, check password." = "Bitte das Passwort überprüfen - für den Upload benötigt der Server eine Berechtigung"; +"Server requires authorization to create queues, check password." = "Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort."; + +/* server test error */ +"Server requires authorization to upload, check password." = "Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort."; /* No comment provided by engineer. */ "Server test failed!" = "Server Test ist fehlgeschlagen!"; @@ -4963,6 +5492,12 @@ chat item action */ /* alert message */ "Settings were changed." = "Die Einstellungen wurden geändert."; +/* No comment provided by engineer. */ +"Setup notifications" = "Benachrichtigungen einrichten"; + +/* No comment provided by engineer. */ +"Setup routers" = "Router einrichten"; + /* No comment provided by engineer. */ "Shape profile images" = "Form der Profil-Bilder"; @@ -4983,7 +5518,10 @@ chat item action */ "Share address publicly" = "Die Adresse öffentlich teilen"; /* alert title */ -"Share address with contacts?" = "Die Adresse mit Kontakten teilen?"; +"Share address with SimpleX contacts?" = "Die Adresse mit SimpleX-Kontakten teilen?"; + +/* No comment provided by engineer. */ +"Share channel" = "Kanal teilen"; /* No comment provided by engineer. */ "Share from other apps." = "Aus anderen Apps heraus teilen."; @@ -5000,6 +5538,9 @@ chat item action */ /* No comment provided by engineer. */ "Share profile" = "Profil teilen"; +/* No comment provided by engineer. */ +"Share relay address" = "Relais-Adresse teilen"; + /* No comment provided by engineer. */ "Share SimpleX address on social media." = "Die SimpleX-Adresse auf sozialen Medien teilen."; @@ -5010,7 +5551,10 @@ chat item action */ "Share to SimpleX" = "Mit SimpleX teilen"; /* No comment provided by engineer. */ -"Share with contacts" = "Mit Kontakten teilen"; +"Share via chat" = "Per Chat teilen"; + +/* No comment provided by engineer. */ +"Share with SimpleX contacts" = "Mit SimpleX-Kontakten teilen"; /* No comment provided by engineer. */ "Share your address" = "Ihre Adresse teilen"; @@ -5070,7 +5614,7 @@ chat item action */ "SimpleX address settings" = "Einstellungen automatisch akzeptieren"; /* simplex link type */ -"SimpleX channel link" = "SimpleX-Kanal-Link"; +"SimpleX channel link" = "SimpleX-Kanallink"; /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX-Chat und Flux haben vereinbart, die von Flux betriebenen Server in die App aufzunehmen."; @@ -5115,7 +5659,7 @@ chat item action */ "SimpleX protocols reviewed by Trail of Bits." = "Die SimpleX-Protokolle wurden von Trail of Bits überprüft."; /* simplex link type */ -"SimpleX relay link" = "SimpleX Relais-Link"; +"SimpleX relay address" = "SimpleX Relais-Adresse"; /* No comment provided by engineer. */ "Simplified incognito mode" = "Vereinfachter Inkognito-Modus"; @@ -5169,6 +5713,9 @@ report reason */ /* chat item text */ "standard end-to-end encryption" = "Standard-Ende-zu-Ende-Verschlüsselung"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Stern auf GitHub vergeben"; + /* No comment provided by engineer. */ "Start chat" = "Starten Sie den Chat"; @@ -5235,6 +5782,48 @@ report reason */ /* No comment provided by engineer. */ "Subscribed" = "Abonniert"; +/* No comment provided by engineer. */ +"Subscriber" = "Abonnent"; + +/* chat feature */ +"Subscriber reports" = "Abonnenten-Meldungen"; + +/* alert message */ +"Subscriber will be removed from channel - this cannot be undone!" = "Abonnent wird aus dem Kanal entfernt. Dies kann nicht rückgängig gemacht werden!"; + +/* No comment provided by engineer. */ +"Subscribers" = "Abonnenten"; + +/* No comment provided by engineer. */ +"Subscribers can add message reactions." = "Abonnenten können eine Reaktion auf Nachrichten geben."; + +/* No comment provided by engineer. */ +"Subscribers can chat with admins." = "Abonnenten können mit Administratoren chatten."; + +/* No comment provided by engineer. */ +"Subscribers can irreversibly delete sent messages. (24 hours)" = "Abonnenten können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden)"; + +/* No comment provided by engineer. */ +"Subscribers can report messsages to moderators." = "Abonnenten können Nachrichten an Moderatoren melden."; + +/* No comment provided by engineer. */ +"Subscribers can send direct messages." = "Abonnenten können Direktnachrichten versenden."; + +/* No comment provided by engineer. */ +"Subscribers can send disappearing messages." = "Abonnenten können verschwindende Nachrichten versenden."; + +/* No comment provided by engineer. */ +"Subscribers can send files and media." = "Abonnenten können Dateien und Medien versenden."; + +/* No comment provided by engineer. */ +"Subscribers can send SimpleX links." = "Abonnenten können SimpleX-Links versenden."; + +/* No comment provided by engineer. */ +"Subscribers can send voice messages." = "Abonnenten können Sprachnachrichten versenden."; + +/* No comment provided by engineer. */ +"Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." = "Abonnenten verbinden sich über den Relais‑Link mit dem Kanal.\nDie Relais-Adresse wurde zur Einrichtung dieses Relais für diesen Kanal verwendet."; + /* No comment provided by engineer. */ "Subscription errors" = "Fehler beim Abonnieren"; @@ -5262,6 +5851,9 @@ report reason */ /* No comment provided by engineer. */ "Take picture" = "Machen Sie ein Foto"; +/* No comment provided by engineer. */ +"Talk to someone" = "Mit jemandem sprechen"; + /* No comment provided by engineer. */ "Tap button " = "Schaltfläche antippen "; @@ -5275,13 +5867,13 @@ report reason */ "Tap Connect to use bot" = "Verbinden tippen, um den Bot zu nutzen."; /* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Tippen Sie im Menü auf SimpleX-Adresse erstellen, um sie später zu erstellen."; +"Tap Join channel" = "Tippen, um dem Kanal beizutreten"; /* No comment provided by engineer. */ "Tap Join group" = "Tippen, um der Gruppe beizutreten"; /* No comment provided by engineer. */ -"Tap to activate profile." = "Zum Aktivieren des Profils tippen."; +"Tap to activate profile." = "Tippen, um das Profil zu aktivieren."; /* No comment provided by engineer. */ "Tap to Connect" = "Zum Verbinden tippen"; @@ -5293,7 +5885,10 @@ report reason */ "Tap to join incognito" = "Zum Inkognito beitreten tippen"; /* No comment provided by engineer. */ -"Tap to paste link" = "Zum Link einfügen tippen"; +"Tap to open" = "Zum Öffnen tippen"; + +/* No comment provided by engineer. */ +"Tap to paste link" = "Tippen, um den Link einzufügen"; /* No comment provided by engineer. */ "Tap to scan" = "Zum Scannen tippen"; @@ -5322,12 +5917,16 @@ report reason */ /* file error alert title */ "Temporary file error" = "Temporärer Datei-Fehler"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Der Test ist beim Schritt %@ fehlgeschlagen."; /* No comment provided by engineer. */ "Test notifications" = "Benachrichtigungen testen"; +/* No comment provided by engineer. */ +"Test relay" = "Relais testen"; + /* No comment provided by engineer. */ "Test server" = "Teste Server"; @@ -5355,6 +5954,9 @@ report reason */ /* No comment provided by engineer. */ "The app protects your privacy by using different operators in each conversation." = "Durch Verwendung verschiedener Netzwerk-Betreiber für jede Unterhaltung schützt die App Ihre Privatsphäre."; +/* No comment provided by engineer. */ +"The app removed this message after %lld attempts to receive it." = "Die App hat diese Nachricht nach %lld Empfangsversuchen entfernt."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "Die App wird eine Bestätigung bei Downloads von unbekannten Datei-Servern anfordern (außer bei .onion)."; @@ -5364,6 +5966,9 @@ report reason */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Der von Ihnen gescannte Code ist kein SimpleX-Link-QR-Code."; +/* conn error description */ +"The connection reached the limit of undelivered messages" = "Die Verbindung hat das Limit für nicht zugestellte Nachrichten erreicht"; + /* No comment provided by engineer. */ "The connection reached the limit of undelivered messages, your contact may be offline." = "Diese Verbindung hat das Limit der nicht ausgelieferten Nachrichten erreicht. Ihr Kontakt ist möglicherweise offline."; @@ -5380,7 +5985,7 @@ report reason */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Die Verschlüsselung funktioniert und ein neues Verschlüsselungsabkommen ist nicht erforderlich. Es kann zu Verbindungsfehlern kommen!"; /* No comment provided by engineer. */ -"The future of messaging" = "Die nächste Generation von privatem Messaging"; +"The first network where you own\nyour contacts and groups." = "Das erste Netzwerk,\nin dem Sie Ihre Kontakte und Gruppen besitzen."; /* No comment provided by engineer. */ "The hash of the previous message is different." = "Der Hash der vorherigen Nachricht unterscheidet sich."; @@ -5406,6 +6011,9 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Die alte Datenbank wurde während der Migration nicht entfernt. Sie kann gelöscht werden."; +/* No comment provided by engineer. */ +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "Die älteste Freiheit des Menschen - mit einem anderen Menschen sprechen zu können, ohne beobachtet zu werden - gestützt auf einer Infrastruktur, die Sie nicht verraten kann."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**."; @@ -5433,6 +6041,12 @@ report reason */ /* No comment provided by engineer. */ "Themes" = "Design"; +/* No comment provided by engineer. */ +"Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "Dann sind wir online gegangen, und jede Plattform wollte Etwas von Ihnen - Ihren Namen, Ihre Nummer, Ihre Freunde. Wir akzeptierten, dass es der Preis mit Anderen zu kommunizieren ist, Jemandem preiszugeben, mit wem und wie wir miteinander kommunizieren. Jede Generation, Menschen und Technologien, kannten es nur so - Telefon, E-Mail, Messenger, soziale Medien. Es schien der einzig mögliche Weg zu sein."; + +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "Es gibt einen anderen Weg. Ein Netzwerk ohne Telefonnummern, ohne Benutzernamen, ohne Benutzerkennungen und ohne jegliche Benutzeridentität. Ein Netzwerk, welches Menschen verbindet und verschlüsselte Nachrichten überträgt, ohne zu wissen, wer mit wem verbunden ist."; + /* No comment provided by engineer. */ "These conditions will also apply for: **%@**." = "Diese Nutzungsbedingungen gelten auch für: **%@**."; @@ -5475,6 +6089,12 @@ report reason */ /* No comment provided by engineer. */ "This group no longer exists." = "Diese Gruppe existiert nicht mehr."; +/* alert message */ +"This is a chat relay address, it cannot be used to connect." = "Dies ist eine Chat‑Relais-Adresse, welche nicht zum Verbinden verwendet werden kann."; + +/* new chat action */ +"This is your link for channel %@!" = "Dies ist Ihr Link für den Kanal %@!"; + /* No comment provided by engineer. */ "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden."; @@ -5508,6 +6128,9 @@ report reason */ /* No comment provided by engineer. */ "To make a new connection" = "Um eine Verbindung mit einem neuen Kontakt zu erstellen"; +/* No comment provided by engineer. */ +"To make SimpleX Network last." = "Für ein dauerhaftes SimpleX-Netzwerk."; + /* No comment provided by engineer. */ "To protect against your link being replaced, you can compare contact security codes." = "Zum Schutz vor dem Austausch Ihres Links können Sie die Sicherheitscodes Ihrer Kontakte vergleichen."; @@ -5556,9 +6179,6 @@ report reason */ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen."; -/* No comment provided by engineer. */ -"Toggle chat list:" = "Chat-Liste umschalten:"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Inkognito beim Verbinden einschalten."; @@ -5568,6 +6188,9 @@ report reason */ /* No comment provided by engineer. */ "Toolbar opacity" = "Deckkraft der Symbolleiste"; +/* No comment provided by engineer. */ +"Top bar" = "Obere Leiste"; + /* No comment provided by engineer. */ "Total" = "Summe aller Abonnements"; @@ -5577,11 +6200,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Transport-Sitzungen"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Beim Versuch die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird."; /* No comment provided by engineer. */ "Turkish interface" = "Türkische Bedienoberfläche"; @@ -5610,8 +6230,11 @@ report reason */ /* No comment provided by engineer. */ "Unblock member?" = "Mitglied freigeben?"; +/* No comment provided by engineer. */ +"Unblock subscriber for all?" = "Abonnent für alle freigeben?"; + /* rcv group event chat item */ -"unblocked %@" = "%@ wurde freigegeben"; +"unblocked %@" = "hat %@ freigegeben"; /* No comment provided by engineer. */ "Undelivered messages" = "Nicht ausgelieferte Nachrichten"; @@ -5682,12 +6305,15 @@ report reason */ /* swipe action */ "Unread" = "Ungelesen"; -/* No comment provided by engineer. */ +/* conn error description */ "Unsupported connection link" = "Verbindungs-Link wird nicht unterstützt"; /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Bis zu 100 der letzten Nachrichten werden an neue Mitglieder gesendet."; +/* No comment provided by engineer. */ +"Up to 100 last messages are sent to new subscribers." = "Bis zu 100 der letzten Nachrichten werden an neue Abonnenten gesendet."; + /* No comment provided by engineer. */ "Update" = "Aktualisieren"; @@ -5700,6 +6326,9 @@ report reason */ /* No comment provided by engineer. */ "Update settings?" = "Einstellungen aktualisieren?"; +/* rcv group event chat item */ +"updated channel profile" = "Kanalprofil aktualisiert"; + /* No comment provided by engineer. */ "Updated conditions" = "Aktualisierte Nutzungsbedingungen"; @@ -5757,9 +6386,6 @@ report reason */ /* No comment provided by engineer. */ "Use %@" = "Verwende %@"; -/* No comment provided by engineer. */ -"Use chat" = "Verwenden Sie Chat"; - /* new chat action */ "Use current profile" = "Aktuelles Profil nutzen"; @@ -5769,6 +6395,9 @@ report reason */ /* No comment provided by engineer. */ "Use for messages" = "Für Nachrichten verwenden"; +/* No comment provided by engineer. */ +"Use for new channels" = "Für neue Kanäle verwenden"; + /* No comment provided by engineer. */ "Use for new connections" = "Für neue Verbindungen nutzen"; @@ -5793,6 +6422,9 @@ report reason */ /* No comment provided by engineer. */ "Use private routing with unknown servers." = "Sie nutzen privates Routing mit unbekannten Servern."; +/* No comment provided by engineer. */ +"Use relay" = "Relais verwenden"; + /* No comment provided by engineer. */ "Use server" = "Server nutzen"; @@ -5817,6 +6449,9 @@ report reason */ /* No comment provided by engineer. */ "Use the app with one hand." = "Die App mit einer Hand bedienen."; +/* No comment provided by engineer. */ +"Use this address in your social media profile, website, or email signature." = "Diese Adresse in Ihrem Social‑Media‑Profil, auf Ihrer Webseite oder in Ihrer E‑Mail‑Signatur verwenden."; + /* No comment provided by engineer. */ "Use web port" = "Web-Port nutzen"; @@ -5835,6 +6470,9 @@ report reason */ /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* relay test step */ +"Verify" = "Überprüfen"; + /* No comment provided by engineer. */ "Verify code with desktop" = "Code mit dem Desktop überprüfen"; @@ -5856,6 +6494,9 @@ report reason */ /* No comment provided by engineer. */ "Verify security code" = "Sicherheitscode überprüfen"; +/* relay hostname */ +"via %@" = "via %@"; + /* No comment provided by engineer. */ "Via browser" = "Über den Browser"; @@ -5889,6 +6530,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später!"; +/* No comment provided by engineer. */ +"Videos" = "Videos"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Videos und Dateien bis zu 1GB"; @@ -5922,9 +6566,18 @@ report reason */ /* No comment provided by engineer. */ "Voice messages prohibited!" = "Sprachnachrichten sind nicht erlaubt!"; +/* alert action */ +"Wait" = "Abwarten"; + +/* relay test step */ +"Wait response" = "Antwort abwarten"; + /* No comment provided by engineer. */ "waiting for answer…" = "Warten auf Antwort…"; +/* No comment provided by engineer. */ +"Waiting for channel owner to add relays." = "Warte auf das Hinzufügen von Relais durch den Eigentümer des Kanals."; + /* No comment provided by engineer. */ "waiting for confirmation…" = "Warten auf Bestätigung…"; @@ -5955,6 +6608,9 @@ report reason */ /* No comment provided by engineer. */ "Warning: you may lose some data!" = "Warnung: Sie könnten einige Daten verlieren!"; +/* No comment provided by engineer. */ +"We made connecting simpler for new users." = "Wir haben das Verbinden für neue Nutzer vereinfacht."; + /* No comment provided by engineer. */ "WebRTC ICE servers" = "WebRTC ICE-Server"; @@ -5991,6 +6647,9 @@ report reason */ /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Wenn Sie ein Inkognito-Profil mit Jemandem teilen, wird dieses Profil auch für die Gruppen verwendet, für die Sie von diesem Kontakt eingeladen werden."; +/* No comment provided by engineer. */ +"Why SimpleX is built." = "Warum SimpleX entwickelt wurde."; + /* No comment provided by engineer. */ "WiFi" = "WiFi"; @@ -6075,18 +6734,24 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Sie sind bereits Mitglied dieser Gruppe!\nVerbindungsanfrage wiederholen?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird."; /* No comment provided by engineer. */ "You are invited to group" = "Sie sind zu der Gruppe eingeladen"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Sie sind nicht mit diesen Servern verbunden. Zur Auslieferung von Nachrichten an diese Server wird privates Routing genutzt."; /* No comment provided by engineer. */ "you are observer" = "Sie sind Beobachter"; +/* No comment provided by engineer. */ +"you are subscriber" = "Sie sind Abonnent"; + /* snd group event chat item */ "you blocked %@" = "Sie haben %@ blockiert"; @@ -6129,6 +6794,9 @@ report reason */ /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Über die Geräte-Einstellungen können Sie die Benachrichtigungsvorschau im Sperrbildschirm erlauben."; +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the channel." = "Sie können einen Link oder QR-Code teilen - damit kann jeder dem Kanal beitreten."; + /* No comment provided by engineer. */ "You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Sie können diesen Link oder QR-Code teilen - Damit kann jede Person der Gruppe beitreten. Wenn Sie den Link später löschen, werden Sie keine Gruppenmitglieder verlieren, die der Gruppe darüber beigetreten sind."; @@ -6169,10 +6837,13 @@ report reason */ "you changed role of %@ to %@" = "Sie haben die Rolle von %1$@ auf %2$@ geändert"; /* No comment provided by engineer. */ -"You could not be verified; please try again." = "Sie konnten nicht überprüft werden; bitte versuchen Sie es erneut."; +"You commit to:\n- Only legal content in public groups\n- Respect other users - no spam" = "Sie verpflichten sich dazu:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden\n- andere Nutzer zu respektieren - kein Spam"; /* No comment provided by engineer. */ -"You decide who can connect." = "Sie entscheiden, wer sich mit Ihnen verbinden kann."; +"You connected to the channel via this relay link." = "Sie haben sich über diesen Relais‑Link mit dem Kanal verbunden."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "Sie konnten nicht überprüft werden; bitte versuchen Sie es erneut."; /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Sie haben bereits ein Verbindungsanfrage beantragt!\nVerbindungsanfrage wiederholen?"; @@ -6228,6 +6899,9 @@ report reason */ /* snd group event chat item */ "you unblocked %@" = "Sie haben %@ freigegeben"; +/* No comment provided by engineer. */ +"You were born without an account" = "Sie wurden ohne eine Benutzerkennung geboren."; + /* No comment provided by engineer. */ "You will be able to send messages **only after your request is accepted**." = "Sie können erst dann Nachrichten versenden, **sobald Ihre Anfrage angenommen wurde**."; @@ -6249,6 +6923,9 @@ report reason */ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this channel. Chat history will be preserved." = "Sie werden keine Nachrichten mehr aus diesem Kanal erhalten. Der Chatverlauf bleibt erhalten."; + /* No comment provided by engineer. */ "You will stop receiving messages from this chat. Chat history will be preserved." = "Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf bleibt erhalten."; @@ -6273,6 +6950,9 @@ report reason */ /* No comment provided by engineer. */ "Your calls" = "Anrufe"; +/* No comment provided by engineer. */ +"Your channel" = "Ihr Kanal"; + /* No comment provided by engineer. */ "Your chat database" = "Chat-Datenbank"; @@ -6303,6 +6983,9 @@ report reason */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "Ihre Kontakte bleiben weiterhin verbunden."; +/* No comment provided by engineer. */ +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "Ihre Kommunikation gehört Ihnen, so wie es immer war, bevor es das Internet gab. Das Netzwerk ist kein Ort, den Sie besuchen. Es ist ein Ort, den Sie erschaffen und besitzen und Niemand kann es Ihnen nehmen, egal ob Sie es privat oder öffentlich machen."; + /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "Ihre Anmeldeinformationen können unverschlüsselt versendet werden."; @@ -6318,6 +7001,9 @@ report reason */ /* No comment provided by engineer. */ "Your ICE servers" = "Ihre ICE-Server"; +/* No comment provided by engineer. */ +"Your network" = "Ihr Netzwerk"; + /* No comment provided by engineer. */ "Your preferences" = "Ihre Präferenzen"; @@ -6327,6 +7013,9 @@ report reason */ /* No comment provided by engineer. */ "Your profile" = "Mein Profil"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared with channel relays and subscribers.\nRelays can access channel messages." = "Ihr Profil **%@** wird mit Kanal‑Relais und Abonnenten geteilt.\nRelais können auf Kanalnachrichten zugreifen."; + /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Ihr Profil **%@** wird geteilt."; @@ -6339,9 +7028,18 @@ report reason */ /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ihr Profil wurde geändert. Wenn Sie es speichern, wird das aktualisierte Profil an alle Ihre Kontakte gesendet."; +/* No comment provided by engineer. */ +"Your public address" = "Ihre öffentliche Adresse"; + /* No comment provided by engineer. */ "Your random profile" = "Ihr Zufallsprofil"; +/* No comment provided by engineer. */ +"Your relay address" = "Ihre Relais-Adresse"; + +/* No comment provided by engineer. */ +"Your relay name" = "Ihr Relais-Name"; + /* No comment provided by engineer. */ "Your server address" = "Ihre Serveradresse"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 6c8232fb63..49826ff7f6 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -10,6 +10,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- entrega de mensajes más estable.\n- grupos un poco mejores.\n- ¡y más!"; +/* No comment provided by engineer. */ +"- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking." = "- aceptar el envío de vistas previas de los enlaces.\n- prevenir el phishing mediante hipervínculos.\n- eliminar el seguimiento de los enlaces."; + /* No comment provided by engineer. */ "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- notificar opcionalmente a los contactos eliminados.\n- nombres de perfil con espacios.\n- ¡...y más!"; @@ -19,21 +22,21 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 coloreado!"; +/* chat link info line */ +"(from owner)" = "(del propietario)"; + /* No comment provided by engineer. */ "(new)" = "(nuevo)"; +/* chat link info line */ +"(signed)" = "(firmado)"; + /* No comment provided by engineer. */ "(this device v%@)" = "(este dispositivo v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuye](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Contacta vía email](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Estrella en GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Añadir contacto**: crea un enlace de invitación nuevo."; @@ -64,6 +67,9 @@ /* No comment provided by engineer. */ "**Scan / Paste link**: to connect via a link you received." = "**Escanear / Pegar enlace**: para conectar mediante un enlace recibido."; +/* No comment provided by engineer. */ +"**Test relay** to retrieve its name." = "**Test servidor** para recibir su nombre."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Advertencia**: Las notificaciones automáticas instantáneas requieren una contraseña guardada en Keychain."; @@ -175,6 +181,18 @@ /* time interval */ "%d months" = "%d mes(es)"; +/* channel relay bar +channel subscriber relay bar */ +"%d relays failed" = "%d servidores han fallado"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays not active" = "%d servidores inactivos"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays removed" = "%d servidores eliminados"; + /* time interval */ "%d sec" = "%d segundo(s)"; @@ -184,15 +202,50 @@ /* integrity error chat item */ "%d skipped message(s)" = "%d mensaje(s) omitido(s)"; +/* channel subscriber count */ +"%d subscriber" = "%d suscriptor"; + +/* channel subscriber count */ +"%d subscribers" = "%d suscriptores"; + /* time interval */ "%d weeks" = "%d semana(s)"; +/* channel creation progress +channel relay bar progress */ +"%d/%d relays active" = "%1$d/%2$d servidores activos"; + +/* channel relay bar */ +"%d/%d relays active, %d errors" = "%1$d/%2$d servidores activos, %3$d errores"; + +/* channel creation progress with errors +channel relay bar */ +"%d/%d relays active, %d failed" = "%1$d/%2$d servidores activos, %3$d han fallado"; + +/* channel relay bar */ +"%d/%d relays active, %d removed" = "%1$d/%2$d servidores activos, %3$d servidores eliminados"; + +/* channel subscriber relay bar progress */ +"%d/%d relays connected" = "%1$d/%2$d servidores conectados"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d errors" = "%1$d/%2$d servidores conectados, %3$d errores"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d failed" = "%1$d/%2$d servidores conectados, %3$d con fallo"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d removed" = "%1$d/%2$d servidores conectados, %3$d eliminados"; + /* No comment provided by engineer. */ "%lld" = "%lld"; /* No comment provided by engineer. */ "%lld %@" = "%lld %@"; +/* No comment provided by engineer. */ +"%lld channel events" = "%lld eventos del canal"; + /* No comment provided by engineer. */ "%lld contact(s) selected" = "%lld contacto(s) seleccionado(s)"; @@ -200,7 +253,7 @@ "%lld file(s) with total size of %@" = "%lld archivo(s) con un tamaño total de %@"; /* No comment provided by engineer. */ -"%lld group events" = "%lld evento(s) de grupo"; +"%lld group events" = "%lld evento(s) del grupo"; /* No comment provided by engineer. */ "%lld members" = "%lld miembros"; @@ -257,11 +310,14 @@ "`a + b`" = "\\`a + b`"; /* email text */ -"

Hi!

\n

Connect to me via SimpleX Chat

" = "

¡Hola!

\n

Conecta conmigo a través de SimpleX Chat

"; +"

Hi!

\n

Connect to me via SimpleX Chat

" = "

¡Hola!

\n

Conecta conmigo a través de SimpleX Chat

"; /* No comment provided by engineer. */ "~strike~" = "\\~strike~"; +/* owner verification */ +"⚠️ Signature verification failed: %@." = "⚠️ Verificación de firma fallida: %@."; + /* time to disappear */ "0 sec" = "0 seg"; @@ -307,6 +363,9 @@ time interval */ /* No comment provided by engineer. */ "A few more things" = "Algunas cosas más"; +/* No comment provided by engineer. */ +"A link for one person to connect" = "Enlace para un solo contacto"; + /* notification title */ "A new contact" = "Contacto nuevo"; @@ -371,6 +430,9 @@ swipe action */ /* alert title */ "Accept member" = "Aceptar miembro"; +/* No comment provided by engineer. */ +"accepted" = "aceptado"; + /* rcv group event chat item */ "accepted %@" = "%@ aceptado"; @@ -384,7 +446,7 @@ swipe action */ "accepted invitation" = "invitación aceptada"; /* rcv group event chat item */ -"accepted you" = "te ha aceptado"; +"accepted you" = "te ha admitido"; /* No comment provided by engineer. */ "Acknowledged" = "Confirmaciones"; @@ -392,6 +454,9 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledgement errors" = "Errores de confirmación"; +/* No comment provided by engineer. */ +"active" = "activo"; + /* token status text */ "Active" = "Activo"; @@ -399,7 +464,7 @@ swipe action */ "Active connections" = "Conexiones activas"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Añade la dirección a tu perfil para que tus contactos puedan compartirla con otros. La actualización del perfil se enviará a tus contactos."; +"Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts." = "Añade la dirección a tu perfil para que tus contactos SimpleX puedan compartirla con otros. La actualización del perfil se enviará a tus contactos SimpleX."; /* No comment provided by engineer. */ "Add friends" = "Añadir amigos"; @@ -440,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Servidores de mensajes añadidos"; +/* No comment provided by engineer. */ +"Adding relays will be supported later." = "Añadir servidores estará disponible en una versión posterior."; + /* No comment provided by engineer. */ "Additional accent" = "Acento adicional"; @@ -512,6 +580,9 @@ swipe action */ /* feature role */ "all members" = "todos los miembros"; +/* No comment provided by engineer. */ +"All messages" = "Todos los mensajes"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Todos los mensajes y archivos son enviados **cifrados de extremo a extremo** y con seguridad de cifrado postcuántico en mensajes directos."; @@ -527,6 +598,12 @@ swipe action */ /* profile dropdown */ "All profiles" = "Todos los perfiles"; +/* No comment provided by engineer. */ +"All relays failed" = "Todos los servidores han fallado"; + +/* No comment provided by engineer. */ +"All relays removed" = "Todos los servidores eliminados"; + /* No comment provided by engineer. */ "All reports will be archived for you." = "Todos los informes serán archivados para ti."; @@ -563,6 +640,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Se permite la eliminación irreversible de mensajes pero sólo si tu contacto también lo permite. (24 horas)"; +/* No comment provided by engineer. */ +"Allow members to chat with admins." = "Permitir que los miembros chateen con administradores."; + /* No comment provided by engineer. */ "Allow message reactions only if your contact allows them." = "Se permiten las reacciones a los mensajes pero sólo si tu contacto también las permite."; @@ -572,12 +652,18 @@ swipe action */ /* No comment provided by engineer. */ "Allow sending direct messages to members." = "Se permiten mensajes directos entre miembros."; +/* No comment provided by engineer. */ +"Allow sending direct messages to subscribers." = "Se permiten mensajes directos entre suscriptores."; + /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Permites el envío de mensajes temporales."; /* No comment provided by engineer. */ "Allow sharing" = "Permitir compartir"; +/* No comment provided by engineer. */ +"Allow subscribers to chat with admins." = "Permitir que los suscriptores chateen con administradores."; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Se permite la eliminación irreversible de mensajes. (24 horas)"; @@ -647,9 +733,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Responder llamada"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Cualquiera puede alojar servidores."; - /* No comment provided by engineer. */ "App build: %@" = "Compilación app: %@"; @@ -734,6 +817,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Llamadas y videollamadas"; +/* No comment provided by engineer. */ +"Audio call" = "Llamada"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "llamada (sin cifrar)"; @@ -788,6 +874,15 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "ID de mensaje incorrecto"; +/* No comment provided by engineer. */ +"Be free\nin your network" = "Se libre\nen tu red"; + +/* No comment provided by engineer. */ +"Be free in your network." = "Se libre en tu red."; + +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "Porque hemos destruido el poder de saber quien eres. De manera que tu poder nunca se pueda arrebatar."; + /* No comment provided by engineer. */ "Better calls" = "Llamadas mejoradas"; @@ -845,6 +940,9 @@ swipe action */ /* No comment provided by engineer. */ "Block member?" = "¿Bloquear miembro?"; +/* No comment provided by engineer. */ +"Block subscriber for all?" = "¿Bloquear al suscriptor para todos?"; + /* marked deleted chat item preview text */ "blocked" = "bloqueado"; @@ -889,9 +987,15 @@ marked deleted chat item preview text */ "Both you and your contact can send voice messages." = "Tanto tú como tu contacto podéis enviar mensajes de voz."; /* No comment provided by engineer. */ -"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Búlgaro, Finlandés, Tailandés y Ucraniano - gracias a los usuarios y [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +"Bottom bar" = "Barra inferior"; + +/* compose placeholder for channel owner */ +"Broadcast" = "Emisión"; /* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Búlgaro, Finlandés, Tailandés y Ucraniano - gracias a los usuarios y [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + +/* chat link info line */ "Business address" = "Dirección empresarial"; /* No comment provided by engineer. */ @@ -906,9 +1010,6 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Al usar SimpleX Chat, aceptas:\n- enviar únicamente contenido legal en los grupos públicos.\n- respetar a los demás usuarios – spam prohibido."; - /* No comment provided by engineer. */ "call" = "llamada"; @@ -933,6 +1034,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Camera not available" = "Cámara no disponible"; +/* No comment provided by engineer. */ +"can't broadcast" = "no puedes retransmitir"; + /* No comment provided by engineer. */ "Can't call contact" = "No se puede llamar al contacto"; @@ -1032,6 +1136,58 @@ set passcode view */ /* chat item text */ "changing address…" = "cambiando de servidor…"; +/* shown as sender role for channel messages */ +"channel" = "canal"; + +/* No comment provided by engineer. */ +"Channel" = "Canal"; + +/* No comment provided by engineer. */ +"Channel display name" = "Título mostrado del canal"; + +/* No comment provided by engineer. */ +"Channel full name (optional)" = "Título completo del canal (opcional)"; + +/* alert message +alert subtitle */ +"Channel has no active relays. Please try to join later." = "El canal no tiene servidores activos. Por favor, intenta unirte más tarde."; + +/* No comment provided by engineer. */ +"Channel image" = "Imagen del canal"; + +/* chat link info line */ +"Channel link" = "Enlace del canal"; + +/* No comment provided by engineer. */ +"Channel preferences" = "Preferencias del canal"; + +/* No comment provided by engineer. */ +"Channel profile" = "Perfil del canal"; + +/* No comment provided by engineer. */ +"Channel profile is stored on subscribers' devices and on the chat relays." = "El perfil del canal se almacena en los dispositivos de los suscriptores y en los servidores de chat."; + +/* snd group event chat item */ +"channel profile updated" = "perfil del canal actualizado"; + +/* alert message */ +"Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers." = "El perfil del canal ha sido modificado. Si lo guardas, el perfil actualizado será enviado a los suscriptores."; + +/* alert title */ +"Channel temporarily unavailable" = "Canales no disponibles temporalmente"; + +/* No comment provided by engineer. */ +"Channel will be deleted for all subscribers - this cannot be undone!" = "El canal será eliminado para todos los suscriptores. ¡No puede deshacerse!"; + +/* No comment provided by engineer. */ +"Channel will be deleted for you - this cannot be undone!" = "El canal será eliminado para tí. ¡No puede deshacerse!"; + +/* alert message */ +"Channel will start working with %d of %d relays. Proceed?" = "El canal comenzará a funcionar con %1$d de %2$d servidores. ¿Continuar?"; + +/* No comment provided by engineer. */ +"Channels" = "Canales"; + /* No comment provided by engineer. */ "Chat" = "Chat"; @@ -1083,6 +1239,18 @@ set passcode view */ /* No comment provided by engineer. */ "Chat profile" = "Perfil de usuario"; +/* No comment provided by engineer. */ +"Chat relay" = "Servidor de chat"; + +/* No comment provided by engineer. */ +"Chat relays" = "Servidores de chat"; + +/* No comment provided by engineer. */ +"Chat relays forward messages in channels you create." = "Los servidores de chat reenvían los mensajes en los canales que has creado."; + +/* No comment provided by engineer. */ +"Chat relays forward messages to channel subscribers." = "Los servidores de chat reenvían los mensajes a los suscriptores del canal."; + /* No comment provided by engineer. */ "Chat theme" = "Tema de chat"; @@ -1092,7 +1260,8 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "El chat será eliminado para tí. ¡No puede deshacerse!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Chatea con administradores"; /* No comment provided by engineer. */ @@ -1104,15 +1273,30 @@ set passcode view */ /* No comment provided by engineer. */ "Chats" = "Chats"; +/* No comment provided by engineer. */ +"Chats with admins are prohibited." = "Chat con administradores no permitido."; + +/* alert message */ +"Chats with admins in public channels have no E2E encryption - use only with trusted chat relays." = "El chat con administradores en el canal público no dispone de cifrado E2E. Úsalo sólo con servidores de confianza."; + /* No comment provided by engineer. */ "Chats with members" = "Chat con miembros"; +/* No comment provided by engineer. */ +"Chats with members are disabled" = "Chats con miembros desactivado"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Comprobar mensajes cada 20 min."; /* No comment provided by engineer. */ "Check messages when allowed." = "Comprobar mensajes cuando se permita."; +/* alert message */ +"Check relay address and try again." = "Comprueba la dirección del servidor y prueba de nuevo."; + +/* alert message */ +"Check relay name and try again." = "Comprueba el nombre del servidor y prueba de nuevo."; + /* alert title */ "Check server address and try again." = "Comprueba la dirección del servidor e inténtalo de nuevo."; @@ -1191,7 +1375,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Las condiciones ya se han aceptado para el/los siguiente(s) operador(s): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Condiciones de uso"; /* No comment provided by engineer. */ @@ -1207,7 +1391,7 @@ set passcode view */ "Configure ICE servers" = "Configure servidores ICE"; /* No comment provided by engineer. */ -"Configure server operators" = "Configurar operadores de servidores"; +"Configure relays" = "Configurar servidores"; /* No comment provided by engineer. */ "Confirm" = "Confirmar"; @@ -1242,7 +1426,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Confirmado"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Conectar"; /* No comment provided by engineer. */ @@ -1272,6 +1457,9 @@ set passcode view */ /* new chat sheet title */ "Connect via link" = "Conectar mediante enlace"; +/* No comment provided by engineer. */ +"Connect via link or QR code" = "Conecta vía enlace o QR"; + /* new chat sheet title */ "Connect via one-time link" = "Conectar mediante enlace de un sólo uso"; @@ -1341,12 +1529,15 @@ set passcode view */ /* alert title */ "Connection error" = "Error conexión"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Error de conexión (Autenticación)"; /* chat list item title (it should not be shown */ "connection established" = "conexión establecida"; +/* No comment provided by engineer. */ +"Connection failed" = "Conexión fallida"; + /* No comment provided by engineer. */ "Connection is blocked by server operator:\n%@" = "Conexión bloqueada por el operador del servidor:\n%@"; @@ -1383,6 +1574,9 @@ set passcode view */ /* profile update event chat item */ "contact %@ changed to %@" = "el contacto %1$@ ha cambiado a %2$@"; +/* chat link info line */ +"Contact address" = "Dirección de contacto"; + /* No comment provided by engineer. */ "Contact allows" = "El contacto permite"; @@ -1443,6 +1637,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Continuar"; +/* No comment provided by engineer. */ +"Contribute" = "Contribuye"; + /* No comment provided by engineer. */ "Conversation deleted!" = "¡Conversación eliminada!"; @@ -1458,12 +1655,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Esquina"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "¿Corregir el nombre a %@?"; -/* No comment provided by engineer. */ -"Create" = "Crear"; - /* No comment provided by engineer. */ "Create 1-time link" = "Crear enlace de un solo uso"; @@ -1491,6 +1685,12 @@ set passcode view */ /* No comment provided by engineer. */ "Create profile" = "Crear perfil"; +/* No comment provided by engineer. */ +"Create public channel" = "Crear canal público"; + +/* No comment provided by engineer. */ +"Create public channel (BETA)" = "Crear canal público (BETA)"; + /* server test step */ "Create queue" = "Crear cola"; @@ -1500,9 +1700,15 @@ set passcode view */ /* No comment provided by engineer. */ "Create your address" = "Crea tu dirección"; +/* No comment provided by engineer. */ +"Create your link" = "Crea tu enlace"; + /* No comment provided by engineer. */ "Create your profile" = "Crea tu perfil"; +/* No comment provided by engineer. */ +"Create your public address" = "Crea tu dirección pública"; + /* No comment provided by engineer. */ "Created" = "Creadas"; @@ -1515,6 +1721,9 @@ set passcode view */ /* No comment provided by engineer. */ "Creating archive link" = "Creando enlace al archivo"; +/* No comment provided by engineer. */ +"Creating channel" = "Creando canal"; + /* No comment provided by engineer. */ "Creating link…" = "Creando enlace…"; @@ -1617,8 +1826,8 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Informe debug"; -/* No comment provided by engineer. */ -"Decentralized" = "Descentralizada"; +/* relay test step */ +"Decode link" = "Decodificar enlace"; /* message decrypt error item */ "Decryption error" = "Error descifrado"; @@ -1661,6 +1870,12 @@ swipe action */ /* No comment provided by engineer. */ "Delete and notify contact" = "Eliminar y notificar contacto"; +/* No comment provided by engineer. */ +"Delete channel" = "Eliminar canal"; + +/* No comment provided by engineer. */ +"Delete channel?" = "¿Eliminar el canal?"; + /* No comment provided by engineer. */ "Delete chat" = "Eliminar chat"; @@ -1730,10 +1945,17 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "¿Eliminar el mensaje de miembro?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Eliminar mensajes del miembro"; + +/* alert title */ +"Delete member messages?" = "¿Eliminar mensajes del miembro?"; + /* No comment provided by engineer. */ "Delete message?" = "¿Eliminar mensaje?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Activar"; /* No comment provided by engineer. */ @@ -1757,6 +1979,9 @@ swipe action */ /* server test step */ "Delete queue" = "Eliminar cola"; +/* No comment provided by engineer. */ +"Delete relay" = "Eliminar servidor"; + /* No comment provided by engineer. */ "Delete report" = "Eliminar informe"; @@ -1781,6 +2006,9 @@ swipe action */ /* copied message info */ "Deleted at: %@" = "Eliminado: %@"; +/* rcv group event chat item */ +"deleted channel" = "canal eliminado"; + /* rcv direct event chat item */ "deleted contact" = "contacto eliminado"; @@ -1871,6 +2099,12 @@ swipe action */ /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Los mensajes directos entre miembros del grupo no están permitidos."; +/* No comment provided by engineer. */ +"Direct messages between subscribers are prohibited." = "Los mensajes directos entre suscriptores del canal no están permitidos."; + +/* alert button */ +"Disable" = "Desactivar"; + /* No comment provided by engineer. */ "Disable (keep overrides)" = "Desactivar (conservando anulaciones)"; @@ -1878,7 +2112,7 @@ swipe action */ "Disable automatic message deletion?" = "¿Desactivar la eliminación automática de mensajes?"; /* alert button */ -"Disable delete messages" = "Desactivar"; +"Disable delete messages" = "Desactivar eliminar mensajes"; /* No comment provided by engineer. */ "Disable for all" = "Desactivar para todos"; @@ -1928,6 +2162,9 @@ swipe action */ /* No comment provided by engineer. */ "Do not send history to new members." = "No se envía el historial a los miembros nuevos."; +/* No comment provided by engineer. */ +"Do not send history to new subscribers." = "No se envía el historial a los suscriptores nuevos."; + /* No comment provided by engineer. */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "NO enviar mensajes directamente incluso si tu servidor o el de destino no soportan enrutamiento privado."; @@ -2007,27 +2244,39 @@ chat item action */ /* No comment provided by engineer. */ "E2E encrypted notifications." = "Notificaciones cifradas E2E."; +/* No comment provided by engineer. */ +"Easier to invite your friends 👋" = "Invitar a tus amigos es más fácil 👋"; + /* chat item action */ "Edit" = "Editar"; +/* No comment provided by engineer. */ +"Edit channel profile" = "Editar perfil del canal"; + /* No comment provided by engineer. */ "Edit group profile" = "Editar perfil de grupo"; /* No comment provided by engineer. */ "Empty message!" = "¡Mensaje vacío!"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Activar"; /* No comment provided by engineer. */ "Enable (keep overrides)" = "Activar (conservar anulaciones)"; +/* channel creation warning */ +"Enable at least one chat relay in Network & Servers." = "Activar al menos un servidor de chat en Servidores y Redes."; + /* alert title */ "Enable automatic message deletion?" = "¿Activar eliminación automática de mensajes?"; /* No comment provided by engineer. */ "Enable camera access" = "Permitir acceso a la cámara"; +/* alert title */ +"Enable chats with admins?" = "¿Activar chat con administradores?"; + /* No comment provided by engineer. */ "Enable disappearing messages by default." = "Activa por defecto los mensajes temporales."; @@ -2043,11 +2292,11 @@ chat item action */ /* No comment provided by engineer. */ "Enable instant notifications?" = "¿Activar notificaciones instantáneas?"; -/* No comment provided by engineer. */ -"Enable lock" = "Activar bloqueo"; +/* alert title */ +"Enable link previews?" = "¿Activar previsualización de enlaces?"; /* No comment provided by engineer. */ -"Enable notifications" = "Activar notificaciones"; +"Enable lock" = "Activar bloqueo"; /* No comment provided by engineer. */ "Enable periodic notifications?" = "¿Activar notificaciones periódicas?"; @@ -2154,6 +2403,9 @@ chat item action */ /* call status */ "ended call %@" = "llamada finalizada %@"; +/* No comment provided by engineer. */ +"Enter channel name…" = "Introduce el título del canal…"; + /* No comment provided by engineer. */ "Enter correct passphrase." = "Introduce la contraseña correcta."; @@ -2172,6 +2424,12 @@ chat item action */ /* No comment provided by engineer. */ "Enter password above to show!" = "¡Introduce la contraseña arriba para mostrar!"; +/* No comment provided by engineer. */ +"Enter profile name..." = "Introduce el nombre del perfil…"; + +/* No comment provided by engineer. */ +"Enter relay name…" = "Introduce el nombre del servidor…"; + /* No comment provided by engineer. */ "Enter server manually" = "Añadir manualmente"; @@ -2190,7 +2448,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "error"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Error"; /* No comment provided by engineer. */ @@ -2208,6 +2466,9 @@ chat item action */ /* No comment provided by engineer. */ "Error adding member(s)" = "Error al añadir miembro(s)"; +/* alert title */ +"Error adding relay" = "Error al añadir el servidor"; + /* alert title */ "Error adding server" = "Error al añadir servidor"; @@ -2238,9 +2499,15 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Error al conectar con el servidor usado para recibir mensajes de esta conexión: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Error al crear dirección"; +/* alert title */ +"Error creating channel" = "Error al crear el canal"; + /* No comment provided by engineer. */ "Error creating group" = "Error al crear grupo"; @@ -2266,7 +2533,7 @@ chat item action */ "Error decrypting file" = "Error al descifrar el archivo"; /* alert title */ -"Error deleting chat" = "Error al eliminar el chat con el miembro"; +"Error deleting chat" = "Error al eliminar el chat"; /* alert title */ "Error deleting chat database" = "Error al eliminar base de datos"; @@ -2322,9 +2589,6 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "Error al abrir chat"; -/* No comment provided by engineer. */ -"Error opening group" = "Error al abrir el grupo"; - /* alert title */ "Error receiving file" = "Error al recibir archivo"; @@ -2349,6 +2613,9 @@ chat item action */ /* No comment provided by engineer. */ "Error resetting statistics" = "Error al restablecer las estadísticas"; +/* No comment provided by engineer. */ +"Error saving channel profile" = "Error al guardar el perfil del canal"; + /* alert title */ "Error saving chat list" = "Error al guardar listas"; @@ -2391,6 +2658,9 @@ chat item action */ /* No comment provided by engineer. */ "Error setting delivery receipts!" = "¡Error al configurar confirmaciones de entrega!"; +/* alert title */ +"Error sharing channel" = "Error al compartir el canal"; + /* No comment provided by engineer. */ "Error starting chat" = "Error al iniciar Chat"; @@ -2433,11 +2703,18 @@ chat item action */ /* No comment provided by engineer. */ "Error: " = "Error: "; +/* receive error chat item */ +"error: %@" = "error: %@"; + /* alert message file error text snd error text */ "Error: %@" = "Error: %@"; +/* relay test error +server test error */ +"Error: %@." = "Error: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Error: sin archivo de base de datos"; @@ -2483,6 +2760,9 @@ snd error text */ /* No comment provided by engineer. */ "Exporting database archive…" = "Exportando base de datos…"; +/* No comment provided by engineer. */ +"failed" = "fallo"; + /* No comment provided by engineer. */ "Failed to remove passphrase" = "Error al eliminar la contraseña"; @@ -2558,6 +2838,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "¡Archivos y multimedia no permitidos!"; +/* No comment provided by engineer. */ +"Filter" = "Filtro"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtra chats no leídos y favoritos."; @@ -2573,8 +2856,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Encuentra chats mas rápido"; -/* server test error */ -"Fingerprint in server address does not match certificate." = "Posiblemente la huella del certificado en la dirección del servidor es incorrecta"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "La huella en la dirección del servidor de destino no coincide con el certificado: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "La huella en la dirección del servidor de reenvío no coincide con el certificado: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "La huella en la dirección del servidor no coincide con el certificado: %@."; + +/* relay test error +server test error */ +"Fingerprint in server address does not match certificate." = "La huella en la dirección del servidor no coincide con el certificado."; /* No comment provided by engineer. */ "Fix" = "Reparar"; @@ -2597,7 +2890,11 @@ snd error text */ /* No comment provided by engineer. */ "For all moderators" = "Para todos los moderadores"; -/* servers error */ +/* No comment provided by engineer. */ +"For anyone to reach you" = "Cualquiera puede contactarte"; + +/* servers error +servers warning */ "For chat profile %@:" = "Para el perfil de chat %@:"; /* No comment provided by engineer. */ @@ -2681,9 +2978,15 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "Reducción consumo de batería"; +/* relay test step */ +"Get link" = "Recibir el enlace"; + /* No comment provided by engineer. */ "Get notified when mentioned." = "Las menciones ahora se notifican."; +/* No comment provided by engineer. */ +"Get started" = "Empezar"; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIFs y stickers"; @@ -2729,7 +3032,7 @@ snd error text */ /* No comment provided by engineer. */ "group is deleted" = "el grupo ha sido eliminado"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Enlace de grupo"; /* No comment provided by engineer. */ @@ -2801,6 +3104,9 @@ snd error text */ /* No comment provided by engineer. */ "History is not sent to new members." = "El historial no se envía a miembros nuevos."; +/* No comment provided by engineer. */ +"History is not sent to new subscribers." = "El historial no se envía a suscriptores nuevos."; + /* time unit */ "hours" = "horas"; @@ -2840,6 +3146,9 @@ snd error text */ /* No comment provided by engineer. */ "If you enter your self-destruct passcode while opening the app:" = "Si al abrir la aplicación introduces el código de autodestrucción:"; +/* down migration warning */ +"If you joined or created channels, they will stop working permanently." = "Si te has unido o has creado canales, dejarán de funcionar permanentemente."; + /* No comment provided by engineer. */ "If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Si necesitas usar el chat ahora pulsa **Hacerlo más tarde** más abajo (se ofrecerá migrar la base de datos cuando se reinicie la aplicación)."; @@ -2853,10 +3162,10 @@ snd error text */ "Image will be received when your contact is online, please wait or check later!" = "La imagen se recibirá cuando el contacto esté en línea, ¡por favor espera o revisa más tarde!"; /* No comment provided by engineer. */ -"Immediately" = "Inmediatamente"; +"Images" = "Imágenes"; /* No comment provided by engineer. */ -"Immune to spam" = "Inmune a spam y abuso"; +"Immediately" = "Inmediatamente"; /* No comment provided by engineer. */ "Import" = "Importar"; @@ -2958,7 +3267,7 @@ snd error text */ "Initial role" = "Rol inicial"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Instalar terminal para [SimpleX Chat](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Instalar terminal para SimpleX Chat"; /* No comment provided by engineer. */ "Instant" = "Al instante"; @@ -2993,7 +3302,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "datos Chat no válidos"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Enlace de conexión no válido"; /* invalid chat item */ @@ -3008,12 +3317,18 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Confirmación de migración no válida"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "¡Nombre no válido!"; /* No comment provided by engineer. */ "Invalid QR code" = "Código QR no válido"; +/* alert title */ +"Invalid relay address!" = "¡Dirección de servidor no válido!"; + +/* alert title */ +"Invalid relay name!" = "¡Nombre de servidor no válido!"; + /* No comment provided by engineer. */ "Invalid response" = "Respuesta no válida"; @@ -3035,9 +3350,15 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Invitar amigos"; +/* No comment provided by engineer. */ +"Invite member" = "Invitar miembro"; + /* No comment provided by engineer. */ "Invite members" = "Invitar miembros"; +/* No comment provided by engineer. */ +"Invite someone privately" = "Invitación privada"; + /* No comment provided by engineer. */ "Invite to chat" = "Invitar al chat"; @@ -3104,6 +3425,9 @@ snd error text */ /* No comment provided by engineer. */ "Join as %@" = "Unirme como %@"; +/* No comment provided by engineer. */ +"Join channel" = "Unirme al canal"; + /* new chat sheet title */ "Join group" = "Unirme al grupo"; @@ -3152,6 +3476,12 @@ snd error text */ /* swipe action */ "Leave" = "Salir"; +/* No comment provided by engineer. */ +"Leave channel" = "Salir del canal"; + +/* No comment provided by engineer. */ +"Leave channel?" = "¿Salir del canal?"; + /* No comment provided by engineer. */ "Leave chat" = "Salir del chat"; @@ -3170,6 +3500,9 @@ snd error text */ /* No comment provided by engineer. */ "Less traffic on mobile networks." = "Menos tráfico en redes móviles."; +/* No comment provided by engineer. */ +"Let someone connect to you" = "Conecta con alguien"; + /* email subject */ "Let's talk in SimpleX Chat" = "Hablemos en SimpleX Chat"; @@ -3179,15 +3512,24 @@ snd error text */ /* No comment provided by engineer. */ "Limitations" = "Limitaciones"; +/* No comment provided by engineer. */ +"link" = "enlace"; + /* No comment provided by engineer. */ "Link mobile and desktop apps! 🔗" = "¡Enlazar aplicación móvil con ordenador! 🔗"; +/* owner verification */ +"Link signature verified." = "Firma del enlace verificada."; + /* No comment provided by engineer. */ "Linked desktop options" = "Opciones ordenador enlazado"; /* No comment provided by engineer. */ "Linked desktops" = "Ordenadores enlazados"; +/* No comment provided by engineer. */ +"Links" = "Enlaces"; + /* swipe action */ "List" = "Lista"; @@ -3281,6 +3623,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Miembro eliminado, no puede aceptar solicitudes"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Los mensajes del miembro serán eliminados. ¡No puede deshacerse!"; + /* chat feature */ "Member reports" = "Informes de miembros"; @@ -3293,10 +3638,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "El rol del miembro cambiará a \"%@\" y recibirá una invitación nueva."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "El miembro será eliminado del chat. ¡No puede deshacerse!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "El miembro será expulsado del grupo. ¡No puede deshacerse!"; /* alert message */ @@ -3305,6 +3650,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can add message reactions." = "Los miembros pueden añadir reacciones a los mensajes."; +/* No comment provided by engineer. */ +"Members can chat with admins." = "Los miembros pueden chatear con los administradores."; + /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Los miembros del grupo pueden eliminar mensajes de forma irreversible. (24 horas)"; @@ -3347,6 +3695,9 @@ snd error text */ /* No comment provided by engineer. */ "Message draft" = "Borrador de mensaje"; +/* No comment provided by engineer. */ +"Message error" = "Mensaje de error"; + /* item status text */ "Message forwarded" = "Mensaje reenviado"; @@ -3407,6 +3758,12 @@ snd error text */ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "¡Los mensajes nuevos de %@ serán mostrados!"; +/* No comment provided by engineer. */ +"Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages." = "Los mensajes en este canal **no están cifrados de extremo a extremo**. Los servidores pueden ver estos mensajes."; + +/* E2EE info chat item */ +"Messages in this channel are not end-to-end encrypted. Chat relays can see these messages." = "Los mensajes en este canal no están cifrados de extremo a extremo. Los servidores pueden ver estos mensajes."; + /* alert message */ "Messages in this chat will never be deleted." = "Los mensajes de esta conversación nunca se eliminan."; @@ -3426,10 +3783,10 @@ snd error text */ "Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Los mensajes, archivos y llamadas están protegidos mediante **cifrado de extremo a extremo resistente a tecnología cuántica** con secreto perfecto hacía adelante, repudio y recuperación tras ataque."; /* No comment provided by engineer. */ -"Migrate device" = "Migrar dispositivo"; +"Migrate" = "Migrar"; /* No comment provided by engineer. */ -"Migrate from another device" = "Migrar desde otro dispositivo"; +"Migrate device" = "Migrar dispositivo"; /* No comment provided by engineer. */ "Migrate here" = "Migrar aquí"; @@ -3521,12 +3878,18 @@ snd error text */ /* No comment provided by engineer. */ "Network & servers" = "Servidores y Redes"; +/* No comment provided by engineer. */ +"Network commitments" = "Compromisos en la red"; + /* No comment provided by engineer. */ "Network connection" = "Conexión de red"; /* No comment provided by engineer. */ "Network decentralization" = "Descentralización de la red"; +/* conn error description */ +"Network error" = "Error de red"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Problema en la red - el mensaje ha expirado tras muchos intentos de envío."; @@ -3537,23 +3900,35 @@ snd error text */ "Network operator" = "Operador de red"; /* No comment provided by engineer. */ -"Network settings" = "Configuración de red"; +"Network routers cannot know\nwho talks to whom" = "Los routers de la red no pueden saber\nquién se comunica con quién"; /* No comment provided by engineer. */ +"Network settings" = "Configuración de red"; + +/* alert title */ "Network status" = "Estado de la red"; /* delete after time */ "never" = "nunca"; +/* No comment provided by engineer. */ +"new" = "nuevo"; + /* token status text */ "New" = "Nuevo"; +/* No comment provided by engineer. */ +"New 1-time link" = "Nuevo enlace de 1 solo uso"; + /* No comment provided by engineer. */ "New chat" = "Nuevo chat"; /* No comment provided by engineer. */ "New chat experience 🎉" = "Nueva experiencia de chat 🎉"; +/* No comment provided by engineer. */ +"New chat relay" = "Nuevo servidor de chat"; + /* notification */ "New contact request" = "Nueva solicitud de contacto"; @@ -3611,9 +3986,21 @@ snd error text */ /* No comment provided by engineer. */ "No" = "No"; +/* No comment provided by engineer. */ +"No account. No phone. No email. No ID.\nThe most secure encryption." = "Sin cuenta. Sin teléfono. Sin email. Sin ID.\nEl cifrado más seguro."; + +/* No comment provided by engineer. */ +"No active relays" = "Sin servidores activos"; + /* Authentication unavailable */ "No app password" = "Sin contraseña de la aplicación"; +/* No comment provided by engineer. */ +"No chat relays" = "Sin servidores de chat"; + +/* servers warning */ +"No chat relays enabled." = "Ningún servidor de chat activado."; + /* No comment provided by engineer. */ "No chats" = "Sin chats"; @@ -3639,7 +4026,7 @@ snd error text */ "No device token!" = "¡Sin dispositivo token!"; /* item status description */ -"No direct connection yet, message is forwarded by admin." = "Aún no hay conexión directa con este miembro, el mensaje es reenviado por el administrador."; +"No direct connection yet, message is forwarded by admin." = "Aún no hay conexión directa, los mensajes son reenviados por el administrador."; /* No comment provided by engineer. */ "no e2e encryption" = "sin cifrar"; @@ -3698,6 +4085,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Sin servidores para enviar archivos."; +/* No comment provided by engineer. */ +"no subscription" = "sin suscripciones"; + /* copied message info in history */ "no text" = "sin texto"; @@ -3708,7 +4098,16 @@ snd error text */ "No unread chats" = "Ningún chat sin leer"; /* No comment provided by engineer. */ -"No user identifiers." = "Sin identificadores de usuario."; +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Nadie monitorizaba tus conversaciones. Nadie registraba tus ubicaciones. La privacidad nunca fue un lujo, era la manera de vivir."; + +/* No comment provided by engineer. */ +"Non-profit governance" = "Gobernanza no lucrativa"; + +/* No comment provided by engineer. */ +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "No un candado mejorado en la puerta de otro. No un terrateniente que respeta tu privacidad pero sigue guardando un registro de tus visitantes. Tu no eres el invitado. Estás en tu casa y ningún rey podrá entrar. Tu eres el soberano."; + +/* alert title */ +"Not all relays connected" = "Hay servidores no conectados"; /* No comment provided by engineer. */ "Not compatible!" = "¡No compatible!"; @@ -3766,7 +4165,7 @@ alert button new chat action */ "Ok" = "Ok"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "OK"; /* No comment provided by engineer. */ @@ -3775,9 +4174,15 @@ new chat action */ /* group pref value */ "on" = "Activado"; +/* No comment provided by engineer. */ +"On your phone, not on servers." = "En tu teléfono, no en algún servidor."; + /* No comment provided by engineer. */ "One-time invitation link" = "Enlace de invitación de un solo uso"; +/* chat link info line */ +"One-time link" = "Enlace de un solo uso"; + /* No comment provided by engineer. */ "Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Se **requieren** hosts .onion para la conexión.\nRequiere activación de la VPN."; @@ -3787,6 +4192,9 @@ new chat action */ /* No comment provided by engineer. */ "Onion hosts will not be used." = "No se usarán hosts .onion."; +/* No comment provided by engineer. */ +"Only channel owners can change channel preferences." = "Sólo los propietarios pueden modificar las preferencias de los canales."; + /* No comment provided by engineer. */ "Only chat owners can change preferences." = "Sólo los propietarios del chat pueden cambiar las preferencias."; @@ -3847,12 +4255,16 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sólo tu contacto puede enviar mensajes de voz."; -/* alert action */ +/* alert action +alert button */ "Open" = "Abrir"; /* No comment provided by engineer. */ "Open changes" = "Abrir cambios"; +/* new chat action */ +"Open channel" = "Abrir canal"; + /* new chat action */ "Open chat" = "Abrir chat"; @@ -3865,6 +4277,9 @@ new chat action */ /* No comment provided by engineer. */ "Open conditions" = "Abrir condiciones"; +/* alert title */ +"Open external link?" = "¿Abrir enlace externo?"; + /* alert action */ "Open full link" = "Abrir enlace completo"; @@ -3877,6 +4292,9 @@ new chat action */ /* authentication reason */ "Open migration to another device" = "Abrir menú migración a otro dispositivo"; +/* new chat action */ +"Open new channel" = "Abrir canal nuevo"; + /* new chat action */ "Open new chat" = "Abrir chat nuevo"; @@ -3907,6 +4325,9 @@ new chat action */ /* alert title */ "Operator server" = "Servidor del operador"; +/* No comment provided by engineer. */ +"Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code" = "Los operadores se comprometen a:\n- Ser independientes\n- Minimizar el tratamiento de metadatos\n- Ejecutar código open-source verificado"; + /* No comment provided by engineer. */ "Or import archive file" = "O importa desde un archivo"; @@ -3920,11 +4341,17 @@ new chat action */ "Or securely share this file link" = "O comparte de forma segura este enlace al archivo"; /* No comment provided by engineer. */ -"Or show this code" = "O muestra el código QR"; +"Or show QR in person or via video call." = "O muestra el código QR en persona o por videollamada."; + +/* No comment provided by engineer. */ +"Or show this code" = "O muestra este código"; /* No comment provided by engineer. */ "Or to share privately" = "O para compartir en privado"; +/* No comment provided by engineer. */ +"Or use this QR - print or show online." = "O usa el QR, imprímelo o muestralo en línea."; + /* No comment provided by engineer. */ "Organize chats into lists" = "Organiza tus chats en listas"; @@ -3943,9 +4370,18 @@ new chat action */ /* member role */ "owner" = "propietario"; +/* No comment provided by engineer. */ +"Owner" = "Propietario"; + /* feature role */ "owners" = "propietarios"; +/* No comment provided by engineer. */ +"Owners" = "Propietarios"; + +/* No comment provided by engineer. */ +"Ownership: you can run your own relays." = "En propiedad: puedes poner en marcha tus propios servidores."; + /* No comment provided by engineer. */ "Passcode" = "Código de acceso"; @@ -3973,6 +4409,9 @@ new chat action */ /* No comment provided by engineer. */ "Paste image" = "Pegar imagen"; +/* No comment provided by engineer. */ +"Paste link / Scan" = "Pegar enlace / Escanear"; + /* No comment provided by engineer. */ "Paste link to connect!" = "Pegar enlace para conectar!"; @@ -4081,6 +4520,12 @@ new chat action */ /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Conserva el último borrador del mensaje con los datos adjuntos."; +/* No comment provided by engineer. */ +"Preset relay address" = "Direcciones predefinidas"; + +/* No comment provided by engineer. */ +"Preset relay name" = "Nombres predefinidos"; + /* No comment provided by engineer. */ "Preset server address" = "Dirección predefinida del servidor"; @@ -4103,10 +4548,10 @@ new chat action */ "Privacy policy and conditions of use." = "Política de privacidad y condiciones de uso."; /* No comment provided by engineer. */ -"Privacy redefined" = "Privacidad redefinida"; +"Privacy: for owners and subscribers." = "Privacidad: para propietarios y suscriptores."; /* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores."; +"Private and secure messaging." = "Mensajería segura y privada."; /* No comment provided by engineer. */ "Private filenames" = "Nombres de archivos privados"; @@ -4132,6 +4577,9 @@ new chat action */ /* alert title */ "Private routing timeout" = "Timeout enrutamiento privado"; +/* alert action */ +"Proceed" = "Continuar"; + /* No comment provided by engineer. */ "Profile and server connections" = "Eliminar perfil y conexiones"; @@ -4148,11 +4596,14 @@ new chat action */ "Profile theme" = "Tema del perfil"; /* alert message */ -"Profile update will be sent to your contacts." = "La actualización del perfil se enviará a tus contactos."; +"Profile update will be sent to your SimpleX contacts." = "La actualización del perfil se enviará a tus contactos SimpleX."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "No se permiten llamadas y videollamadas."; +/* No comment provided by engineer. */ +"Prohibit chats with admins." = "El chat con los administradores no está permitido."; + /* No comment provided by engineer. */ "Prohibit irreversible message deletion." = "No se permite la eliminación irreversible de mensajes."; @@ -4168,6 +4619,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "No se permiten mensajes directos entre miembros."; +/* No comment provided by engineer. */ +"Prohibit sending direct messages to subscribers." = "No se permiten mensajes directos entre suscriptores."; + /* No comment provided by engineer. */ "Prohibit sending disappearing messages." = "No se permiten mensajes temporales."; @@ -4210,6 +4664,9 @@ new chat action */ /* No comment provided by engineer. */ "Proxy requires password" = "El proxy requiere contraseña"; +/* No comment provided by engineer. */ +"Public channels - speak freely 🚀" = "Canales públicos - habla con libertad 🚀"; + /* No comment provided by engineer. */ "Push notifications" = "Notificaciones push"; @@ -4238,16 +4695,10 @@ new chat action */ "Read more" = "Saber más"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Conoce más en la [Guía del Usuario](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Conoce más en nuestro repositorio GitHub."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Conoce más en el [Manual del Usuario](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Conoce más en el [Manual del Usuario](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Conoce más en nuestro [repositorio GitHub](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Conoce más en la Guía del Usuario."; /* No comment provided by engineer. */ "Receipts are disabled" = "Las confirmaciones están desactivadas"; @@ -4267,9 +4718,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "confirmación recibida…"; -/* notification */ -"Received file event" = "Evento de archivo recibido"; - /* message info title */ "Received message" = "Mensaje entrante"; @@ -4359,6 +4807,24 @@ swipe action */ /* call status */ "rejected call" = "llamada rechazada"; +/* member role */ +"relay" = "servidor"; + +/* No comment provided by engineer. */ +"Relay" = "Servidor"; + +/* alert title */ +"Relay address" = "Dirección del servidor"; + +/* alert title */ +"Relay connection failed" = "La conexión con el servidor ha fallado"; + +/* No comment provided by engineer. */ +"Relay link" = "Enlace servidor"; + +/* alert message */ +"Relay results:" = "Resultados del servidor:"; + /* No comment provided by engineer. */ "Relay server is only used if necessary. Another party can observe your IP address." = "El servidor de retransmisión sólo se usa en caso de necesidad. Un tercero podría ver tu IP."; @@ -4366,8 +4832,17 @@ swipe action */ "Relay server protects your IP address, but it can observe the duration of the call." = "El servidor de retransmisión protege tu IP pero puede ver la duración de la llamada."; /* No comment provided by engineer. */ +"Relay test failed!" = "¡El test del servidor ha fallado!"; + +/* No comment provided by engineer. */ +"Reliability: many relays per channel." = "Fiabilidad: muchos servidores por canal."; + +/* alert action */ "Remove" = "Eliminar"; +/* alert action */ +"Remove and delete messages" = "Eliminar miembro y sus mensajes"; + /* No comment provided by engineer. */ "Remove archive?" = "¿Eliminar archivo?"; @@ -4380,18 +4855,30 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Expulsar miembro"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "¿Expulsar miembro?"; /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "¿Eliminar contraseña de Keychain?"; +/* No comment provided by engineer. */ +"Remove subscriber" = "Eliminar suscriptor"; + +/* alert title */ +"Remove subscriber?" = "¿Eliminar suscriptor?"; + /* No comment provided by engineer. */ "removed" = "expulsado"; +/* receive error chat item */ +"removed (%d attempts)" = "eliminado (%d intentos)"; + /* rcv group event chat item */ "removed %@" = "ha expulsado a %@"; +/* No comment provided by engineer. */ +"removed by operator" = "eliminado por el operador"; + /* profile update event chat item */ "removed contact address" = "dirección de contacto eliminada"; @@ -4560,6 +5047,9 @@ swipe action */ /* No comment provided by engineer. */ "Run chat" = "Ejecutar SimpleX"; +/* No comment provided by engineer. */ +"Safe web links" = "Enlaces web seguros"; + /* No comment provided by engineer. */ "Safely receive files" = "Recibe archivos de forma segura"; @@ -4576,6 +5066,9 @@ chat item action */ /* alert button */ "Save (and notify members)" = "Guardar (y notificar miembros)"; +/* alert button */ +"Save (and notify subscribers)" = "Guardar (y notificar suscriptores)"; + /* alert title */ "Save admission settings?" = "¿Guardar configuración?"; @@ -4585,12 +5078,21 @@ chat item action */ /* No comment provided by engineer. */ "Save and notify group members" = "Guardar y notificar grupo"; +/* No comment provided by engineer. */ +"Save and notify subscribers" = "Guardar y notificar suscriptores"; + /* No comment provided by engineer. */ "Save and reconnect" = "Guardar y reconectar"; /* No comment provided by engineer. */ "Save and update group profile" = "Guardar y actualizar perfil del grupo"; +/* No comment provided by engineer. */ +"Save channel profile" = "Guardar perfil del canal"; + +/* alert title */ +"Save channel profile?" = "¿Guardar perfil del canal?"; + /* No comment provided by engineer. */ "Save group profile" = "Guardar perfil de grupo"; @@ -4675,9 +5177,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "La barra de búsqueda acepta enlaces de invitación."; +/* No comment provided by engineer. */ +"Search files" = "Buscar archivos"; + +/* No comment provided by engineer. */ +"Search images" = "Buscar imágenes"; + +/* No comment provided by engineer. */ +"Search links" = "Buscar enlaces"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Buscar o pegar enlace SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Buscar vídeos"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Buscar mensajes de voz"; + /* network option */ "sec" = "seg"; @@ -4705,6 +5222,9 @@ chat item action */ /* chat item text */ "security code changed" = "código de seguridad cambiado"; +/* No comment provided by engineer. */ +"Security: owners hold channel keys." = "Seguridad: los propietarios tienen la llave del canal."; + /* chat item action */ "Select" = "Seleccionar"; @@ -4783,12 +5303,18 @@ chat item action */ /* No comment provided by engineer. */ "Send request without message" = "Enviar solicitud sin mensaje"; +/* No comment provided by engineer. */ +"Send the link via any messenger - it's secure. Ask to paste into SimpleX." = "Envía el enlace con cualquier mensajero, es seguro. El contacto debe pegarlo en SimpleX."; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Envíalos desde la galería o desde teclados personalizados."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Se envían hasta 100 mensajes más recientes a los miembros nuevos."; +/* No comment provided by engineer. */ +"Send up to 100 last messages to new subscribers." = "Se envían hasta 100 mensajes más recientes a los suscriptores nuevos."; + /* No comment provided by engineer. */ "Send your private feedback to groups." = "Envía tu comentario privado a los grupos."; @@ -4798,6 +5324,9 @@ chat item action */ /* No comment provided by engineer. */ "Sender may have deleted the connection request." = "El remitente puede haber eliminado la solicitud de conexión."; +/* alert message */ +"Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later." = "Enviar una previsualización del enlace puede revelar tu dirección IP al sitio web. Puedes cambiarlo más tarde en los ajustes de privacidad."; + /* No comment provided by engineer. */ "Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "El envío de confirmaciones de entrega se activará para todos los contactos en todos los perfiles visibles."; @@ -4831,9 +5360,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Directamente"; -/* notification */ -"Sent file event" = "Evento de archivo enviado"; - /* message info title */ "Sent message" = "Mensaje saliente"; @@ -4879,11 +5405,14 @@ chat item action */ /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "información cola del servidor: %1$@\n\núltimo mensaje recibido: %2$@"; -/* server test error */ -"Server requires authorization to create queues, check password." = "El servidor requiere autorización para crear colas, comprueba la contraseña"; +/* relay test error */ +"Server requires authorization to connect to relay, check password." = "El servidor requiere autorización para conectar con el servidor, comprueba la contraseña."; /* server test error */ -"Server requires authorization to upload, check password." = "El servidor requiere autorización para subir, comprueba la contraseña"; +"Server requires authorization to create queues, check password." = "El servidor requiere autorización para crear colas, comprueba la contraseña."; + +/* server test error */ +"Server requires authorization to upload, check password." = "El servidor requiere autorización para subir, comprueba la contraseña."; /* No comment provided by engineer. */ "Server test failed!" = "¡Prueba no superada!"; @@ -4963,6 +5492,12 @@ chat item action */ /* alert message */ "Settings were changed." = "La configuración ha sido modificada."; +/* No comment provided by engineer. */ +"Setup notifications" = "Configurar notificaciones"; + +/* No comment provided by engineer. */ +"Setup routers" = "Configurar routers"; + /* No comment provided by engineer. */ "Shape profile images" = "Dar forma a las imágenes de perfil"; @@ -4983,7 +5518,10 @@ chat item action */ "Share address publicly" = "Campartir dirección públicamente"; /* alert title */ -"Share address with contacts?" = "¿Compartir la dirección con los contactos?"; +"Share address with SimpleX contacts?" = "¿Compartir la dirección con los contactos SimpleX?"; + +/* No comment provided by engineer. */ +"Share channel" = "Compartir canal"; /* No comment provided by engineer. */ "Share from other apps." = "Comparte desde otras aplicaciones."; @@ -5000,6 +5538,9 @@ chat item action */ /* No comment provided by engineer. */ "Share profile" = "Perfil a compartir"; +/* No comment provided by engineer. */ +"Share relay address" = "Compartir dirección del servidor"; + /* No comment provided by engineer. */ "Share SimpleX address on social media." = "Comparte tu dirección SimpleX en redes sociales."; @@ -5010,7 +5551,10 @@ chat item action */ "Share to SimpleX" = "Compartir con Simplex"; /* No comment provided by engineer. */ -"Share with contacts" = "Compartir con contactos"; +"Share via chat" = "Compartir mediante chat"; + +/* No comment provided by engineer. */ +"Share with SimpleX contacts" = "Compartir con contactos SimpleX"; /* No comment provided by engineer. */ "Share your address" = "Comparte tu dirección"; @@ -5115,7 +5659,7 @@ chat item action */ "SimpleX protocols reviewed by Trail of Bits." = "Protocolos de SimpleX auditados por Trail of Bits."; /* simplex link type */ -"SimpleX relay link" = "Enlace de servidor SimpleX"; +"SimpleX relay address" = "Dirección de servidor SimpleX"; /* No comment provided by engineer. */ "Simplified incognito mode" = "Modo incógnito simplificado"; @@ -5169,6 +5713,9 @@ report reason */ /* chat item text */ "standard end-to-end encryption" = "cifrado estándar de extremo a extremo"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Estrella en GitHub"; + /* No comment provided by engineer. */ "Start chat" = "Iniciar chat"; @@ -5235,6 +5782,48 @@ report reason */ /* No comment provided by engineer. */ "Subscribed" = "Suscritas"; +/* No comment provided by engineer. */ +"Subscriber" = "Suscriptor"; + +/* chat feature */ +"Subscriber reports" = "Informes de suscriptores"; + +/* alert message */ +"Subscriber will be removed from channel - this cannot be undone!" = "El suscriptor será eliminado del canal. ¡No puede deshacerse!"; + +/* No comment provided by engineer. */ +"Subscribers" = "Suscriptores"; + +/* No comment provided by engineer. */ +"Subscribers can add message reactions." = "Los suscriptores pueden añadir reacciones a los mensajes."; + +/* No comment provided by engineer. */ +"Subscribers can chat with admins." = "Los suscriptores pueden chatear con los administradores."; + +/* No comment provided by engineer. */ +"Subscribers can irreversibly delete sent messages. (24 hours)" = "Los suscriptores del canal pueden eliminar mensajes de forma irreversible. (24 horas)"; + +/* No comment provided by engineer. */ +"Subscribers can report messsages to moderators." = "Los suscriptores pueden informar de mensajes a los moderadores."; + +/* No comment provided by engineer. */ +"Subscribers can send direct messages." = "Los suscriptores del canal pueden enviar mensajes directos."; + +/* No comment provided by engineer. */ +"Subscribers can send disappearing messages." = "Los suscriptores del canal pueden enviar mensajes temporales."; + +/* No comment provided by engineer. */ +"Subscribers can send files and media." = "Los suscriptores del canal pueden enviar archivos y multimedia."; + +/* No comment provided by engineer. */ +"Subscribers can send SimpleX links." = "Los suscriptores del canal pueden enviar enlaces SimpleX."; + +/* No comment provided by engineer. */ +"Subscribers can send voice messages." = "Los suscriptores del canal pueden enviar mensajes de voz."; + +/* No comment provided by engineer. */ +"Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." = "Los suscriptores usan el enlace del servidor para conectarse a los canales.\nLa dirección del servidor se usó para establecer el servidor para el canal."; + /* No comment provided by engineer. */ "Subscription errors" = "Errores de suscripción"; @@ -5262,6 +5851,9 @@ report reason */ /* No comment provided by engineer. */ "Take picture" = "Hacer foto"; +/* No comment provided by engineer. */ +"Talk to someone" = "Para comunicarte"; + /* No comment provided by engineer. */ "Tap button " = "Pulsa el botón "; @@ -5275,7 +5867,7 @@ report reason */ "Tap Connect to use bot" = "Pulsa Conectar para usar el bot"; /* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Pulsa Crear dirección SimpleX en el menú para crearla más tarde."; +"Tap Join channel" = "Pulsa Unirme al canal"; /* No comment provided by engineer. */ "Tap Join group" = "Pulsa Unirme al grupo"; @@ -5292,6 +5884,9 @@ report reason */ /* No comment provided by engineer. */ "Tap to join incognito" = "Pulsa para unirte en modo incógnito"; +/* No comment provided by engineer. */ +"Tap to open" = "Pulsa para abrir"; + /* No comment provided by engineer. */ "Tap to paste link" = "Pulsa aquí para pegar el enlace"; @@ -5322,12 +5917,16 @@ report reason */ /* file error alert title */ "Temporary file error" = "Error en archivo temporal"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Prueba no superada en el paso %@."; /* No comment provided by engineer. */ "Test notifications" = "Probar notificaciones"; +/* No comment provided by engineer. */ +"Test relay" = "Test servidor"; + /* No comment provided by engineer. */ "Test server" = "Probar servidor"; @@ -5355,6 +5954,9 @@ report reason */ /* No comment provided by engineer. */ "The app protects your privacy by using different operators in each conversation." = "La aplicación protege tu privacidad mediante el uso de diferentes operadores en cada conversación."; +/* No comment provided by engineer. */ +"The app removed this message after %lld attempts to receive it." = "La app ha eliminado el mensaje tras %lld intentos de recibirlo."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "La aplicación pedirá que confirmes las descargas desde servidores de archivos desconocidos (excepto si son .onion)."; @@ -5364,6 +5966,9 @@ report reason */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "El código QR escaneado no es un enlace de SimpleX."; +/* conn error description */ +"The connection reached the limit of undelivered messages" = "La conexión ha alcanzado al límite de mensajes no entregados"; + /* No comment provided by engineer. */ "The connection reached the limit of undelivered messages, your contact may be offline." = "La conexión ha alcanzado el límite de mensajes no entregados. es posible que tu contacto esté desconectado."; @@ -5380,7 +5985,7 @@ report reason */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "El cifrado funciona y un cifrado nuevo no es necesario. ¡Podría dar lugar a errores de conexión!"; /* No comment provided by engineer. */ -"The future of messaging" = "La nueva generación de mensajería privada"; +"The first network where you own\nyour contacts and groups." = "La primera red donde los grupos\ny los contactos son tuyos."; /* No comment provided by engineer. */ "The hash of the previous message is different." = "El hash del mensaje anterior es diferente."; @@ -5406,6 +6011,9 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "La base de datos antigua no se eliminó durante la migración, puede eliminarse."; +/* No comment provided by engineer. */ +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "La libertad más antigua del ser humano, la de hablar con otra persona sin ser observado, materializada sobre una infraestructura que no puede traicionarla."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Las mismas condiciones se aplicarán al operador **%@**."; @@ -5433,6 +6041,12 @@ report reason */ /* No comment provided by engineer. */ "Themes" = "Temas"; +/* No comment provided by engineer. */ +"Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "Después pasamos a internet y cada plataforma pedía una parte de tí: tu nombre, tu número, tus amistades. Aceptamos que el precio de hablar con los demás es informar a alguien de quién es interlocutor. Cada generación, personas y tecnología, ha funcionado así: teléfono, email, mensajería, redes sociales. Parecía el único camino."; + +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "Existe otro camino. Una red sin números de teléfono. Sin nombres de usuario. Sin cuentas. Sin identificadores de ningún tipo. Una red que conecta las personas y entrega mensajes cifrados sin saber quien está conectado."; + /* No comment provided by engineer. */ "These conditions will also apply for: **%@**." = "Estas condiciones también se aplican para: **%@**."; @@ -5475,6 +6089,12 @@ report reason */ /* No comment provided by engineer. */ "This group no longer exists." = "Este grupo ya no existe."; +/* alert message */ +"This is a chat relay address, it cannot be used to connect." = "Esto es una dirección de servidor, no puede usarse para conectar."; + +/* new chat action */ +"This is your link for channel %@!" = "Este es tu enlace para el canal %@!"; + /* No comment provided by engineer. */ "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible."; @@ -5508,6 +6128,9 @@ report reason */ /* No comment provided by engineer. */ "To make a new connection" = "Para hacer una conexión nueva"; +/* No comment provided by engineer. */ +"To make SimpleX Network last." = "Para que la Red SimpleX perdure."; + /* No comment provided by engineer. */ "To protect against your link being replaced, you can compare contact security codes." = "Para protegerte contra una sustitución del enlace, puedes comparar los códigos de seguridad con tu contacto."; @@ -5556,9 +6179,6 @@ report reason */ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Para verificar el cifrado de extremo a extremo con tu contacto, compara (o escanea) el código en ambos dispositivos."; -/* No comment provided by engineer. */ -"Toggle chat list:" = "Alternar lista de chats:"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Activa incógnito al conectar."; @@ -5568,6 +6188,9 @@ report reason */ /* No comment provided by engineer. */ "Toolbar opacity" = "Opacidad barra"; +/* No comment provided by engineer. */ +"Top bar" = "Barra superior"; + /* No comment provided by engineer. */ "Total" = "Total"; @@ -5577,11 +6200,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Sesiones de transporte"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Intentando conectar con el servidor usado para recibir mensajes de este contacto (error: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Intentando conectar con el servidor usado para recibir mensajes de este contacto."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Intentando conectar con el servidor usado para recibir mensajes de esta conexión."; /* No comment provided by engineer. */ "Turkish interface" = "Interfaz en turco"; @@ -5610,6 +6230,9 @@ report reason */ /* No comment provided by engineer. */ "Unblock member?" = "¿Desbloquear miembro?"; +/* No comment provided by engineer. */ +"Unblock subscriber for all?" = "¿Desbloquear al suscriptor para todos?"; + /* rcv group event chat item */ "unblocked %@" = "ha desbloqueado a %@"; @@ -5677,17 +6300,20 @@ report reason */ "Unmute" = "Activar audio"; /* No comment provided by engineer. */ -"unprotected" = "con IP desprotegida"; +"unprotected" = "desprotegida"; /* swipe action */ "Unread" = "No leído"; -/* No comment provided by engineer. */ +/* conn error description */ "Unsupported connection link" = "Enlace de conexión no compatible"; /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Hasta 100 últimos mensajes son enviados a los miembros nuevos."; +/* No comment provided by engineer. */ +"Up to 100 last messages are sent to new subscribers." = "Hasta 100 últimos mensajes son enviados a los suscriptores nuevos."; + /* No comment provided by engineer. */ "Update" = "Actualizar"; @@ -5700,6 +6326,9 @@ report reason */ /* No comment provided by engineer. */ "Update settings?" = "¿Actualizar configuración?"; +/* rcv group event chat item */ +"updated channel profile" = "perfil del canal actualizado"; + /* No comment provided by engineer. */ "Updated conditions" = "Condiciones actualizadas"; @@ -5757,9 +6386,6 @@ report reason */ /* No comment provided by engineer. */ "Use %@" = "Usar %@"; -/* No comment provided by engineer. */ -"Use chat" = "Usar Chat"; - /* new chat action */ "Use current profile" = "Usar perfil actual"; @@ -5769,6 +6395,9 @@ report reason */ /* No comment provided by engineer. */ "Use for messages" = "Uso para mensajes"; +/* No comment provided by engineer. */ +"Use for new channels" = "Usar para canales nuevos"; + /* No comment provided by engineer. */ "Use for new connections" = "Para conexiones nuevas"; @@ -5793,6 +6422,9 @@ report reason */ /* No comment provided by engineer. */ "Use private routing with unknown servers." = "Usar enrutamiento privado con servidores de mensaje desconocidos."; +/* No comment provided by engineer. */ +"Use relay" = "Usar servidor"; + /* No comment provided by engineer. */ "Use server" = "Usar servidor"; @@ -5817,6 +6449,9 @@ report reason */ /* No comment provided by engineer. */ "Use the app with one hand." = "Usa la aplicación con una sola mano."; +/* No comment provided by engineer. */ +"Use this address in your social media profile, website, or email signature." = "Usa esta dirección en tu perfil de redes sociales, página web o firma email."; + /* No comment provided by engineer. */ "Use web port" = "Usar puerto web"; @@ -5835,6 +6470,9 @@ report reason */ /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* relay test step */ +"Verify" = "Verificar"; + /* No comment provided by engineer. */ "Verify code with desktop" = "Verificar código con ordenador"; @@ -5856,6 +6494,9 @@ report reason */ /* No comment provided by engineer. */ "Verify security code" = "Comprobar código de seguridad"; +/* relay hostname */ +"via %@" = "mediante %@"; + /* No comment provided by engineer. */ "Via browser" = "Mediante navegador"; @@ -5889,6 +6530,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "El vídeo se recibirá cuando el contacto esté en línea, por favor espera o revisa más tarde."; +/* No comment provided by engineer. */ +"Videos" = "Vídeos"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Vídeos y archivos de hasta 1Gb"; @@ -5922,9 +6566,18 @@ report reason */ /* No comment provided by engineer. */ "Voice messages prohibited!" = "¡Mensajes de voz no permitidos!"; +/* alert action */ +"Wait" = "Espera"; + +/* relay test step */ +"Wait response" = "Espera respuesta"; + /* No comment provided by engineer. */ "waiting for answer…" = "esperando respuesta…"; +/* No comment provided by engineer. */ +"Waiting for channel owner to add relays." = "Esperando a que el propietario del canal añada servidores."; + /* No comment provided by engineer. */ "waiting for confirmation…" = "esperando confirmación…"; @@ -5955,6 +6608,9 @@ report reason */ /* No comment provided by engineer. */ "Warning: you may lose some data!" = "Atención: ¡puedes perder algunos datos!"; +/* No comment provided by engineer. */ +"We made connecting simpler for new users." = "Hemos simplificado la conexión para los usuarios nuevos."; + /* No comment provided by engineer. */ "WebRTC ICE servers" = "Servidores WebRTC ICE"; @@ -5991,6 +6647,9 @@ report reason */ /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Cuando compartes un perfil incógnito con alguien, este perfil también se usará para los grupos a los que te inviten."; +/* No comment provided by engineer. */ +"Why SimpleX is built." = "Por qué fue creado SimpleX."; + /* No comment provided by engineer. */ "WiFi" = "WiFi"; @@ -6043,7 +6702,7 @@ report reason */ "You accepted connection" = "Has aceptado la conexión"; /* snd group event chat item */ -"you accepted this member" = "has aceptado al miembro"; +"you accepted this member" = "has admitido al miembro"; /* No comment provided by engineer. */ "You allow" = "Permites"; @@ -6075,18 +6734,24 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "¡En proceso de unirte al grupo!\n¿Repetir solicitud de admisión?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Estás conectado al servidor usado para recibir mensajes de este contacto."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Estás conectado al servidor usado para recibir mensajes de esta conexión."; /* No comment provided by engineer. */ "You are invited to group" = "Has sido invitado a un grupo"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado."; /* No comment provided by engineer. */ "you are observer" = "Tu rol es observador"; +/* No comment provided by engineer. */ +"you are subscriber" = "eres suscriptor"; + /* snd group event chat item */ "you blocked %@" = "has bloqueado a %@"; @@ -6130,7 +6795,10 @@ report reason */ "You can set lock screen notification preview via settings." = "Puedes configurar las notificaciones de la pantalla de bloqueo desde Configuración."; /* No comment provided by engineer. */ -"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Puedes compartir el enlace o el código QR para que cualquiera pueda unirse al grupo. Si más tarde lo eliminas, no afectará a los miembros del grupo."; +"You can share a link or a QR code - anybody will be able to join the channel." = "Puedes compartir un enlace o código QR. Cualquiera podrá unirse al canal."; + +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Puedes compartir el enlace o código QR. Cualquiera podrá unirse al grupo. Si más tarde lo eliminas, no afectará a los miembros del grupo."; /* No comment provided by engineer. */ "You can share this address with your contacts to let them connect with **%@**." = "Puedes compartir esta dirección con tus contactos para que puedan conectar con **%@**."; @@ -6169,10 +6837,13 @@ report reason */ "you changed role of %@ to %@" = "has cambiado el rol de %1$@ a %2$@"; /* No comment provided by engineer. */ -"You could not be verified; please try again." = "No has podido ser autenticado. Inténtalo de nuevo."; +"You commit to:\n- Only legal content in public groups\n- Respect other users - no spam" = "Te comprometes a:\n- Sólo contenido legal en grupos públicos\n- Respetar a los demás usuarios — no hacer spam"; /* No comment provided by engineer. */ -"You decide who can connect." = "Tu decides quién se conecta."; +"You connected to the channel via this relay link." = "Te conectaste al canal mediante este enlace de servidor."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "No has podido ser autenticado. Inténtalo de nuevo."; /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Ya has solicitado la conexión\n¿Repetir solicitud?"; @@ -6228,6 +6899,9 @@ report reason */ /* snd group event chat item */ "you unblocked %@" = "has desbloqueado a %@"; +/* No comment provided by engineer. */ +"You were born without an account" = "Naciste sin una cuenta"; + /* No comment provided by engineer. */ "You will be able to send messages **only after your request is accepted**." = "Podrás enviar mensajes **después de que tu solicitud sea aceptada**."; @@ -6250,7 +6924,10 @@ report reason */ "You will still receive calls and notifications from muted profiles when they are active." = "Seguirás recibiendo llamadas y notificaciones de los perfiles silenciados cuando estén activos."; /* No comment provided by engineer. */ -"You will stop receiving messages from this chat. Chat history will be preserved." = "Dejarás de recibir mensajes del chat. El historial del chat se conserva."; +"You will stop receiving messages from this channel. Chat history will be preserved." = "Dejarás de recibir mensajes de este canal. El historial del chat se conservará."; + +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Dejarás de recibir mensajes del chat. El historial del chat se conservará."; /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Dejarás de recibir mensajes del grupo. El historial del chat se conservará."; @@ -6273,6 +6950,9 @@ report reason */ /* No comment provided by engineer. */ "Your calls" = "Llamadas"; +/* No comment provided by engineer. */ +"Your channel" = "Tu canal"; + /* No comment provided by engineer. */ "Your chat database" = "Base de datos"; @@ -6303,6 +6983,9 @@ report reason */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "Tus contactos permanecerán conectados."; +/* No comment provided by engineer. */ +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "Tus conversaciones te pertenecen, tal como ha sido siempre antes de la llegada de internet. Tu red no es un lugar que visitas. Es un lugar que has creado, te pertenece y nadie te la podrá quitar, ya sea pública o privada."; + /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "Tus credenciales podrían ser enviadas sin cifrar."; @@ -6318,6 +7001,9 @@ report reason */ /* No comment provided by engineer. */ "Your ICE servers" = "Servidores ICE"; +/* No comment provided by engineer. */ +"Your network" = "Tu red"; + /* No comment provided by engineer. */ "Your preferences" = "Mis preferencias"; @@ -6327,6 +7013,9 @@ report reason */ /* No comment provided by engineer. */ "Your profile" = "Tu perfil"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared with channel relays and subscribers.\nRelays can access channel messages." = "El perfil **%@** será compartido con los servidores de canal y los suscriptores.\nLos servidores tienen acceso a los mensajes del canal."; + /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "El perfil **%@** será compartido."; @@ -6334,14 +7023,23 @@ report reason */ "Your profile is stored on your device and only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Tu perfil ha sido modificado. Si lo guardas la actualización será enviada a todos tus contactos."; +/* No comment provided by engineer. */ +"Your public address" = "Tu dirección pública"; + /* No comment provided by engineer. */ "Your random profile" = "Tu perfil aleatorio"; +/* No comment provided by engineer. */ +"Your relay address" = "Tu dirección de servidor"; + +/* No comment provided by engineer. */ +"Your relay name" = "Tu nombre del servidor"; + /* No comment provided by engineer. */ "Your server address" = "Dirección del servidor"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 76e4c1be0d..b75323054a 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -13,15 +13,9 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 värillinen!"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Osallistu](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Lähetä meille sähköpostia](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Tähti GitHubissa](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**e2e encrypted** audio call" = "**e2e-salattu** äänipuhelu"; @@ -257,9 +251,6 @@ swipe action */ /* call status */ "accepted call" = "hyväksytty puhelu"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Lisää osoite profiiliisi, jotta kontaktisi voivat jakaa sen muiden kanssa. Profiilipäivitys lähetetään kontakteillesi."; - /* No comment provided by engineer. */ "Add profile" = "Lisää profiili"; @@ -386,9 +377,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Vastaa puheluun"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia."; - /* No comment provided by engineer. */ "App build: %@" = "Sovellusversio: %@"; @@ -650,7 +638,8 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm password" = "Vahvista salasana"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Yhdistä"; /* No comment provided by engineer. */ @@ -698,7 +687,7 @@ set passcode view */ /* alert title */ "Connection error" = "Yhteysvirhe"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Yhteysvirhe (AUTH)"; /* chat list item title (it should not be shown */ @@ -746,15 +735,15 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Jatka"; +/* No comment provided by engineer. */ +"Contribute" = "Osallistu"; + /* No comment provided by engineer. */ "Copy" = "Kopioi"; /* No comment provided by engineer. */ "Core version: v%@" = "Ydinversio: v%@"; -/* No comment provided by engineer. */ -"Create" = "Luo"; - /* server test step */ "Create file" = "Luo tiedosto"; @@ -860,9 +849,6 @@ set passcode view */ /* time unit */ "days" = "päivää"; -/* No comment provided by engineer. */ -"Decentralized" = "Hajautettu"; - /* message decrypt error item */ "Decryption error" = "Salauksen purkuvirhe"; @@ -943,7 +929,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Poista viesti?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Poista viestit"; /* No comment provided by engineer. */ @@ -1096,7 +1083,7 @@ swipe action */ /* No comment provided by engineer. */ "Edit group profile" = "Muokkaa ryhmäprofiilia"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Salli"; /* No comment provided by engineer. */ @@ -1114,9 +1101,6 @@ swipe action */ /* No comment provided by engineer. */ "Enable lock" = "Ota lukitus käyttöön"; -/* No comment provided by engineer. */ -"Enable notifications" = "Salli ilmoitukset"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "Salli säännölliset ilmoitukset?"; @@ -1225,7 +1209,7 @@ swipe action */ /* No comment provided by engineer. */ "error" = "virhe"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Virhe"; /* No comment provided by engineer. */ @@ -1428,7 +1412,8 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Löydä keskustelut nopeammin"; -/* server test error */ +/* relay test error +server test error */ "Fingerprint in server address does not match certificate." = "Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen"; /* No comment provided by engineer. */ @@ -1494,7 +1479,7 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen."; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Ryhmälinkki"; /* No comment provided by engineer. */ @@ -1596,9 +1581,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "Heti"; -/* No comment provided by engineer. */ -"Immune to spam" = "Immuuni roskapostille ja väärinkäytöksille"; - /* No comment provided by engineer. */ "Import" = "Tuo"; @@ -1663,7 +1645,7 @@ snd error text */ "Initial role" = "Alkuperäinen rooli"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Asenna [SimpleX Chat terminaalille](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Asenna SimpleX Chat terminaalille"; /* No comment provided by engineer. */ "Instant" = "Heti"; @@ -1680,7 +1662,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "virheelliset keskustelu-tiedot"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Virheellinen yhteyslinkki"; /* invalid chat item */ @@ -1869,7 +1851,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Jäsenen rooli muutetaan muotoon \"%@\". Jäsen saa uuden kutsun."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Jäsen poistetaan ryhmästä - tätä ei voi perua!"; /* No comment provided by engineer. */ @@ -1983,7 +1965,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Verkkoasetukset"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Verkon tila"; /* delete after time */ @@ -2061,9 +2043,6 @@ snd error text */ /* copied message info in history */ "no text" = "ei tekstiä"; -/* No comment provided by engineer. */ -"No user identifiers." = "Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi."; - /* No comment provided by engineer. */ "Notifications" = "Ilmoitukset"; @@ -2255,9 +2234,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "Yksityisyys ja turvallisuus"; -/* No comment provided by engineer. */ -"Privacy redefined" = "Yksityisyys uudelleen määritettynä"; - /* No comment provided by engineer. */ "Private filenames" = "Yksityiset tiedostonimet"; @@ -2270,9 +2246,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile password" = "Profiilin salasana"; -/* alert message */ -"Profile update will be sent to your contacts." = "Profiilipäivitys lähetetään kontakteillesi."; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Estä ääni- ja videopuhelut."; @@ -2325,13 +2298,10 @@ new chat action */ "Read more" = "Lue lisää"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; +"Read more in our GitHub repository." = "Lue lisää GitHub-arkistosta."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Lue lisää [GitHub-arkistosta](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Lue lisää Käyttöoppaasta."; /* No comment provided by engineer. */ "Receipts are disabled" = "Kuittaukset pois käytöstä"; @@ -2348,9 +2318,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "vahvistus saatu…"; -/* notification */ -"Received file event" = "Tiedoston vastaanottotapahtuma"; - /* message info title */ "Received message" = "Vastaanotettu viesti"; @@ -2401,13 +2368,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Poista"; /* No comment provided by engineer. */ "Remove member" = "Poista jäsen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Poista jäsen?"; /* No comment provided by engineer. */ @@ -2642,9 +2609,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Lähetetty klo: %@"; -/* notification */ -"Sent file event" = "Lähetetty tiedosto tapahtuma"; - /* message info title */ "Sent message" = "Lähetetty viesti"; @@ -2700,15 +2664,9 @@ chat item action */ /* No comment provided by engineer. */ "Share address" = "Jaa osoite"; -/* alert title */ -"Share address with contacts?" = "Jaa osoite kontakteille?"; - /* No comment provided by engineer. */ "Share link" = "Jaa linkki"; -/* No comment provided by engineer. */ -"Share with contacts" = "Jaa kontaktien kanssa"; - /* No comment provided by engineer. */ "Show calls in phone history" = "Näytä puhelut puhelinhistoriassa"; @@ -2775,6 +2733,9 @@ chat item action */ /* notification title */ "Somebody" = "Joku"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Tähti GitHubissa"; + /* No comment provided by engineer. */ "Start chat" = "Aloita keskustelu"; @@ -2853,7 +2814,8 @@ chat item action */ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Testi epäonnistui vaiheessa %@."; /* No comment provided by engineer. */ @@ -2892,9 +2854,6 @@ chat item action */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin!"; -/* No comment provided by engineer. */ -"The future of messaging" = "Seuraavan sukupolven yksityisviestit"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Edellisen viestin tarkiste on erilainen."; @@ -2979,12 +2938,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Kuljetuksen eristäminen"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta."; - /* No comment provided by engineer. */ "Turn off" = "Sammuta"; @@ -3066,9 +3019,6 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = "Käytä .onion-isäntiä"; -/* No comment provided by engineer. */ -"Use chat" = "Käytä chattia"; - /* new chat action */ "Use current profile" = "Käytä nykyistä profiilia"; @@ -3213,9 +3163,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Olet jo muodostanut yhteyden %@:n kanssa."; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta."; - /* No comment provided by engineer. */ "You are invited to group" = "Sinut on kutsuttu ryhmään"; @@ -3276,9 +3223,6 @@ chat item action */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "Sinua ei voitu todentaa; yritä uudelleen."; -/* No comment provided by engineer. */ -"You decide who can connect." = "Kimin bağlanabileceğine siz karar verirsiniz."; - /* No comment provided by engineer. */ "You have to enter passphrase every time the app starts - it is not stored on the device." = "Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen."; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 2a210cbfa8..329490f34b 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(cet appareil v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuer](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Contact par mail](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Star sur GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Ajouter un contact** : pour créer un nouveau lien d'invitation."; @@ -392,9 +386,6 @@ swipe action */ /* No comment provided by engineer. */ "Active connections" = "Connections actives"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d'autres personnes. La mise à jour du profil sera envoyée à vos contacts."; - /* No comment provided by engineer. */ "Add friends" = "Ajouter des amis"; @@ -638,9 +629,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Répondre à l'appel"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "N'importe qui peut heberger un serveur."; - /* No comment provided by engineer. */ "App build: %@" = "Build de l'app : %@"; @@ -864,7 +852,7 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgare, finnois, thaïlandais et ukrainien - grâce aux utilisateurs et à [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) !"; -/* No comment provided by engineer. */ +/* chat link info line */ "Business address" = "Adresse professionnelle"; /* No comment provided by engineer. */ @@ -876,9 +864,6 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "En utilisant SimpleX Chat, vous acceptez de :\n- n'envoyer que du contenu légal dans les groupes publics.\n- respecter les autres utilisateurs - pas de spam."; - /* No comment provided by engineer. */ "call" = "appeler"; @@ -1143,7 +1128,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Les conditions sont déjà acceptées pour ces opérateurs : **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Conditions d'utilisation"; /* No comment provided by engineer. */ @@ -1158,9 +1143,6 @@ set passcode view */ /* No comment provided by engineer. */ "Configure ICE servers" = "Configurer les serveurs ICE"; -/* No comment provided by engineer. */ -"Configure server operators" = "Configurer les opérateurs de serveur"; - /* No comment provided by engineer. */ "Confirm" = "Confirmer"; @@ -1194,7 +1176,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Confirmé"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Se connecter"; /* No comment provided by engineer. */ @@ -1290,7 +1273,7 @@ set passcode view */ /* alert title */ "Connection error" = "Erreur de connexion"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Erreur de connexion (AUTH)"; /* chat list item title (it should not be shown */ @@ -1377,6 +1360,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Continuer"; +/* No comment provided by engineer. */ +"Contribute" = "Contribuer"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Conversation supprimée !"; @@ -1392,12 +1378,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Coin"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Corriger le nom pour %@ ?"; -/* No comment provided by engineer. */ -"Create" = "Créer"; - /* No comment provided by engineer. */ "Create 1-time link" = "Créer un lien unique"; @@ -1548,9 +1531,6 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Livraison de débogage"; -/* No comment provided by engineer. */ -"Decentralized" = "Décentralisé"; - /* message decrypt error item */ "Decryption error" = "Erreur de déchiffrement"; @@ -1661,7 +1641,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Supprimer le message ?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Supprimer les messages"; /* No comment provided by engineer. */ @@ -1935,7 +1916,7 @@ chat item action */ /* No comment provided by engineer. */ "Edit group profile" = "Modifier le profil du groupe"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Activer"; /* No comment provided by engineer. */ @@ -1962,9 +1943,6 @@ chat item action */ /* No comment provided by engineer. */ "Enable lock" = "Activer le verrouillage"; -/* No comment provided by engineer. */ -"Enable notifications" = "Activer les notifications"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "Activer les notifications périodiques ?"; @@ -2106,7 +2084,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "erreur"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Erreur"; /* No comment provided by engineer. */ @@ -2465,7 +2443,8 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Recherche de message plus rapide"; -/* server test error */ +/* relay test error +server test error */ "Fingerprint in server address does not match certificate." = "Il est possible que l'empreinte du certificat dans l'adresse du serveur soit incorrecte"; /* No comment provided by engineer. */ @@ -2486,7 +2465,8 @@ snd error text */ /* No comment provided by engineer. */ "Fix not supported by group member" = "Correction non prise en charge par un membre du groupe"; -/* servers error */ +/* servers error +servers warning */ "For chat profile %@:" = "Pour le profil de discussion %@ :"; /* No comment provided by engineer. */ @@ -2606,7 +2586,7 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "L'invitation du groupe n'est plus valide, elle a été supprimé par l'expéditeur."; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Lien du groupe"; /* No comment provided by engineer. */ @@ -2720,9 +2700,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "Immédiatement"; -/* No comment provided by engineer. */ -"Immune to spam" = "Protégé du spam et des abus"; - /* No comment provided by engineer. */ "Import" = "Importer"; @@ -2817,7 +2794,7 @@ snd error text */ "Initial role" = "Rôle initial"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Installer [SimpleX Chat pour terminal](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Installer SimpleX Chat pour terminal"; /* No comment provided by engineer. */ "Instant" = "Instantané"; @@ -2837,7 +2814,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "données de chat invalides"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Lien de connection invalide"; /* invalid chat item */ @@ -2852,7 +2829,7 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Confirmation de migration invalide"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Nom invalide !"; /* No comment provided by engineer. */ @@ -3104,10 +3081,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Le rôle du membre sera changé pour \"%@\". Ce membre recevra une nouvelle invitation."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la discussion - cela ne peut pas être annulé !"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe - impossible de revenir en arrière !"; /* No comment provided by engineer. */ @@ -3221,9 +3198,6 @@ snd error text */ /* No comment provided by engineer. */ "Migrate device" = "Transférer l'appareil"; -/* No comment provided by engineer. */ -"Migrate from another device" = "Transférer depuis un autre appareil"; - /* No comment provided by engineer. */ "Migrate here" = "Transférer ici"; @@ -3323,7 +3297,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Paramètres réseau"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "État du réseau"; /* delete after time */ @@ -3458,9 +3432,6 @@ snd error text */ /* copied message info in history */ "no text" = "aucun texte"; -/* No comment provided by engineer. */ -"No user identifiers." = "Aucun identifiant d'utilisateur."; - /* No comment provided by engineer. */ "Not compatible!" = "Non compatible !"; @@ -3505,7 +3476,7 @@ alert button new chat action */ "Ok" = "Ok"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "OK"; /* No comment provided by engineer. */ @@ -3574,7 +3545,8 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Seul votre contact peut envoyer des messages vocaux."; -/* alert action */ +/* alert action +alert button */ "Open" = "Ouvrir"; /* No comment provided by engineer. */ @@ -3775,9 +3747,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy for your customers." = "Respect de la vie privée de vos clients."; -/* No comment provided by engineer. */ -"Privacy redefined" = "La vie privée redéfinie"; - /* No comment provided by engineer. */ "Private filenames" = "Noms de fichiers privés"; @@ -3811,9 +3780,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile theme" = "Thème de profil"; -/* alert message */ -"Profile update will be sent to your contacts." = "La mise à jour du profil sera envoyée à vos contacts."; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Interdire les appels audio/vidéo."; @@ -3896,16 +3862,10 @@ new chat action */ "Read more" = "En savoir plus"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Pour en savoir plus, consultez le [Guide de l'utilisateur](https ://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Pour en savoir plus, consultez notre dépôt GitHub."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Pour en savoir plus, consultez le [Guide de l'utilisateur](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Pour en savoir plus, consultez le [Guide de l'utilisateur](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Pour en savoir plus, consultez notre [dépôt GitHub](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Pour en savoir plus, consultez le Guide de l'utilisateur."; /* No comment provided by engineer. */ "Receipts are disabled" = "Les accusés de réception sont désactivés"; @@ -3925,9 +3885,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "confimation reçu…"; -/* notification */ -"Received file event" = "Événement de fichier reçu"; - /* message info title */ "Received message" = "Message reçu"; @@ -4008,7 +3965,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Le serveur relais protège votre adresse IP, mais il peut observer la durée de l'appel."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Supprimer"; /* No comment provided by engineer. */ @@ -4020,7 +3977,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Retirer le membre"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Retirer ce membre ?"; /* No comment provided by engineer. */ @@ -4378,9 +4335,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Envoyé directement"; -/* notification */ -"Sent file event" = "Événement de fichier envoyé"; - /* message info title */ "Sent message" = "Message envoyé"; @@ -4517,9 +4471,6 @@ chat item action */ /* No comment provided by engineer. */ "Share address publicly" = "Partager publiquement votre adresse"; -/* alert title */ -"Share address with contacts?" = "Partager l'adresse avec vos contacts ?"; - /* No comment provided by engineer. */ "Share from other apps." = "Partager depuis d'autres applications."; @@ -4538,9 +4489,6 @@ chat item action */ /* No comment provided by engineer. */ "Share to SimpleX" = "Partager sur SimpleX"; -/* No comment provided by engineer. */ -"Share with contacts" = "Partager avec vos contacts"; - /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Afficher → sur les messages envoyés via le routage privé."; @@ -4676,6 +4624,9 @@ chat item action */ /* chat item text */ "standard end-to-end encryption" = "chiffrement de bout en bout standard"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Star sur GitHub"; + /* No comment provided by engineer. */ "Start chat" = "Démarrer le chat"; @@ -4769,9 +4720,6 @@ chat item action */ /* No comment provided by engineer. */ "Tap button " = "Appuyez sur le bouton "; -/* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement."; - /* No comment provided by engineer. */ "Tap to activate profile." = "Appuyez pour activer un profil."; @@ -4808,7 +4756,8 @@ chat item action */ /* file error alert title */ "Temporary file error" = "Erreur de fichier temporaire"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Échec du test à l'étape %@."; /* No comment provided by engineer. */ @@ -4859,9 +4808,6 @@ chat item action */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Le chiffrement fonctionne et le nouvel accord de chiffrement n'est pas nécessaire. Cela peut provoquer des erreurs de connexion !"; -/* No comment provided by engineer. */ -"The future of messaging" = "La nouvelle génération de messagerie privée"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Le hash du message précédent est différent."; @@ -5012,9 +4958,6 @@ chat item action */ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils."; -/* No comment provided by engineer. */ -"Toggle chat list:" = "Afficher la liste des conversations :"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Basculer en mode incognito lors de la connexion."; @@ -5030,12 +4973,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "Sessions de transport"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact."; - /* No comment provided by engineer. */ "Turkish interface" = "Interface en turc"; @@ -5186,9 +5123,6 @@ chat item action */ /* No comment provided by engineer. */ "Use %@" = "Utiliser %@"; -/* No comment provided by engineer. */ -"Use chat" = "Utiliser le chat"; - /* new chat action */ "Use current profile" = "Utiliser le profil actuel"; @@ -5486,9 +5420,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Vous êtes déjà membre de ce groupe !\nRépéter la demande d'adhésion ?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact."; - /* No comment provided by engineer. */ "You are invited to group" = "Vous êtes invité·e au groupe"; @@ -5579,9 +5510,6 @@ chat item action */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "Vous n'avez pas pu être vérifié·e ; veuillez réessayer."; -/* No comment provided by engineer. */ -"You decide who can connect." = "Vous choisissez qui peut se connecter."; - /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Vous avez déjà demandé une connexion !\nRépéter la demande de connexion ?"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index bb4d4b1eca..623d79433c 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -5,11 +5,14 @@ "_italic_" = "\\_dőlt_"; /* No comment provided by engineer. */ -"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- kapcsolódás a [könyvtár szolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- kézbesítési jelentések (legfeljebb 20 tag).\n- gyorsabb és stabilabb."; +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- kapcsolódás a [könyvtárszolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb."; /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilabb üzenetkézbesítés.\n- picit továbbfejlesztett csoportok.\n- és még sok más!"; +/* No comment provided by engineer. */ +"- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking." = "- Hivatkozások előnézetének küldése.\n- Hiperhivatkozásokon keresztüli adathalászat megakadályozása.\n- Hivatkozások nyomonkövetési paramétereinek eltávolítása."; + /* No comment provided by engineer. */ "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- partnerek értesítése a törlésről (nem kötelező)\n- profilnevek szóközökkel\n- és még sok más!"; @@ -19,21 +22,21 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 színezett!"; +/* chat link info line */ +"(from owner)" = "(a tulajdonostól)"; + /* No comment provided by engineer. */ "(new)" = "(új)"; +/* chat link info line */ +"(signed)" = "(aláírva)"; + /* No comment provided by engineer. */ "(this device v%@)" = "(ez az eszköz: v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Közreműködés](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Küldjön nekünk e-mailt](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Csillagozás a GitHubon](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; @@ -41,22 +44,22 @@ "**Create group**: to create a new group." = "**Csoport létrehozása:** új csoport létrehozásához."; /* No comment provided by engineer. */ -"**e2e encrypted** audio call" = "**e2e titkosított** hanghívás"; +"**e2e encrypted** audio call" = "**végpontok között titkosított** hanghívás"; /* No comment provided by engineer. */ -"**e2e encrypted** video call" = "**e2e titkosított** videóhívás"; +"**e2e encrypted** video call" = "**végpontok között titkosított** videóhívás"; /* No comment provided by engineer. */ "**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van."; /* No comment provided by engineer. */ -"**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." = "**Legprivátabb:** ne használja a SimpleX Chat értesítési kiszolgálót, rendszeresen ellenőrizze az üzeneteket a háttérben (attól függően, hogy milyen gyakran használja az alkalmazást)."; +"**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." = "**A legprivátabb**: Az alkalmazás nem használja a SimpleX Chat push-kiszolgálóját. Az alkalmazás a háttérben ellenőrzi az üzeneteket, amikor a rendszer ezt lehetővé teszi, attól függően, hogy Ön milyen gyakran használja az alkalmazást."; /* No comment provided by engineer. */ "**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti."; /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; @@ -65,7 +68,10 @@ "**Scan / Paste link**: to connect via a link you received." = "**Hivatkozás beolvasása / beillesztése**: egy kapott hivatkozáson keresztüli kapcsolódáshoz."; /* No comment provided by engineer. */ -"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; +"**Test relay** to retrieve its name." = "**Átjátszó tesztelése** a nevének lekéréséhez."; + +/* No comment provided by engineer. */ +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; /* No comment provided by engineer. */ "**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum el lesz távolítva."; @@ -119,10 +125,10 @@ "%@ is connected!" = "%@ kapcsolódott!"; /* No comment provided by engineer. */ -"%@ is not verified" = "%@ nincs hitelesítve"; +"%@ is not verified" = "%@ nincs ellenőrizve"; /* No comment provided by engineer. */ -"%@ is verified" = "%@ hitelesítve"; +"%@ is verified" = "%@ ellenőrizve"; /* No comment provided by engineer. */ "%@ server" = "%@ kiszolgáló"; @@ -175,6 +181,18 @@ /* time interval */ "%d months" = "%d hónap"; +/* channel relay bar +channel subscriber relay bar */ +"%d relays failed" = "%d átjátszóhoz nem sikerült kapcsolódni"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays not active" = "%d átjátszó inaktív"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays removed" = "%d átjátszó eltávolítva"; + /* time interval */ "%d sec" = "%d mp"; @@ -184,9 +202,41 @@ /* integrity error chat item */ "%d skipped message(s)" = "%d üzenet kihagyva"; +/* channel subscriber count */ +"%d subscriber" = "%d feliratkozó"; + +/* channel subscriber count */ +"%d subscribers" = "%d feliratkozó"; + /* time interval */ "%d weeks" = "%d hét"; +/* channel creation progress +channel relay bar progress */ +"%d/%d relays active" = "%1$d/%2$d átjátszó aktív"; + +/* channel relay bar */ +"%d/%d relays active, %d errors" = "%1$d/%2$d átjátszó aktív, %3$d hiba"; + +/* channel creation progress with errors +channel relay bar */ +"%d/%d relays active, %d failed" = "%1$d/%2$d átjátszó aktív, %3$d sikertelen"; + +/* channel relay bar */ +"%d/%d relays active, %d removed" = "%1$d/%2$d átjátszó aktív, %3$d eltávolítva"; + +/* channel subscriber relay bar progress */ +"%d/%d relays connected" = "%1$d/%2$d átjátszó kapcsolódva"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d errors" = "%1$d/%2$d átjátszó kapcsolódva, %3$d hiba"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d failed" = "%1$d/%2$d átjátszó kapcsolódott, %3$d átjátszóhoz nem sikerült kapcsolódni"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d removed" = "%1$d/%2$d átjátszó kapcsolódott, %3$d eltávolítva"; + /* No comment provided by engineer. */ "%lld" = "%lld"; @@ -194,7 +244,10 @@ "%lld %@" = "%lld %@"; /* No comment provided by engineer. */ -"%lld contact(s) selected" = "%lld partner kijelölve"; +"%lld channel events" = "%lld csatornaesemény"; + +/* No comment provided by engineer. */ +"%lld contact(s) selected" = "%lld partner kiválasztva"; /* No comment provided by engineer. */ "%lld file(s) with total size of %@" = "%lld fájl, %@ összméretben"; @@ -227,7 +280,7 @@ "%lld seconds" = "%lld mp"; /* No comment provided by engineer. */ -"%lldd" = "%lldn"; +"%lldd" = "%lldnap"; /* No comment provided by engineer. */ "%lldh" = "%lldó"; @@ -239,7 +292,7 @@ "%lldm" = "%lldp"; /* No comment provided by engineer. */ -"%lldmth" = "%lldh"; +"%lldmth" = "%lldhónap"; /* No comment provided by engineer. */ "%llds" = "%lldmp"; @@ -262,6 +315,9 @@ /* No comment provided by engineer. */ "~strike~" = "\\~áthúzott~"; +/* owner verification */ +"⚠️ Signature verification failed: %@." = "⚠️ Nem sikerült ellenőrizni az aláírást: %@."; + /* time to disappear */ "0 sec" = "0 mp"; @@ -293,7 +349,7 @@ time interval */ "1-time link" = "Egyszer használható meghívó"; /* No comment provided by engineer. */ -"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó egy hivatkozás és *csak egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható."; +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó egy hivatkozás és *csak egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltó alkalmazáson keresztül megosztható."; /* No comment provided by engineer. */ "5 minutes" = "5 perc"; @@ -307,6 +363,9 @@ time interval */ /* No comment provided by engineer. */ "A few more things" = "Néhány további dolog"; +/* No comment provided by engineer. */ +"A link for one person to connect" = "Egy hivatkozás, ami egyetlen partnerrel való kapcsolat létrehozására szolgál"; + /* notification title */ "A new contact" = "Egy új partner"; @@ -371,6 +430,9 @@ swipe action */ /* alert title */ "Accept member" = "Tag befogadása"; +/* No comment provided by engineer. */ +"accepted" = "elfogadva"; + /* rcv group event chat item */ "accepted %@" = "befogadta őt: %@"; @@ -392,6 +454,9 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledgement errors" = "Visszaigazolási hibák"; +/* No comment provided by engineer. */ +"active" = "aktív"; + /* token status text */ "Active" = "Aktív"; @@ -399,7 +464,7 @@ swipe action */ "Active connections" = "Aktív kapcsolatok száma"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára."; +"Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts." = "Cím hozzáadása a profilhoz, hogy a SimpleX partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve a SimpleX partnerei számára."; /* No comment provided by engineer. */ "Add friends" = "Barátok hozzáadása"; @@ -440,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Hozzáadott üzenetkiszolgálók"; +/* No comment provided by engineer. */ +"Adding relays will be supported later." = "Az átjátszók hozzáadása később lesz támogatott."; + /* No comment provided by engineer. */ "Additional accent" = "További kiemelőszín"; @@ -507,11 +575,14 @@ swipe action */ "All data is kept private on your device." = "Az összes adat privát módon van tárolva az eszközén."; /* No comment provided by engineer. */ -"All group members will remain connected." = "Az összes csoporttag kapcsolatban marad."; +"All group members will remain connected." = "Az összes csoporttag továbbra is kapcsolatban marad."; /* feature role */ "all members" = "összes tag"; +/* No comment provided by engineer. */ +"All messages" = "Összes üzenet"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek."; @@ -527,6 +598,12 @@ swipe action */ /* profile dropdown */ "All profiles" = "Összes profil"; +/* No comment provided by engineer. */ +"All relays failed" = "Nem sikerült kapcsolódni egyetlen átjátszóhoz sem"; + +/* No comment provided by engineer. */ +"All relays removed" = "Az összes átjátszó el lett távolítva"; + /* No comment provided by engineer. */ "All reports will be archived for you." = "Az összes jelentés archiválva lesz az Ön számára."; @@ -534,13 +611,13 @@ swipe action */ "All servers" = "Összes kiszolgáló"; /* No comment provided by engineer. */ -"All your contacts will remain connected." = "Az összes partnerével kapcsolatban marad."; +"All your contacts will remain connected." = "Az összes partnerével továbbra is kapcsolatban marad."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára."; +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára."; /* No comment provided by engineer. */ -"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra."; +"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-átjátszókra."; /* No comment provided by engineer. */ "Allow" = "Engedélyezés"; @@ -563,6 +640,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra)"; +/* No comment provided by engineer. */ +"Allow members to chat with admins." = "A csevegés az adminisztrátorokkal engedélyezve van a tagok számára."; + /* No comment provided by engineer. */ "Allow message reactions only if your contact allows them." = "A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; @@ -572,12 +652,18 @@ swipe action */ /* No comment provided by engineer. */ "Allow sending direct messages to members." = "A közvetlen üzenetek küldése a tagok között engedélyezve van."; +/* No comment provided by engineer. */ +"Allow sending direct messages to subscribers." = "A közvetlen üzenetek küldése a feliratkozók között engedélyezve van."; + /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Az eltűnő üzenetek küldése engedélyezve van."; /* No comment provided by engineer. */ "Allow sharing" = "Megosztás engedélyezése"; +/* No comment provided by engineer. */ +"Allow subscribers to chat with admins." = "A csevegés az adminisztrátorokkal engedélyezve van a feliratkozók számára."; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra)"; @@ -609,7 +695,7 @@ swipe action */ "Allow your contacts to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra)"; /* No comment provided by engineer. */ -"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldésének engedélyezése a partnerei számára."; +"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldése engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ "Allow your contacts to send files and media." = "A fájlok és a médiatartalmak küldése engedélyezve van a partnerei számára."; @@ -630,10 +716,10 @@ swipe action */ "always" = "mindig"; /* No comment provided by engineer. */ -"Always use private routing." = "Mindig használjon privát útválasztást."; +"Always use private routing." = "Mindig legyen használva privát útválasztás."; /* No comment provided by engineer. */ -"Always use relay" = "Mindig használjon továbbítókiszolgálót"; +"Always use relay" = "Mindig legyen használva átjátszó"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; @@ -647,9 +733,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Hívás fogadása"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Bárki üzemeltethet kiszolgálókat."; - /* No comment provided by engineer. */ "App build: %@" = "Alkalmazás összeállítási száma: %@"; @@ -735,7 +818,10 @@ swipe action */ "Audio and video calls" = "Hang- és videóhívások"; /* No comment provided by engineer. */ -"audio call (not e2e encrypted)" = "hanghívás (nem e2e titkosított)"; +"Audio call" = "Hanghívás"; + +/* No comment provided by engineer. */ +"audio call (not e2e encrypted)" = "hanghívás (végpontok között NEM titkosított)"; /* chat feature */ "Audio/video calls" = "Hang- és videóhívások"; @@ -774,19 +860,28 @@ swipe action */ "Background" = "Háttér"; /* No comment provided by engineer. */ -"Bad desktop address" = "Érvénytelen számítógépcím"; +"Bad desktop address" = "Hibás a számítógép címe"; /* integrity error chat item */ "bad message hash" = "hibás az üzenet kivonata"; /* No comment provided by engineer. */ -"Bad message hash" = "Érvénytelen az üzenet kivonata"; +"Bad message hash" = "Hibás az üzenet kivonata"; /* integrity error chat item */ "bad message ID" = "hibás az üzenet azonosítója"; /* No comment provided by engineer. */ -"Bad message ID" = "Téves üzenet ID"; +"Bad message ID" = "Hibás az üzenet azonosítója"; + +/* No comment provided by engineer. */ +"Be free\nin your network" = "Váljon szabaddá\na saját hálózatában"; + +/* No comment provided by engineer. */ +"Be free in your network." = "Legyen szabad a saját hálózatában."; + +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "Mert felszámoltuk a lehetőségét is annak, hogy megtudjuk, Ön kicsoda. Így az önrendelkezése soha nem kerülhet idegen kezekbe."; /* No comment provided by engineer. */ "Better calls" = "Továbbfejlesztett hívásélmény"; @@ -804,7 +899,7 @@ swipe action */ "Better messages" = "Továbbfejlesztett üzenetek"; /* No comment provided by engineer. */ -"Better networking" = "Jobb hálózatkezelés"; +"Better networking" = "Továbbfejlesztett hálózatkezelés"; /* No comment provided by engineer. */ "Better notifications" = "Továbbfejlesztett értesítések"; @@ -819,10 +914,10 @@ swipe action */ "Better user experience" = "Továbbfejlesztett felhasználói élmény"; /* No comment provided by engineer. */ -"Bio" = "Névjegy"; +"Bio" = "Életrajz"; /* alert title */ -"Bio too large" = "A névjegy túl hosszú"; +"Bio too large" = "Az életrajz túl hosszú"; /* No comment provided by engineer. */ "Black" = "Fekete"; @@ -845,6 +940,9 @@ swipe action */ /* No comment provided by engineer. */ "Block member?" = "Letiltja a tagot?"; +/* No comment provided by engineer. */ +"Block subscriber for all?" = "Az összes feliratkozó számára letiltja a feliratkozót?"; + /* marked deleted chat item preview text */ "blocked" = "letiltva"; @@ -889,9 +987,15 @@ marked deleted chat item preview text */ "Both you and your contact can send voice messages." = "Mindkét fél küldhet hangüzeneteket."; /* No comment provided by engineer. */ -"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bolgár, finn, thai és ukrán – köszönet a felhasználóknak és a [Weblate-nek](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +"Bottom bar" = "Alsó sáv"; + +/* compose placeholder for channel owner */ +"Broadcast" = "Közvetítés…"; /* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bolgár, finn, thai és ukrán – köszönet a felhasználóknak és a [Weblate-nek](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + +/* chat link info line */ "Business address" = "Üzleti cím"; /* No comment provided by engineer. */ @@ -906,14 +1010,11 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "A SimpleX Chat használatával Ön elfogadja, hogy:\n- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban.\n- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek."; - /* No comment provided by engineer. */ "call" = "hívás"; /* No comment provided by engineer. */ -"Call already ended!" = "A hívás már befejeződött!"; +"Call already ended!" = "A hívás már véget ért!"; /* call status */ "call error" = "híváshiba"; @@ -933,6 +1034,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Camera not available" = "A kamera nem elérhető"; +/* No comment provided by engineer. */ +"can't broadcast" = "nem lehet közvetíteni"; + /* No comment provided by engineer. */ "Can't call contact" = "Nem lehet felhívni a partnert"; @@ -1032,6 +1136,58 @@ set passcode view */ /* chat item text */ "changing address…" = "cím módosítása…"; +/* shown as sender role for channel messages */ +"channel" = "csatorna"; + +/* No comment provided by engineer. */ +"Channel" = "Csatorna"; + +/* No comment provided by engineer. */ +"Channel display name" = "Csatorna megjelenítendő neve"; + +/* No comment provided by engineer. */ +"Channel full name (optional)" = "Csatorna teljes neve (nem kötelező)"; + +/* alert message +alert subtitle */ +"Channel has no active relays. Please try to join later." = "A csatornának nincsenek aktív átjátszói. Próbáljon meg később csatlakozni."; + +/* No comment provided by engineer. */ +"Channel image" = "Csatornakép"; + +/* chat link info line */ +"Channel link" = "Csatornahivatkozás"; + +/* No comment provided by engineer. */ +"Channel preferences" = "Csatornabeállítások"; + +/* No comment provided by engineer. */ +"Channel profile" = "Csatornaprofil"; + +/* No comment provided by engineer. */ +"Channel profile is stored on subscribers' devices and on the chat relays." = "A csatornaprofil a feliratkozók eszközén és a csevegési átjátszókon van tárolva."; + +/* snd group event chat item */ +"channel profile updated" = "csatornaprofil frissítve"; + +/* alert message */ +"Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers." = "A csatornaprofil módosult. Ha menti, akkor a frissített profil el lesz küldve a csatorna feliratkozóinak."; + +/* alert title */ +"Channel temporarily unavailable" = "A csatorna ideiglenesen nem érhető el"; + +/* No comment provided by engineer. */ +"Channel will be deleted for all subscribers - this cannot be undone!" = "A csatorna az összes feliratkozó számára törölve lesz – ez a művelet nem vonható vissza!"; + +/* No comment provided by engineer. */ +"Channel will be deleted for you - this cannot be undone!" = "A csatorna törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; + +/* alert message */ +"Channel will start working with %d of %d relays. Proceed?" = "A csatorna %2$d átjátszóból %1$d használatával kezd el működni. Folytatja?"; + +/* No comment provided by engineer. */ +"Channels" = "Csatornák"; + /* No comment provided by engineer. */ "Chat" = "Csevegés"; @@ -1083,6 +1239,18 @@ set passcode view */ /* No comment provided by engineer. */ "Chat profile" = "Csevegési profil"; +/* No comment provided by engineer. */ +"Chat relay" = "Csevegési átjátszó"; + +/* No comment provided by engineer. */ +"Chat relays" = "Csevegési átjátszók"; + +/* No comment provided by engineer. */ +"Chat relays forward messages in channels you create." = "A csevegési átjátszók továbbítják az üzeneteket az Ön által létrehozott csatornákban."; + +/* No comment provided by engineer. */ +"Chat relays forward messages to channel subscribers." = "A csevegési átjátszók továbbítják az üzeneteket a csatorna feliratkozóinak."; + /* No comment provided by engineer. */ "Chat theme" = "Csevegés témája"; @@ -1092,7 +1260,8 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Csevegés az adminisztrátorokkal"; /* No comment provided by engineer. */ @@ -1104,15 +1273,30 @@ set passcode view */ /* No comment provided by engineer. */ "Chats" = "Csevegések"; +/* No comment provided by engineer. */ +"Chats with admins are prohibited." = "A csevegés az adminisztrátorokkal le van tiltva."; + +/* alert message */ +"Chats with admins in public channels have no E2E encryption - use only with trusted chat relays." = "A nyilvános csatornákban az adminisztrátorokkal való csevegések nem rendelkeznek végpontok közötti titkosítással – csak megbízható csevegési átjátszókkal használja őket."; + /* No comment provided by engineer. */ "Chats with members" = "Csevegés a tagokkal"; +/* No comment provided by engineer. */ +"Chats with members are disabled" = "A csevegés a tagokkal le van tiltva"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Üzenetek ellenőrzése 20 percenként."; /* No comment provided by engineer. */ "Check messages when allowed." = "Üzenetek ellenőrzése, amikor engedélyezett."; +/* alert message */ +"Check relay address and try again." = "Ellenőrizze az átjátszó címét, és próbálja újra."; + +/* alert message */ +"Check relay name and try again." = "Ellenőrizze az átjátszó nevét, és próbálja újra."; + /* alert title */ "Check server address and try again." = "Kiszolgáló címének ellenőrzése és újrapróbálkozás."; @@ -1120,7 +1304,7 @@ set passcode view */ "Chinese and Spanish interface" = "Kínai és spanyol kezelőfelület"; /* No comment provided by engineer. */ -"Choose _Migrate from another device_ on the new device and scan QR code." = "Válassza az _Átköltöztetés egy másik eszközről_ opciót az új eszközén és olvassa be a QR-kódot."; +"Choose _Migrate from another device_ on the new device and scan QR code." = "Válassza az _Átköltöztetés egy másik eszközről_ beállítást az új eszközén és olvassa be a QR-kódot."; /* No comment provided by engineer. */ "Choose file" = "Fájl kiválasztása"; @@ -1138,25 +1322,25 @@ set passcode view */ "Chunks uploaded" = "Feltöltött töredékek"; /* swipe action */ -"Clear" = "Kiürítés"; +"Clear" = "Ürítés"; /* No comment provided by engineer. */ -"Clear conversation" = "Üzenetek kiürítése"; +"Clear conversation" = "Üzenetek ürítése"; /* No comment provided by engineer. */ -"Clear conversation?" = "Kiüríti az üzeneteket?"; +"Clear conversation?" = "Üríti a beszélgetés üzeneteit?"; /* No comment provided by engineer. */ -"Clear group?" = "Kiüríti a csoportot?"; +"Clear group?" = "Üríti a csoport üzeneteit?"; /* No comment provided by engineer. */ -"Clear or delete group?" = "Csoport kiürítése vagy törlése?"; +"Clear or delete group?" = "Csoport ürítése vagy törlése?"; /* No comment provided by engineer. */ -"Clear private notes?" = "Kiüríti a privát jegyzeteket?"; +"Clear private notes?" = "Üríti a privát jegyzetek tartalmát?"; /* No comment provided by engineer. */ -"Clear verification" = "Hitelesítés törlése"; +"Clear verification" = "Ellenőrzés törlése"; /* No comment provided by engineer. */ "Color chats with the new themes." = "Csevegések színezése új témákkal."; @@ -1177,7 +1361,7 @@ set passcode view */ "Compare security codes with your contacts." = "Biztonsági kódok összehasonlítása a partnerekével."; /* No comment provided by engineer. */ -"complete" = "befejezett"; +"complete" = "kész"; /* No comment provided by engineer. */ "Completed" = "Elkészült"; @@ -1191,7 +1375,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "A feltételek már el lettek fogadva a következő üzemeltető(k) számára: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Használati feltételek"; /* No comment provided by engineer. */ @@ -1207,7 +1391,7 @@ set passcode view */ "Configure ICE servers" = "ICE-kiszolgálók beállítása"; /* No comment provided by engineer. */ -"Configure server operators" = "Kiszolgálóüzemeltetők beállítása"; +"Configure relays" = "Átjátszók konfigurálása"; /* No comment provided by engineer. */ "Confirm" = "Megerősítés"; @@ -1242,7 +1426,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Megerősítve"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Kapcsolódás"; /* No comment provided by engineer. */ @@ -1272,8 +1457,11 @@ set passcode view */ /* new chat sheet title */ "Connect via link" = "Kapcsolódás egy hivatkozáson keresztül"; +/* No comment provided by engineer. */ +"Connect via link or QR code" = "Hivatkozás vagy QR-kód használata"; + /* new chat sheet title */ -"Connect via one-time link" = "Kapcsolódás egyszer használható meghívón keresztül"; +"Connect via one-time link" = "Kapcsolódás az egyszer használható meghívón keresztül"; /* new chat action */ "Connect with %@" = "Kapcsolódás a következővel: %@"; @@ -1291,7 +1479,7 @@ set passcode view */ "Connected servers" = "Kapcsolódott kiszolgálók"; /* No comment provided by engineer. */ -"Connected to desktop" = "Kapcsolódva a számítógéphez"; +"Connected to desktop" = "Társítva a számítógéppel"; /* No comment provided by engineer. */ "connecting" = "kapcsolódás"; @@ -1312,7 +1500,7 @@ set passcode view */ "connecting (introduction invitation)" = "kapcsolódás (bemutatkozó meghívó)"; /* call status */ -"connecting call" = "kapcsolódási hívás…"; +"connecting call" = "hívás kapcsolása…"; /* No comment provided by engineer. */ "Connecting server…" = "Kapcsolódás a kiszolgálóhoz…"; @@ -1324,7 +1512,7 @@ set passcode view */ "Connecting to contact, please wait or check later!" = "Kapcsolódás a partnerhez, várjon vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"Connecting to desktop" = "Kapcsolódás a számítógéphez"; +"Connecting to desktop" = "Társítás számítógéppel"; /* No comment provided by engineer. */ "connecting…" = "kapcsolódás…"; @@ -1341,12 +1529,15 @@ set passcode view */ /* alert title */ "Connection error" = "Kapcsolódási hiba"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Kapcsolódási hiba (AUTH)"; /* chat list item title (it should not be shown */ "connection established" = "kapcsolat létrehozva"; +/* No comment provided by engineer. */ +"Connection failed" = "Nem sikerült létrehozni a kapcsolatot"; + /* No comment provided by engineer. */ "Connection is blocked by server operator:\n%@" = "A kiszolgáló üzemeltetője letiltotta a kapcsolatot:\n%@"; @@ -1383,6 +1574,9 @@ set passcode view */ /* profile update event chat item */ "contact %@ changed to %@" = "%1$@ a következőre módosította a nevét: %2$@"; +/* chat link info line */ +"Contact address" = "Kapcsolattartási cím"; + /* No comment provided by engineer. */ "Contact allows" = "Partner engedélyezi"; @@ -1399,10 +1593,10 @@ set passcode view */ "contact disabled" = "partner letiltva"; /* No comment provided by engineer. */ -"contact has e2e encryption" = "a partner e2e titkosítással rendelkezik"; +"contact has e2e encryption" = "a partner végpontok közötti titkosítással rendelkezik"; /* No comment provided by engineer. */ -"contact has no e2e encryption" = "a partner nem rendelkezik e2e titkosítással"; +"contact has no e2e encryption" = "a partner nem rendelkezik végpontok közötti titkosítással"; /* notification */ "Contact hidden:" = "Rejtett név:"; @@ -1443,6 +1637,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Folytatás"; +/* No comment provided by engineer. */ +"Contribute" = "Közreműködés"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Beszélgetés törölve!"; @@ -1450,7 +1647,7 @@ set passcode view */ "Copy" = "Másolás"; /* No comment provided by engineer. */ -"Copy error" = "Másolási hiba"; +"Copy error" = "Hiba másolása"; /* No comment provided by engineer. */ "Core version: v%@" = "Fő verzió: v%@"; @@ -1458,12 +1655,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Sarok"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Helyesbíti a nevet a következőre: %@?"; -/* No comment provided by engineer. */ -"Create" = "Létrehozás"; - /* No comment provided by engineer. */ "Create 1-time link" = "Egyszer használható meghívó létrehozása"; @@ -1486,11 +1680,17 @@ set passcode view */ "Create list" = "Lista létrehozása"; /* No comment provided by engineer. */ -"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻"; +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Új profil létrehozása a [számítógépes alkalmazásban](https://simplex.chat/downloads/). 💻"; /* No comment provided by engineer. */ "Create profile" = "Profil létrehozása"; +/* No comment provided by engineer. */ +"Create public channel" = "Nyilvános csatorna létrehozása"; + +/* No comment provided by engineer. */ +"Create public channel (BETA)" = "Nyilvános csatorna létrehozása (BÉTA)"; + /* server test step */ "Create queue" = "Várólista létrehozása"; @@ -1500,9 +1700,15 @@ set passcode view */ /* No comment provided by engineer. */ "Create your address" = "Saját cím létrehozása"; +/* No comment provided by engineer. */ +"Create your link" = "Saját hivatkozás létrehozása"; + /* No comment provided by engineer. */ "Create your profile" = "Profil létrehozása"; +/* No comment provided by engineer. */ +"Create your public address" = "Saját nyilvános cím létrehozása"; + /* No comment provided by engineer. */ "Created" = "Létrehozva"; @@ -1515,6 +1721,9 @@ set passcode view */ /* No comment provided by engineer. */ "Creating archive link" = "Archívum hivatkozás létrehozása"; +/* No comment provided by engineer. */ +"Creating channel" = "Csatorna létrehozása"; + /* No comment provided by engineer. */ "Creating link…" = "Hivatkozás létrehozása…"; @@ -1522,7 +1731,7 @@ set passcode view */ "creator" = "készítő"; /* No comment provided by engineer. */ -"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül:"; +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül:"; /* No comment provided by engineer. */ "Current Passcode" = "Jelenlegi jelkód"; @@ -1617,8 +1826,8 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Kézbesítési hibák felderítése"; -/* No comment provided by engineer. */ -"Decentralized" = "Decentralizált"; +/* relay test step */ +"Decode link" = "Hivatkozás dekódolása"; /* message decrypt error item */ "Decryption error" = "Titkosítás-visszafejtési hiba"; @@ -1656,11 +1865,17 @@ swipe action */ "Delete after" = "Törlés ennyi idő után"; /* No comment provided by engineer. */ -"Delete all files" = "Az összes fájl törlése"; +"Delete all files" = "Összes fájl törlése"; /* No comment provided by engineer. */ "Delete and notify contact" = "Törlés, és a partner értesítése"; +/* No comment provided by engineer. */ +"Delete channel" = "Csatorna törlése"; + +/* No comment provided by engineer. */ +"Delete channel?" = "Törli a csatornát?"; + /* No comment provided by engineer. */ "Delete chat" = "Csevegés törlése"; @@ -1730,10 +1945,17 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Törli a tag üzenetét?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Tag üzeneteinek törlése"; + +/* alert title */ +"Delete member messages?" = "Törli a tag üzeneteit?"; + /* No comment provided by engineer. */ "Delete message?" = "Törli az üzenetet?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Üzenetek törlése"; /* No comment provided by engineer. */ @@ -1757,6 +1979,9 @@ swipe action */ /* server test step */ "Delete queue" = "Várólista törlése"; +/* No comment provided by engineer. */ +"Delete relay" = "Átjátszó törlése"; + /* No comment provided by engineer. */ "Delete report" = "Jelentés törlése"; @@ -1781,6 +2006,9 @@ swipe action */ /* copied message info */ "Deleted at: %@" = "Törölve: %@"; +/* rcv group event chat item */ +"deleted channel" = "törölt csatorna"; + /* rcv direct event chat item */ "deleted contact" = "törölt partner"; @@ -1815,19 +2043,19 @@ swipe action */ "Desktop address" = "Számítógép címe"; /* No comment provided by engineer. */ -"Desktop app version %@ is not compatible with this app." = "A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással."; +"Desktop app version %@ is not compatible with this app." = "A számítógépes alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással."; /* No comment provided by engineer. */ "Desktop devices" = "Számítógépek"; /* No comment provided by engineer. */ -"Destination server address of %@ is incompatible with forwarding server %@ settings." = "A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbítókiszolgáló beállításaival."; +"Destination server address of %@ is incompatible with forwarding server %@ settings." = "A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbító kiszolgáló beállításaival."; /* snd error text */ "Destination server error: %@" = "Célkiszolgáló-hiba: %@"; /* No comment provided by engineer. */ -"Destination server version of %@ is incompatible with forwarding server %@." = "A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbítókiszolgálóval."; +"Destination server version of %@ is incompatible with forwarding server %@." = "A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbító kiszolgálóval."; /* No comment provided by engineer. */ "Detailed statistics" = "Részletes statisztikák"; @@ -1857,7 +2085,7 @@ swipe action */ "different migration in the app/database: %@ / %@" = "különböző átköltöztetés az alkalmazásban/adatbázisban: %@ / %@"; /* No comment provided by engineer. */ -"Different names, avatars and transport isolation." = "Különböző nevek, profilképek és átvitelizoláció."; +"Different names, avatars and transport isolation." = "Különböző nevek, profilképek és átvitelelkülönítés."; /* connection level description */ "direct" = "közvetlen"; @@ -1871,6 +2099,12 @@ swipe action */ /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "A tagok közötti közvetlen üzenetek le vannak tiltva."; +/* No comment provided by engineer. */ +"Direct messages between subscribers are prohibited." = "A feliratkozók közötti közvetlen üzenetek le vannak tiltva."; + +/* alert button */ +"Disable" = "Letiltás"; + /* No comment provided by engineer. */ "Disable (keep overrides)" = "Letiltás (egyéni beállítások megtartása)"; @@ -1928,6 +2162,9 @@ swipe action */ /* No comment provided by engineer. */ "Do not send history to new members." = "Az előzmények ne legyenek elküldve az új tagok számára."; +/* No comment provided by engineer. */ +"Do not send history to new subscribers." = "Az előzmények ne legyenek elküldve az új feliratkozók számára."; + /* No comment provided by engineer. */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; @@ -1935,7 +2172,7 @@ swipe action */ "Do not use credentials with proxy." = "Ne használja a hitelesítési adatokat proxyval."; /* No comment provided by engineer. */ -"Do NOT use private routing." = "NE használjon privát útválasztást."; +"Do NOT use private routing." = "NE legyen használva privát útválasztás."; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NE használja a SimpleXet segélyhívásokhoz."; @@ -1947,13 +2184,13 @@ swipe action */ "Don't create address" = "Ne hozzon létre címet"; /* No comment provided by engineer. */ -"Don't enable" = "Ne engedélyezze"; +"Don't enable" = "Nem engedélyezem"; /* No comment provided by engineer. */ "Don't miss important messages." = "Ne maradjon le a fontos üzenetekről."; /* alert action */ -"Don't show again" = "Ne mutasd újra"; +"Don't show again" = "Ne jelenjen meg újra"; /* No comment provided by engineer. */ "Done" = "Kész"; @@ -2002,32 +2239,44 @@ chat item action */ "Duration" = "Időtartam"; /* No comment provided by engineer. */ -"e2e encrypted" = "e2e titkosított"; +"e2e encrypted" = "végpontok között titkosított"; /* No comment provided by engineer. */ -"E2E encrypted notifications." = "Végpontok közötti titkosított értesítések."; +"E2E encrypted notifications." = "Végpontok között titkosított értesítések."; + +/* No comment provided by engineer. */ +"Easier to invite your friends 👋" = "Könnyebben hívhatja meg a barátait 👋"; /* chat item action */ "Edit" = "Szerkesztés"; +/* No comment provided by engineer. */ +"Edit channel profile" = "Csatornaprofil szerkesztése"; + /* No comment provided by engineer. */ "Edit group profile" = "Csoportprofil szerkesztése"; /* No comment provided by engineer. */ "Empty message!" = "Üres üzenet!"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Engedélyezés"; /* No comment provided by engineer. */ "Enable (keep overrides)" = "Engedélyezés (egyéni beállítások megtartása)"; +/* channel creation warning */ +"Enable at least one chat relay in Network & Servers." = "Engedélyezzen legalább egy csevegési átjátszót a „Hálózat és kiszolgálók” menüben."; + /* alert title */ "Enable automatic message deletion?" = "Engedélyezi az automatikus üzenettörlést?"; /* No comment provided by engineer. */ "Enable camera access" = "Kamera-hozzáférés engedélyezése"; +/* alert title */ +"Enable chats with admins?" = "Engedélyezi a csevegést az adminisztrátorokkal?"; + /* No comment provided by engineer. */ "Enable disappearing messages by default." = "Eltűnő üzenetek engedélyezése alapértelmezetten."; @@ -2043,11 +2292,11 @@ chat item action */ /* No comment provided by engineer. */ "Enable instant notifications?" = "Engedélyezi az azonnali értesítéseket?"; -/* No comment provided by engineer. */ -"Enable lock" = "Zárolás engedélyezése"; +/* alert title */ +"Enable link previews?" = "Engedélyezi a hivatkozások előnézetét?"; /* No comment provided by engineer. */ -"Enable notifications" = "Értesítések engedélyezése"; +"Enable lock" = "Zárolás engedélyezése"; /* No comment provided by engineer. */ "Enable periodic notifications?" = "Engedélyezi az időszakos értesítéseket?"; @@ -2080,7 +2329,7 @@ chat item action */ "enabled for you" = "engedélyezve az Ön számára"; /* No comment provided by engineer. */ -"Encrypt" = "Titkosít"; +"Encrypt" = "Titkosítás"; /* No comment provided by engineer. */ "Encrypt database?" = "Titkosítja az adatbázist?"; @@ -2149,10 +2398,13 @@ chat item action */ "Encryption renegotiation in progress." = "A titkosítás újraegyeztetése folyamatban van."; /* No comment provided by engineer. */ -"ended" = "befejeződött"; +"ended" = "hívás vége"; /* call status */ -"ended call %@" = "%@ hívása befejeződött"; +"ended call %@" = "%@ hívása véget ért"; + +/* No comment provided by engineer. */ +"Enter channel name…" = "Adja meg a csatorna nevét…"; /* No comment provided by engineer. */ "Enter correct passphrase." = "Adja meg a helyes jelmondatot."; @@ -2172,6 +2424,12 @@ chat item action */ /* No comment provided by engineer. */ "Enter password above to show!" = "Adja meg a jelszót fentebb a megjelenítéshez!"; +/* No comment provided by engineer. */ +"Enter profile name..." = "Profil nevének megadása…"; + +/* No comment provided by engineer. */ +"Enter relay name…" = "Adja meg az átjátszó nevét…"; + /* No comment provided by engineer. */ "Enter server manually" = "Kiszolgáló megadása kézzel"; @@ -2190,7 +2448,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "hiba"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Hiba"; /* No comment provided by engineer. */ @@ -2208,6 +2466,9 @@ chat item action */ /* No comment provided by engineer. */ "Error adding member(s)" = "Hiba történt a tag(ok) hozzáadásakor"; +/* alert title */ +"Error adding relay" = "Hiba az átjátszó hozzáadásakor"; + /* alert title */ "Error adding server" = "Hiba történt a kiszolgáló hozzáadásakor"; @@ -2236,11 +2497,17 @@ chat item action */ "Error checking token status" = "Hiba történt a token állapotának ellenőrzésekor"; /* alert message */ -"Error connecting to forwarding server %@. Please try later." = "Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; +"Error connecting to forwarding server %@. Please try later." = "Hiba történt a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; + +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %@"; /* No comment provided by engineer. */ "Error creating address" = "Hiba történt a cím létrehozásakor"; +/* alert title */ +"Error creating channel" = "Hiba a csatorna létrehozásakor"; + /* No comment provided by engineer. */ "Error creating group" = "Hiba történt a csoport létrehozásakor"; @@ -2266,7 +2533,7 @@ chat item action */ "Error decrypting file" = "Hiba történt a fájl visszafejtésekor"; /* alert title */ -"Error deleting chat" = "Hiba a taggal való csevegés törlésekor"; +"Error deleting chat" = "Hiba a csevegés törlésekor"; /* alert title */ "Error deleting chat database" = "Hiba történt a csevegési adatbázis törlésekor"; @@ -2322,9 +2589,6 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "Hiba történt a csevegés megnyitásakor"; -/* No comment provided by engineer. */ -"Error opening group" = "Hiba a csoport előkészítésekor"; - /* alert title */ "Error receiving file" = "Hiba történt a fájl fogadásakor"; @@ -2349,6 +2613,9 @@ chat item action */ /* No comment provided by engineer. */ "Error resetting statistics" = "Hiba történt a statisztikák visszaállításakor"; +/* No comment provided by engineer. */ +"Error saving channel profile" = "Hiba a csatornaprofil mentésekor"; + /* alert title */ "Error saving chat list" = "Hiba történt a csevegési lista mentésekor"; @@ -2391,6 +2658,9 @@ chat item action */ /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Hiba történt a kézbesítési jelentések beállításakor!"; +/* alert title */ +"Error sharing channel" = "Hiba a csatorna megosztásakor"; + /* No comment provided by engineer. */ "Error starting chat" = "Hiba történt a csevegés elindításakor"; @@ -2428,16 +2698,23 @@ chat item action */ "Error uploading the archive" = "Hiba történt az archívum feltöltésekor"; /* No comment provided by engineer. */ -"Error verifying passphrase:" = "Hiba történt a jelmondat hitelesítésekor:"; +"Error verifying passphrase:" = "Hiba történt a jelmondat ellenőrzésekor:"; /* No comment provided by engineer. */ "Error: " = "Hiba: "; +/* receive error chat item */ +"error: %@" = "hiba: %@"; + /* alert message file error text snd error text */ "Error: %@" = "Hiba: %@"; +/* relay test error +server test error */ +"Error: %@." = "Hiba: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Hiba: nincs adatbázisfájl"; @@ -2483,6 +2760,9 @@ snd error text */ /* No comment provided by engineer. */ "Exporting database archive…" = "Adatbázis-archívum exportálása…"; +/* No comment provided by engineer. */ +"failed" = "sikertelen"; + /* No comment provided by engineer. */ "Failed to remove passphrase" = "Nem sikerült eltávolítani a jelmondatot"; @@ -2553,11 +2833,14 @@ snd error text */ "Files and media are prohibited." = "A fájlok és a médiatartalmak küldése le van tiltva."; /* No comment provided by engineer. */ -"Files and media not allowed" = "A fájlok és a médiatartalmak nincsenek engedélyezve"; +"Files and media not allowed" = "A fájlok és a médiatartalmak küldése nincs engedélyezve"; /* No comment provided by engineer. */ "Files and media prohibited!" = "A fájlok és a médiatartalmak küldése le van tiltva!"; +/* No comment provided by engineer. */ +"Filter" = "Szűrő"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Olvasatlan és kedvenc csevegésekre való szűrés."; @@ -2573,8 +2856,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Csevegési üzenetek gyorsabb megtalálása"; -/* server test error */ -"Fingerprint in server address does not match certificate." = "Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "A továbbító kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + +/* relay test error +server test error */ +"Fingerprint in server address does not match certificate." = "A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal."; /* No comment provided by engineer. */ "Fix" = "Javítás"; @@ -2597,7 +2890,11 @@ snd error text */ /* No comment provided by engineer. */ "For all moderators" = "Az összes moderátor számára"; -/* servers error */ +/* No comment provided by engineer. */ +"For anyone to reach you" = "Bárki számára, aki el szeretné érni Önt"; + +/* servers error +servers warning */ "For chat profile %@:" = "A(z) %@ nevű csevegési profilhoz:"; /* No comment provided by engineer. */ @@ -2646,19 +2943,19 @@ snd error text */ "Forwarding %lld messages" = "%lld üzenet továbbítása"; /* alert message */ -"Forwarding server %@ failed to connect to destination server %@. Please try later." = "A(z) %1$@ továbbítókiszolgáló nem tudott kapcsolódni a(z) %2$@ célkiszolgálóhoz. Próbálja meg később."; +"Forwarding server %@ failed to connect to destination server %@. Please try later." = "A(z) %1$@ továbbító kiszolgáló nem tudott kapcsolódni a(z) %2$@ célkiszolgálóhoz. Próbálja meg később."; /* No comment provided by engineer. */ -"Forwarding server address is incompatible with network settings: %@." = "A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@."; +"Forwarding server address is incompatible with network settings: %@." = "A továbbító kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@."; /* No comment provided by engineer. */ -"Forwarding server version is incompatible with network settings: %@." = "A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@."; +"Forwarding server version is incompatible with network settings: %@." = "A továbbító kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@."; /* snd error text */ -"Forwarding server: %@\nDestination server error: %@" = "Továbbítókiszolgáló: %1$@\nCélkiszolgáló-hiba: %2$@"; +"Forwarding server: %@\nDestination server error: %@" = "Továbbító kiszolgáló: %1$@\nCélkiszolgáló-hiba: %2$@"; /* snd error text */ -"Forwarding server: %@\nError: %@" = "Továbbítókiszolgáló: %1$@\nHiba: %2$@"; +"Forwarding server: %@\nError: %@" = "Továbbító kiszolgáló: %1$@\nHiba: %2$@"; /* No comment provided by engineer. */ "Found desktop" = "Megtalált számítógép"; @@ -2681,9 +2978,15 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "Tovább csökkentett akkumulátor-használat"; +/* relay test step */ +"Get link" = "Hivatkozás megtekintése"; + /* No comment provided by engineer. */ "Get notified when mentioned." = "Kapjon értesítést, ha megemlítik."; +/* No comment provided by engineer. */ +"Get started" = "Vágjunk bele"; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF-ek és matricák"; @@ -2729,7 +3032,7 @@ snd error text */ /* No comment provided by engineer. */ "group is deleted" = "csoport törölve"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Csoporthivatkozás"; /* No comment provided by engineer. */ @@ -2801,6 +3104,9 @@ snd error text */ /* No comment provided by engineer. */ "History is not sent to new members." = "Az előzmények nem lesznek elküldve az új tagok számára."; +/* No comment provided by engineer. */ +"History is not sent to new subscribers." = "Az előzmények nem lesznek elküldve az új feliratkozók számára."; + /* time unit */ "hours" = "óra"; @@ -2817,7 +3123,7 @@ snd error text */ "How SimpleX works" = "Hogyan működik a SimpleX"; /* No comment provided by engineer. */ -"How to" = "Hogyan"; +"How to" = "Útmutató"; /* No comment provided by engineer. */ "How to use it" = "Használati útmutató"; @@ -2840,8 +3146,11 @@ snd error text */ /* No comment provided by engineer. */ "If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot:"; +/* down migration warning */ +"If you joined or created channels, they will stop working permanently." = "Ha csatornákat hozott létre vagy csak csatlakozott hozzájuk, akkor azok véglegesen le fognak állni."; + /* No comment provided by engineer. */ -"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson lentebb a **Befejezés később** beállításra (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; /* No comment provided by engineer. */ "Ignore" = "Mellőzés"; @@ -2853,10 +3162,10 @@ snd error text */ "Image will be received when your contact is online, please wait or check later!" = "A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"Immediately" = "Azonnal"; +"Images" = "Képek"; /* No comment provided by engineer. */ -"Immune to spam" = "Védett a kéretlen tartalommal szemben"; +"Immediately" = "Azonnal"; /* No comment provided by engineer. */ "Import" = "Importálás"; @@ -2868,7 +3177,7 @@ snd error text */ "Import database" = "Adatbázis importálása"; /* No comment provided by engineer. */ -"Import failed" = "Sikertelen importálás"; +"Import failed" = "Nem sikerült az importálás"; /* No comment provided by engineer. */ "Import theme" = "Téma importálása"; @@ -2883,10 +3192,10 @@ snd error text */ "Improved message delivery" = "Továbbfejlesztett üzenetkézbesítés"; /* No comment provided by engineer. */ -"Improved privacy and security" = "Fejlesztett adatvédelem és biztonság"; +"Improved privacy and security" = "Továbbfejlesztett adatvédelem és biztonság"; /* No comment provided by engineer. */ -"Improved server configuration" = "Javított kiszolgáló konfiguráció"; +"Improved server configuration" = "Továbbfejlesztett kiszolgálókonfiguráció"; /* No comment provided by engineer. */ "In order to continue, chat should be stopped." = "A folytatáshoz a csevegést meg kell szakítani."; @@ -2958,13 +3267,13 @@ snd error text */ "Initial role" = "Kezdeti szerepkör"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "A [SimpleX Chat terminálhoz] telepítése (https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "A SimpleX Chat terminálhoz telepítése"; /* No comment provided by engineer. */ "Instant" = "Azonnali"; /* No comment provided by engineer. */ -"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések el lesznek rejtve!\n"; +"Instant push notifications will be hidden!\n" = "Az azonnali leküldéses értesítések el lesznek rejtve!\n"; /* No comment provided by engineer. */ "Interface" = "Kezelőfelület"; @@ -2993,7 +3302,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "érvénytelen csevegésadat"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Érvénytelen kapcsolattartási hivatkozás"; /* invalid chat item */ @@ -3008,12 +3317,18 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Érvénytelen átköltöztetési visszaigazolás"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Érvénytelen név!"; /* No comment provided by engineer. */ "Invalid QR code" = "Érvénytelen QR-kód"; +/* alert title */ +"Invalid relay address!" = "Érvénytelen az átjátszó címe!"; + +/* alert title */ +"Invalid relay name!" = "Érvénytelen az átjátszó neve!"; + /* No comment provided by engineer. */ "Invalid response" = "Érvénytelen válasz"; @@ -3035,9 +3350,15 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Barátok meghívása"; +/* No comment provided by engineer. */ +"Invite member" = "Tag meghívása"; + /* No comment provided by engineer. */ "Invite members" = "Tagok meghívása"; +/* No comment provided by engineer. */ +"Invite someone privately" = "Partner meghívása privátban"; + /* No comment provided by engineer. */ "Invite to chat" = "Meghívás a csevegésbe"; @@ -3057,10 +3378,10 @@ snd error text */ "invited via your group link" = "meghíva a saját csoporthivatkozásán keresztül"; /* No comment provided by engineer. */ -"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a leküldéses értesítések fogadását."; /* No comment provided by engineer. */ -"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a leküldéses értesítések fogadását."; /* No comment provided by engineer. */ "IP address" = "IP-cím"; @@ -3102,7 +3423,10 @@ snd error text */ "Join" = "Csatlakozás"; /* No comment provided by engineer. */ -"Join as %@" = "csatlakozás mint %@"; +"Join as %@" = "Csatlakozás mint: %@"; + +/* No comment provided by engineer. */ +"Join channel" = "Csatlakozás a csatornához"; /* new chat sheet title */ "Join group" = "Csatlakozás a csoporthoz"; @@ -3126,7 +3450,7 @@ snd error text */ "Keep conversation" = "Beszélgetés megtartása"; /* No comment provided by engineer. */ -"Keep the app open to use it from desktop" = "A számítógépről való használathoz tartsd nyitva az alkalmazást"; +"Keep the app open to use it from desktop" = "Alkalmazás megnyitva tartása a számítógépről való használathoz"; /* alert title */ "Keep unused invitation?" = "Megtartja a fel nem használt meghívót?"; @@ -3152,6 +3476,12 @@ snd error text */ /* swipe action */ "Leave" = "Elhagyás"; +/* No comment provided by engineer. */ +"Leave channel" = "Csatorna elhagyása"; + +/* No comment provided by engineer. */ +"Leave channel?" = "Elhagyja a csatornát?"; + /* No comment provided by engineer. */ "Leave chat" = "Csevegés elhagyása"; @@ -3170,6 +3500,9 @@ snd error text */ /* No comment provided by engineer. */ "Less traffic on mobile networks." = "Kevesebb adatforgalom a mobilhálózatokon."; +/* No comment provided by engineer. */ +"Let someone connect to you" = "Hagyja, hogy valaki elérje Önt"; + /* email subject */ "Let's talk in SimpleX Chat" = "Beszélgessünk a SimpleX Chatben"; @@ -3180,7 +3513,13 @@ snd error text */ "Limitations" = "Korlátozások"; /* No comment provided by engineer. */ -"Link mobile and desktop apps! 🔗" = "Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗"; +"link" = "hivatkozás"; + +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗"; + +/* owner verification */ +"Link signature verified." = "Hivatkozás aláírása ellenőrizve."; /* No comment provided by engineer. */ "Linked desktop options" = "Társított számítógép beállítások"; @@ -3188,6 +3527,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Társított számítógépek"; +/* No comment provided by engineer. */ +"Links" = "Hivatkozások"; + /* swipe action */ "List" = "Lista"; @@ -3237,7 +3579,7 @@ snd error text */ "Mark read" = "Megjelölés olvasottként"; /* No comment provided by engineer. */ -"Mark verified" = "Hitelesítés"; +"Mark verified" = "Megjelölés ellenőrzöttként"; /* No comment provided by engineer. */ "Markdown in messages" = "Markdown az üzenetekben"; @@ -3246,7 +3588,7 @@ snd error text */ "marked deleted" = "törlésre jelölve"; /* No comment provided by engineer. */ -"Max 30 seconds, received instantly." = "Max. 30 másodperc, azonnal érkezett."; +"Max 30 seconds, received instantly." = "Legfeljebb 30 másodperc, azonnal megérkezik."; /* No comment provided by engineer. */ "Media & file servers" = "Fájl- és médiakiszolgálók"; @@ -3281,6 +3623,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "A tag törölve lett – nem lehet elfogadni a kérést"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza!"; + /* chat feature */ "Member reports" = "Tagok jelentései"; @@ -3293,10 +3638,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre a következőre fog módosulni: „%@”. A tag új meghívást fog kapni."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza!"; /* alert message */ @@ -3305,6 +3650,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can add message reactions." = "A tagok reakciókat adhatnak hozzá az üzenetekhez."; +/* No comment provided by engineer. */ +"Members can chat with admins." = "A tagok cseveghetnek az adminisztrátorokkal"; + /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra)"; @@ -3345,7 +3693,10 @@ snd error text */ "Message delivery warning" = "Üzenetkézbesítési figyelmeztetés"; /* No comment provided by engineer. */ -"Message draft" = "Üzenetvázlat"; +"Message draft" = "Piszkozatok"; + +/* No comment provided by engineer. */ +"Message error" = "Üzenethiba"; /* item status text */ "Message forwarded" = "Továbbított üzenet"; @@ -3407,6 +3758,12 @@ snd error text */ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "%@ összes üzenete meg fog jelenni!"; +/* No comment provided by engineer. */ +"Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages." = "Ebben a csatornában az üzenetek **nem rendelkeznek végpontok közötti titkosítással**. A csevegési átjátszók láthatják ezeket az üzeneteket."; + +/* E2EE info chat item */ +"Messages in this channel are not end-to-end encrypted. Chat relays can see these messages." = "Ebben a csatornában az üzenetek nem rendelkeznek végpontok közötti titkosítással. A csevegési átjátszók láthatják ezeket az üzeneteket."; + /* alert message */ "Messages in this chat will never be deleted." = "Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve."; @@ -3417,7 +3774,7 @@ snd error text */ "Messages sent" = "Elküldött üzenetek"; /* alert message */ -"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kijelölte őket."; +"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kiválasztotta őket."; /* No comment provided by engineer. */ "Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve."; @@ -3426,10 +3783,10 @@ snd error text */ "Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti kvantumbiztos titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve."; /* No comment provided by engineer. */ -"Migrate device" = "Eszköz átköltöztetése"; +"Migrate" = "Átköltöztetés"; /* No comment provided by engineer. */ -"Migrate from another device" = "Átköltöztetés egy másik eszközről"; +"Migrate device" = "Eszköz átköltöztetése"; /* No comment provided by engineer. */ "Migrate here" = "Átköltöztetés ide"; @@ -3447,16 +3804,16 @@ snd error text */ "Migrating database archive…" = "Adatbázis-archívum átköltöztetése…"; /* No comment provided by engineer. */ -"Migration complete" = "Átköltöztetés befejezve"; +"Migration complete" = "Átköltöztetés kész"; /* No comment provided by engineer. */ "Migration error:" = "Átköltöztetési hiba:"; /* No comment provided by engineer. */ -"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** lehetőségre a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** beállításra a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; /* No comment provided by engineer. */ -"Migration is completed" = "Az átköltöztetés befejeződött"; +"Migration is completed" = "Az átköltöztetés elkészült"; /* No comment provided by engineer. */ "Migrations:" = "Átköltöztetések:"; @@ -3521,12 +3878,18 @@ snd error text */ /* No comment provided by engineer. */ "Network & servers" = "Hálózat és kiszolgálók"; +/* No comment provided by engineer. */ +"Network commitments" = "Hálózati kötelezettségvállalások"; + /* No comment provided by engineer. */ "Network connection" = "Hálózati kapcsolat"; /* No comment provided by engineer. */ "Network decentralization" = "Hálózati decentralizáció"; +/* conn error description */ +"Network error" = "Hálózati hiba"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt."; @@ -3537,31 +3900,43 @@ snd error text */ "Network operator" = "Hálózatüzemeltető"; /* No comment provided by engineer. */ -"Network settings" = "Hálózati beállítások"; +"Network routers cannot know\nwho talks to whom" = "A hálózati útválasztók nem tudhatják,\nhogy ki kivel beszélget"; /* No comment provided by engineer. */ +"Network settings" = "Hálózati beállítások"; + +/* alert title */ "Network status" = "Hálózat állapota"; /* delete after time */ "never" = "soha"; +/* No comment provided by engineer. */ +"new" = "új"; + /* token status text */ "New" = "Új"; +/* No comment provided by engineer. */ +"New 1-time link" = "Új egyszer használható meghívó"; + /* No comment provided by engineer. */ "New chat" = "Új csevegés"; /* No comment provided by engineer. */ "New chat experience 🎉" = "Új csevegési élmény 🎉"; +/* No comment provided by engineer. */ +"New chat relay" = "Új csevegési átjátszó"; + /* notification */ "New contact request" = "Új partneri kapcsolatkérés"; /* notification */ -"New contact:" = "Új kapcsolat:"; +"New contact:" = "Új partner:"; /* No comment provided by engineer. */ -"New desktop app!" = "Új számítógép-alkalmazás!"; +"New desktop app!" = "Új számítógépes alkalmazás!"; /* No comment provided by engineer. */ "New display name" = "Új megjelenítendő név"; @@ -3611,9 +3986,21 @@ snd error text */ /* No comment provided by engineer. */ "No" = "Nem"; +/* No comment provided by engineer. */ +"No account. No phone. No email. No ID.\nThe most secure encryption." = "Nincs fiók. Nincs telefonszám. Nincs e-mail-cím. Nincs személyazonosító.\nA legbiztonságosabb titkosítás."; + +/* No comment provided by engineer. */ +"No active relays" = "Nincsenek aktív átjátszók"; + /* Authentication unavailable */ "No app password" = "Nincs alkalmazás jelszó"; +/* No comment provided by engineer. */ +"No chat relays" = "Nincsenek csevegési átjátszók"; + +/* servers warning */ +"No chat relays enabled." = "Nincsenek engedélyezve csevegési átjátszók."; + /* No comment provided by engineer. */ "No chats" = "Nincsenek csevegések"; @@ -3627,7 +4014,7 @@ snd error text */ "No chats with members" = "Nincsenek csevegések a tagokkal"; /* No comment provided by engineer. */ -"No contacts selected" = "Nincs partner kijelölve"; +"No contacts selected" = "Nincs partner kiválasztva"; /* No comment provided by engineer. */ "No contacts to add" = "Nincs hozzáadandó partner"; @@ -3642,7 +4029,7 @@ snd error text */ "No direct connection yet, message is forwarded by admin." = "Még nincs közvetlen kapcsolat, az üzenetet az adminisztrátor továbbítja."; /* No comment provided by engineer. */ -"no e2e encryption" = "nincs e2e titkosítás"; +"no e2e encryption" = "nincs végpontok közötti titkosítás"; /* No comment provided by engineer. */ "No filtered chats" = "Nincsenek szűrt csevegések"; @@ -3681,7 +4068,7 @@ snd error text */ "No private routing session" = "Nincs privát útválasztási munkamenet"; /* No comment provided by engineer. */ -"No push server" = "Helyi"; +"No push server" = "Nincs kiszolgáló a leküldéses értesítésekhez"; /* No comment provided by engineer. */ "No received or sent files" = "Nincsenek fogadott vagy küldött fájlok"; @@ -3696,7 +4083,10 @@ snd error text */ "No servers to receive messages." = "Nincsenek üzenetfogadási kiszolgálók."; /* servers error */ -"No servers to send files." = "Nincsenek fájlküldő-kiszolgálók."; +"No servers to send files." = "Nincsenek fájlküldési kiszolgálók."; + +/* No comment provided by engineer. */ +"no subscription" = "nincs feliratkozás"; /* copied message info in history */ "no text" = "nincs szöveg"; @@ -3708,7 +4098,16 @@ snd error text */ "No unread chats" = "Nincsenek olvasatlan csevegések"; /* No comment provided by engineer. */ -"No user identifiers." = "Nincsenek felhasználói azonosítók."; +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Senki sem követte nyomon a beszélgetéseinket. Senki sem készített térképet arról, hogy merre jártunk. A magánéletünk nem csak egy funkció volt, hanem az életmódunk."; + +/* No comment provided by engineer. */ +"Non-profit governance" = "Nonprofit irányítás"; + +/* No comment provided by engineer. */ +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Nem egy jobb zár mások ajtaján. Nem egy kedvesebb házmester, aki tiszteletben tartja az Ön magánéletét, de mégis nyilvántartást vezet minden látogatójáról. Ön itt nem csak egy vendég. Ön itt otthon van. Nincs az a hatalom, amely beléphetne ide - Ön itt szuverén."; + +/* alert title */ +"Not all relays connected" = "Nem minden átjátszó kapcsolódott"; /* No comment provided by engineer. */ "Not compatible!" = "Nem kompatibilis!"; @@ -3720,7 +4119,7 @@ snd error text */ "Notes" = "Jegyzetek"; /* No comment provided by engineer. */ -"Nothing selected" = "Nincs semmi kijelölve"; +"Nothing selected" = "Nincs semmi kiválasztva"; /* alert title */ "Nothing to forward!" = "Nincs mit továbbítani!"; @@ -3766,7 +4165,7 @@ alert button new chat action */ "Ok" = "Rendben"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "Rendben"; /* No comment provided by engineer. */ @@ -3775,9 +4174,15 @@ new chat action */ /* group pref value */ "on" = "bekapcsolva"; +/* No comment provided by engineer. */ +"On your phone, not on servers." = "Az eszközön, nem pedig kiszolgálókon."; + /* No comment provided by engineer. */ "One-time invitation link" = "Egyszer használható meghívó"; +/* chat link info line */ +"One-time link" = "Egyszer használható meghívó"; + /* No comment provided by engineer. */ "Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Onion kiszolgálók **szükségesek** a kapcsolódáshoz.\nKompatibilis VPN szükséges."; @@ -3787,6 +4192,9 @@ new chat action */ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Az onion kiszolgálók nem lesznek használva."; +/* No comment provided by engineer. */ +"Only channel owners can change channel preferences." = "Csak a csatorna tulajdonosai módosíthatják a csatornabeállításokat."; + /* No comment provided by engineer. */ "Only chat owners can change preferences." = "Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat."; @@ -3815,44 +4223,48 @@ new chat action */ "Only you can add message reactions." = "Csak Ön adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra)"; +"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra)"; /* No comment provided by engineer. */ -"Only you can make calls." = "Csak Ön tud hívásokat indítani."; +"Only you can make calls." = "Csak Ön kezdeményezhet hívásokat."; /* No comment provided by engineer. */ -"Only you can send disappearing messages." = "Csak Ön tud eltűnő üzeneteket küldeni."; +"Only you can send disappearing messages." = "Csak Ön küldhet eltűnő üzeneteket."; /* No comment provided by engineer. */ "Only you can send files and media." = "Csak Ön küldhet fájlokat és médiatartalmakat."; /* No comment provided by engineer. */ -"Only you can send voice messages." = "Csak Ön tud hangüzeneteket küldeni."; +"Only you can send voice messages." = "Csak Ön küldhet hangüzeneteket."; /* No comment provided by engineer. */ "Only your contact can add message reactions." = "Csak a partnere adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra)"; +"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra)"; /* No comment provided by engineer. */ -"Only your contact can make calls." = "Csak a partnere tud hívást indítani."; +"Only your contact can make calls." = "Csak a partnere kezdeményezhet hívásokat."; /* No comment provided by engineer. */ -"Only your contact can send disappearing messages." = "Csak a partnere tud eltűnő üzeneteket küldeni."; +"Only your contact can send disappearing messages." = "Csak a partnere küldhet eltűnő üzeneteket."; /* No comment provided by engineer. */ -"Only your contact can send files and media." = "Csak a partnere küldhet fájlokat és a médiatartalmakat."; +"Only your contact can send files and media." = "Csak a partnere küldhet fájlokat és médiatartalmakat."; /* No comment provided by engineer. */ -"Only your contact can send voice messages." = "Csak a partnere tud hangüzeneteket küldeni."; +"Only your contact can send voice messages." = "Csak a partnere küldhet hangüzeneteket."; -/* alert action */ +/* alert action +alert button */ "Open" = "Megnyitás"; /* No comment provided by engineer. */ "Open changes" = "Módosítások megtekintése"; +/* new chat action */ +"Open channel" = "Csatorna megnyitása"; + /* new chat action */ "Open chat" = "Csevegés megnyitása"; @@ -3865,6 +4277,9 @@ new chat action */ /* No comment provided by engineer. */ "Open conditions" = "Feltételek megnyitása"; +/* alert title */ +"Open external link?" = "Megnyitja a külső hivatkozást?"; + /* alert action */ "Open full link" = "Teljes hivatkozás megnyitása"; @@ -3877,6 +4292,9 @@ new chat action */ /* authentication reason */ "Open migration to another device" = "Átköltöztetés indítása egy másik eszközre"; +/* new chat action */ +"Open new channel" = "Új csatorna megnyitása"; + /* new chat action */ "Open new chat" = "Új csevegés megnyitása"; @@ -3907,6 +4325,9 @@ new chat action */ /* alert title */ "Operator server" = "Kiszolgáló-üzemeltető"; +/* No comment provided by engineer. */ +"Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code" = "Az üzemeltetők kijelentik, hogy:\n- függetlenek maradnak\n- minimálisra csökkentik a metaadatok használatát\n- ellenőrzött, nyílt forráskódú szoftvereket futtatnak"; + /* No comment provided by engineer. */ "Or import archive file" = "Vagy archívumfájl importálása"; @@ -3919,12 +4340,18 @@ new chat action */ /* No comment provided by engineer. */ "Or securely share this file link" = "Vagy ossza meg biztonságosan ezt a fájlhivatkozást"; +/* No comment provided by engineer. */ +"Or show QR in person or via video call." = "Vagy mutassa meg a QR-kódot személyesen vagy videóhíváson keresztül."; + /* No comment provided by engineer. */ "Or show this code" = "Vagy mutassa meg ezt a kódot"; /* No comment provided by engineer. */ "Or to share privately" = "Vagy a privát megosztáshoz"; +/* No comment provided by engineer. */ +"Or use this QR - print or show online." = "Vagy használja ezt a QR-kódot – nyomtassa ki vagy mutassa meg online."; + /* No comment provided by engineer. */ "Organize chats into lists" = "Csevegések listákba szervezése"; @@ -3943,9 +4370,18 @@ new chat action */ /* member role */ "owner" = "tulajdonos"; +/* No comment provided by engineer. */ +"Owner" = "Tulajdonos"; + /* feature role */ "owners" = "tulajdonosok"; +/* No comment provided by engineer. */ +"Owners" = "Tulajdonosok"; + +/* No comment provided by engineer. */ +"Ownership: you can run your own relays." = "Tulajdonjog: saját átjátszókat üzemeltethet."; + /* No comment provided by engineer. */ "Passcode" = "Jelkód"; @@ -3973,6 +4409,9 @@ new chat action */ /* No comment provided by engineer. */ "Paste image" = "Kép beillesztése"; +/* No comment provided by engineer. */ +"Paste link / Scan" = "Hivatkozás megadása vagy QR-kód beolvasása"; + /* No comment provided by engineer. */ "Paste link to connect!" = "Hivatkozás beillesztése a kapcsolódáshoz!"; @@ -4052,7 +4491,7 @@ new chat action */ "Please report it to the developers." = "Jelentse a fejlesztőknek."; /* No comment provided by engineer. */ -"Please restart the app and migrate the database to enable push notifications." = "Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges push-értesítések engedélyezéséhez."; +"Please restart the app and migrate the database to enable push notifications." = "Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges leküldéses értesítések engedélyezéséhez."; /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez."; @@ -4067,7 +4506,7 @@ new chat action */ "Please wait for group moderators to review your request to join the group." = "Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérését."; /* token info */ -"Please wait for token activation to complete." = "Várjon, amíg a token aktiválása befejeződik."; +"Please wait for token activation to complete." = "Várjon, amíg a token aktiválása elkészül."; /* token info */ "Please wait for token to be registered." = "Várjon a token regisztrálására."; @@ -4082,7 +4521,13 @@ new chat action */ "Preserve the last message draft, with attachments." = "Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt."; /* No comment provided by engineer. */ -"Preset server address" = "Az előre beállított kiszolgáló címe"; +"Preset relay address" = "Előre beállított átjátszó címe"; + +/* No comment provided by engineer. */ +"Preset relay name" = "Előre beállított átjátszó neve"; + +/* No comment provided by engineer. */ +"Preset server address" = "Előre beállított kiszolgáló címe"; /* No comment provided by engineer. */ "Preset servers" = "Előre beállított kiszolgálók"; @@ -4103,10 +4548,10 @@ new chat action */ "Privacy policy and conditions of use." = "Adatvédelmi szabályzat és felhasználási feltételek."; /* No comment provided by engineer. */ -"Privacy redefined" = "Újraértelmezett adatvédelem"; +"Privacy: for owners and subscribers." = "Adatvédelem: tulajdonosok és előfizetők számára."; /* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "A privát csevegések, a csoportok és a partnerek nem érhetők el a kiszolgálók üzemeltetői számára."; +"Private and secure messaging." = "Privát és biztonságos üzenetváltás."; /* No comment provided by engineer. */ "Private filenames" = "Privát fájlnevek"; @@ -4132,6 +4577,9 @@ new chat action */ /* alert title */ "Private routing timeout" = "Privát útválasztás időtúllépése"; +/* alert action */ +"Proceed" = "Folytatás"; + /* No comment provided by engineer. */ "Profile and server connections" = "Profil és kiszolgálókapcsolatok"; @@ -4148,11 +4596,14 @@ new chat action */ "Profile theme" = "Profiltéma"; /* alert message */ -"Profile update will be sent to your contacts." = "A profilfrissítés el lesz küldve a partnerei számára."; +"Profile update will be sent to your SimpleX contacts." = "A profilfrissítés el lesz küldve a SimpleX partnerei számára."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "A hívások kezdeményezése le van tiltva."; +/* No comment provided by engineer. */ +"Prohibit chats with admins." = "A csevegés az adminisztrátorokkal le van tiltva."; + /* No comment provided by engineer. */ "Prohibit irreversible message deletion." = "Az elküldött üzenetek végleges törlése le van tiltva."; @@ -4163,11 +4614,14 @@ new chat action */ "Prohibit messages reactions." = "A reakciók hozzáadása az üzenetekhez le van tiltva."; /* No comment provided by engineer. */ -"Prohibit reporting messages to moderators." = "Az üzenetek a moderátorok felé történő jelentésének megtiltása."; +"Prohibit reporting messages to moderators." = "Az üzenetek jelentése a moderátorok felé le van tiltva."; /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "A közvetlen üzenetek küldése a tagok között le van tiltva."; +/* No comment provided by engineer. */ +"Prohibit sending direct messages to subscribers." = "A közvetlen üzenetek küldése a feliratkozók között le van tiltva."; + /* No comment provided by engineer. */ "Prohibit sending disappearing messages." = "Az eltűnő üzenetek küldése le van tiltva."; @@ -4190,7 +4644,7 @@ new chat action */ "Protect your chat profiles with a password!" = "Védje meg a csevegési profiljait egy jelszóval!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje az IP-címét a partnerei által kiválasztott üzenetváltási átjátszókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben."; /* No comment provided by engineer. */ "Protocol background timeout" = "Protokoll időtúllépése a háttérben"; @@ -4211,10 +4665,13 @@ new chat action */ "Proxy requires password" = "A proxy jelszót igényel"; /* No comment provided by engineer. */ -"Push notifications" = "Push-értesítések"; +"Public channels - speak freely 🚀" = "Nyilvános csatornák – mondja el szabadon a véleményét 🚀"; /* No comment provided by engineer. */ -"Push server" = "Push-kiszolgáló"; +"Push notifications" = "Leküldéses értesítések"; + +/* No comment provided by engineer. */ +"Push server" = "Leküldéses értesítéskiszolgáló"; /* chat item text */ "quantum resistant e2e encryption" = "végpontok közötti kvantumbiztos titkosítás"; @@ -4223,13 +4680,13 @@ new chat action */ "Quantum resistant encryption" = "Kvantumbiztos titkosítás"; /* No comment provided by engineer. */ -"Rate the app" = "Értékelje az alkalmazást"; +"Rate the app" = "Alkalmazás értékelése"; /* No comment provided by engineer. */ "Reachable chat toolbar" = "Könnyen elérhető csevegési eszköztár"; /* chat item menu */ -"React…" = "Reagálj…"; +"React…" = "Reagálás…"; /* swipe action */ "Read" = "Olvasott"; @@ -4238,16 +4695,10 @@ new chat action */ "Read more" = "Tudjon meg többet"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "További információ a [Használati útmutatóban](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "További információ a GitHub-tárolónkban."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "További információ a [Használati útmutatóban](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "További információ a [Használati útmutatóban](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "További információ a [GitHub-tárolónkban](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "További információ a Használati útmutatóban."; /* No comment provided by engineer. */ "Receipts are disabled" = "A kézbesítési jelentések le vannak tiltva"; @@ -4256,7 +4707,7 @@ new chat action */ "Receive errors" = "Üzenetfogadási hibák"; /* No comment provided by engineer. */ -"received answer…" = "válasz fogadása…"; +"received answer…" = "válasz érkezett…"; /* No comment provided by engineer. */ "Received at" = "Fogadva"; @@ -4265,10 +4716,7 @@ new chat action */ "Received at: %@" = "Fogadva: %@"; /* No comment provided by engineer. */ -"received confirmation…" = "visszaigazolás fogadása…"; - -/* notification */ -"Received file event" = "Fogadott fájlesemény"; +"received confirmation…" = "visszaigazolás érkezett…"; /* message info title */ "Received message" = "Fogadott üzenetbuborék színe"; @@ -4345,7 +4793,7 @@ swipe action */ "Reject" = "Elutasítás"; /* No comment provided by engineer. */ -"Reject (sender NOT notified)" = "Elutasítás (a kérés küldője NEM fog értesítést kapni)"; +"Reject (sender NOT notified)" = "Elutasítás (a kérés küldője NEM lesz értesítve)"; /* alert title */ "Reject contact request" = "Partneri kapcsolatkérés elutasítása"; @@ -4359,15 +4807,42 @@ swipe action */ /* call status */ "rejected call" = "elutasított hívás"; -/* No comment provided by engineer. */ -"Relay server is only used if necessary. Another party can observe your IP address." = "A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét."; +/* member role */ +"relay" = "átjátszó"; /* No comment provided by engineer. */ -"Relay server protects your IP address, but it can observe the duration of the call." = "A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát."; +"Relay" = "Átjátszó"; + +/* alert title */ +"Relay address" = "Átjátszó címe"; + +/* alert title */ +"Relay connection failed" = "Nem sikerült kapcsolódni az átjátszóhoz"; /* No comment provided by engineer. */ +"Relay link" = "Átjátszóhivatkozás"; + +/* alert message */ +"Relay results:" = "Átjátszóeredmények:"; + +/* No comment provided by engineer. */ +"Relay server is only used if necessary. Another party can observe your IP address." = "Az átjátszó csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét."; + +/* No comment provided by engineer. */ +"Relay server protects your IP address, but it can observe the duration of the call." = "Az átjátszó megvédi az IP-címét, de megfigyelheti a hívás időtartamát."; + +/* No comment provided by engineer. */ +"Relay test failed!" = "Nem sikerült tesztelni az átjátszót!"; + +/* No comment provided by engineer. */ +"Reliability: many relays per channel." = "Megbízhatóság: több átjátszó is használható csatornánként."; + +/* alert action */ "Remove" = "Eltávolítás"; +/* alert action */ +"Remove and delete messages" = "Eltávolítás és az üzeneteinek törlése"; + /* No comment provided by engineer. */ "Remove archive?" = "Eltávolítja az archívumot?"; @@ -4380,18 +4855,30 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Eltávolítás"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Eltávolítja a tagot?"; /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Eltávolítja a jelmondatot a kulcstartóból?"; +/* No comment provided by engineer. */ +"Remove subscriber" = "Feliratkozó eltávolítása"; + +/* alert title */ +"Remove subscriber?" = "Eltávolítja a feliratkozót?"; + /* No comment provided by engineer. */ "removed" = "eltávolítva"; +/* receive error chat item */ +"removed (%d attempts)" = "eltávolítva (%d kísérlet)"; + /* rcv group event chat item */ "removed %@" = "eltávolította őt: %@"; +/* No comment provided by engineer. */ +"removed by operator" = "az üzemeltető eltávolította"; + /* profile update event chat item */ "removed contact address" = "eltávolította a kapcsolattartási címet"; @@ -4486,7 +4973,7 @@ swipe action */ "Reset all hints" = "Tippek visszaállítása"; /* No comment provided by engineer. */ -"Reset all statistics" = "Az összes statisztika visszaállítása"; +"Reset all statistics" = "Összes statisztika visszaállítása"; /* No comment provided by engineer. */ "Reset all statistics?" = "Visszaállítja az összes statisztikát?"; @@ -4560,6 +5047,9 @@ swipe action */ /* No comment provided by engineer. */ "Run chat" = "Csevegési szolgáltatás indítása"; +/* No comment provided by engineer. */ +"Safe web links" = "Biztonságos webhivatkozások"; + /* No comment provided by engineer. */ "Safely receive files" = "Fájlok biztonságos fogadása"; @@ -4576,6 +5066,9 @@ chat item action */ /* alert button */ "Save (and notify members)" = "Mentés (és a tagok értesítése)"; +/* alert button */ +"Save (and notify subscribers)" = "Mentés (és a feliratkozók értesítése)"; + /* alert title */ "Save admission settings?" = "Menti a befogadási beállításokat?"; @@ -4585,12 +5078,21 @@ chat item action */ /* No comment provided by engineer. */ "Save and notify group members" = "Mentés és a csoporttagok értesítése"; +/* No comment provided by engineer. */ +"Save and notify subscribers" = "Mentés és a feliratkozók értesítése"; + /* No comment provided by engineer. */ "Save and reconnect" = "Mentés és újrakapcsolódás"; /* No comment provided by engineer. */ "Save and update group profile" = "Mentés és a csoportprofil frissítése"; +/* No comment provided by engineer. */ +"Save channel profile" = "Csatornaprofil mentése"; + +/* alert title */ +"Save channel profile?" = "Menti a csatornaprofilt?"; + /* No comment provided by engineer. */ "Save group profile" = "Csoportprofil mentése"; @@ -4676,7 +5178,22 @@ chat item action */ "Search bar accepts invitation links." = "A keresősáv elfogadja a meghívási hivatkozásokat."; /* No comment provided by engineer. */ -"Search or paste SimpleX link" = "Keresés vagy SimpleX-hivatkozás beillesztése"; +"Search files" = "Fájlok keresése"; + +/* No comment provided by engineer. */ +"Search images" = "Képek keresése"; + +/* No comment provided by engineer. */ +"Search links" = "Hivatkozások keresése"; + +/* No comment provided by engineer. */ +"Search or paste SimpleX link" = "Keressen vagy adjon meg egy SimpleX-hivatkozást"; + +/* No comment provided by engineer. */ +"Search videos" = "Videók keresése"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Hangüzenetek keresése"; /* network option */ "sec" = "mp"; @@ -4697,7 +5214,7 @@ chat item action */ "Secured" = "Biztosítva"; /* No comment provided by engineer. */ -"Security assessment" = "Biztonsági kiértékelés"; +"Security assessment" = "Biztonsági felmérés"; /* No comment provided by engineer. */ "Security code" = "Biztonsági kód"; @@ -4705,17 +5222,20 @@ chat item action */ /* chat item text */ "security code changed" = "biztonsági kódja módosult"; +/* No comment provided by engineer. */ +"Security: owners hold channel keys." = "Biztonság: a csatornák kulcsait a tulajdonosok őrzik."; + /* chat item action */ -"Select" = "Kijelölés"; +"Select" = "Kiválasztás"; /* No comment provided by engineer. */ -"Select chat profile" = "Csevegési profil kijelölése"; +"Select chat profile" = "Csevegési profil kiválasztása"; /* No comment provided by engineer. */ -"Selected %lld" = "%lld kijelölve"; +"Selected %lld" = "%lld kiválasztva"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Self-destruct" = "Önmegsemmisítés"; @@ -4783,12 +5303,18 @@ chat item action */ /* No comment provided by engineer. */ "Send request without message" = "Kérés küldése üzenet nélkül"; +/* No comment provided by engineer. */ +"Send the link via any messenger - it's secure. Ask to paste into SimpleX." = "Küldje el a hivatkozást bármilyen üzenetváltó alkalmazáson keresztül – ez egy biztonságos módszer – és kérje meg a partnerét, hogy illessze be a SimpleX alkalmazásba."; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Küldje el őket a galériából vagy az egyéni billentyűzetekről."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára."; +/* No comment provided by engineer. */ +"Send up to 100 last messages to new subscribers." = "Legfeljebb az utolsó 100 üzenet elküldése az új feliratkozók számára."; + /* No comment provided by engineer. */ "Send your private feedback to groups." = "Küldjön privát visszajelzést a csoportoknak."; @@ -4798,6 +5324,9 @@ chat item action */ /* No comment provided by engineer. */ "Sender may have deleted the connection request." = "A kérés küldője törölhette a kapcsolódási kérést."; +/* alert message */ +"Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later." = "A hivatkozáselőnézet küldése felfedheti az Ön IP-címét a weboldal számára. Ezt később módosíthatja az adatvédelmi beállításokban."; + /* No comment provided by engineer. */ "Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára."; @@ -4808,13 +5337,13 @@ chat item action */ "Sending file will be stopped." = "A fájl küldése le fog állni."; /* No comment provided by engineer. */ -"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld partnernél"; +"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld partner számára"; /* No comment provided by engineer. */ "Sending receipts is disabled for %lld groups" = "A kézbesítési jelentések le vannak tiltva %lld csoportban"; /* No comment provided by engineer. */ -"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld partnernél"; +"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld partner számára"; /* No comment provided by engineer. */ "Sending receipts is enabled for %lld groups" = "A kézbesítési jelentések engedélyezve vannak %lld csoportban"; @@ -4831,9 +5360,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Közvetlenül küldött"; -/* notification */ -"Sent file event" = "Elküldött fájlesemény"; - /* message info title */ "Sent message" = "Üzenetbuborék színe"; @@ -4879,11 +5405,14 @@ chat item action */ /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "a kiszolgáló várólista információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; -/* server test error */ -"Server requires authorization to create queues, check password." = "A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát"; +/* relay test error */ +"Server requires authorization to connect to relay, check password." = "A kiszolgáló hitelesítést igényel az átjátszóhoz való kapcsolódáshoz, ellenőrizze a jelszavát."; /* server test error */ -"Server requires authorization to upload, check password." = "A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát"; +"Server requires authorization to create queues, check password." = "A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát."; + +/* server test error */ +"Server requires authorization to upload, check password." = "A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát."; /* No comment provided by engineer. */ "Server test failed!" = "Sikertelen kiszolgáló teszt!"; @@ -4907,7 +5436,7 @@ chat item action */ "Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Session code" = "Munkamenet kód"; +"Session code" = "Munkamenet kódja"; /* No comment provided by engineer. */ "Set 1 day" = "Beállítva 1 nap"; @@ -4949,7 +5478,7 @@ chat item action */ "Set passphrase to export" = "Jelmondat beállítása az exportáláshoz"; /* No comment provided by engineer. */ -"Set profile bio and welcome message." = "Névjegy és üdvözlőüzenet beállítása a profilokhoz."; +"Set profile bio and welcome message." = "Életrajz és üdvözlőüzenet beállítása a profilokhoz."; /* No comment provided by engineer. */ "Set the message shown to new members!" = "Megjelenítendő üzenet beállítása az új tagok számára!"; @@ -4963,6 +5492,12 @@ chat item action */ /* alert message */ "Settings were changed." = "A beállítások módosultak."; +/* No comment provided by engineer. */ +"Setup notifications" = "Értesítések beállítása"; + +/* No comment provided by engineer. */ +"Setup routers" = "Útválasztók beállítása"; + /* No comment provided by engineer. */ "Shape profile images" = "Profilkép alakzata"; @@ -4983,7 +5518,10 @@ chat item action */ "Share address publicly" = "Cím nyilvános megosztása"; /* alert title */ -"Share address with contacts?" = "Megosztja a címet a partnereivel?"; +"Share address with SimpleX contacts?" = "Megosztja a címet a SimpleX partnereivel?"; + +/* No comment provided by engineer. */ +"Share channel" = "Csatorna megosztása"; /* No comment provided by engineer. */ "Share from other apps." = "Megosztás más alkalmazásokból."; @@ -4995,11 +5533,14 @@ chat item action */ "Share old address" = "Régi cím megosztása"; /* alert button */ -"Share old link" = "Teljes hivatkozás megosztása"; +"Share old link" = "Régi (hosszú) hivatkozás megosztása"; /* No comment provided by engineer. */ "Share profile" = "Profil megosztása"; +/* No comment provided by engineer. */ +"Share relay address" = "Átjátszó címének megosztása"; + /* No comment provided by engineer. */ "Share SimpleX address on social media." = "SimpleX-cím megosztása a közösségi médiában."; @@ -5010,7 +5551,10 @@ chat item action */ "Share to SimpleX" = "Megosztás a SimpleXben"; /* No comment provided by engineer. */ -"Share with contacts" = "Megosztás a partnerekkel"; +"Share via chat" = "Megosztás egy csevegésen keresztül"; + +/* No comment provided by engineer. */ +"Share with SimpleX contacts" = "Megosztás a SimpleX partnerekkel"; /* No comment provided by engineer. */ "Share your address" = "Saját cím megosztása"; @@ -5061,13 +5605,13 @@ chat item action */ "SimpleX Address" = "SimpleX-cím"; /* No comment provided by engineer. */ -"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül."; +"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó alkalmazáson keresztül."; /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívó?"; /* alert title */ -"SimpleX address settings" = "Beállítások automatikus elfogadása"; +"SimpleX address settings" = "SimpleX-címbeállítások"; /* simplex link type */ "SimpleX channel link" = "SimpleX-csatornahivatkozás"; @@ -5115,7 +5659,7 @@ chat item action */ "SimpleX protocols reviewed by Trail of Bits." = "A SimpleX protokollokat a Trail of Bits auditálta."; /* simplex link type */ -"SimpleX relay link" = "SimpleX továbbítókiszolgáló-hivatkozás"; +"SimpleX relay address" = "SimpleX-átjátszó címe"; /* No comment provided by engineer. */ "Simplified incognito mode" = "Egyszerűsített inkognitómód"; @@ -5130,7 +5674,7 @@ chat item action */ "Skipped messages" = "Kihagyott üzenetek"; /* No comment provided by engineer. */ -"Small groups (max 20)" = "Kis csoportok (max. 20 tag)"; +"Small groups (max 20)" = "Kis csoportok (legfeljebb 20 tag)"; /* No comment provided by engineer. */ "SMP server" = "SMP-kiszolgáló"; @@ -5170,7 +5714,10 @@ report reason */ "standard end-to-end encryption" = "szabványos végpontok közötti titkosítás"; /* No comment provided by engineer. */ -"Start chat" = "Csevegés indítása"; +"Star on GitHub" = "Csillagozás a GitHubon"; + +/* No comment provided by engineer. */ +"Start chat" = "Csevegés elindítása"; /* No comment provided by engineer. */ "Start chat?" = "Elindítja a csevegést?"; @@ -5182,7 +5729,7 @@ report reason */ "Starting from %@." = "Statisztikagyűjtés kezdete: %@."; /* No comment provided by engineer. */ -"starting…" = "indítás…"; +"starting…" = "hívás indítása…"; /* No comment provided by engineer. */ "Statistics" = "Statisztikák"; @@ -5235,6 +5782,48 @@ report reason */ /* No comment provided by engineer. */ "Subscribed" = "Feliratkozva"; +/* No comment provided by engineer. */ +"Subscriber" = "Feliratkozó"; + +/* chat feature */ +"Subscriber reports" = "Feliratkozók jelentései"; + +/* alert message */ +"Subscriber will be removed from channel - this cannot be undone!" = "A feliratkozó el lesz távolítva a csatornából – ez a művelet nem vonható vissza!"; + +/* No comment provided by engineer. */ +"Subscribers" = "Feliratkozók"; + +/* No comment provided by engineer. */ +"Subscribers can add message reactions." = "A feliratkozók reakciókat adhatnak hozzá az üzenetekhez."; + +/* No comment provided by engineer. */ +"Subscribers can chat with admins." = "A feliratkozók cseveghetnek az adminisztrátorokkal."; + +/* No comment provided by engineer. */ +"Subscribers can irreversibly delete sent messages. (24 hours)" = "A feliratkozók véglegesen törölhetik az elküldött üzeneteiket. (24 óra)"; + +/* No comment provided by engineer. */ +"Subscribers can report messsages to moderators." = "A feliratkozók jelenthetik az üzeneteket a moderátorok felé."; + +/* No comment provided by engineer. */ +"Subscribers can send direct messages." = "A feliratkozók küldhetnek egymásnak közvetlen üzeneteket."; + +/* No comment provided by engineer. */ +"Subscribers can send disappearing messages." = "A feliratkozók küldhetnek eltűnő üzeneteket."; + +/* No comment provided by engineer. */ +"Subscribers can send files and media." = "A feliratkozók küldhetnek fájlokat és médiatartalmakat."; + +/* No comment provided by engineer. */ +"Subscribers can send SimpleX links." = "A feliratkozók küldhetnek SimpleX-hivatkozásokat."; + +/* No comment provided by engineer. */ +"Subscribers can send voice messages." = "A feliratkozók küldhetnek hangüzeneteket."; + +/* No comment provided by engineer. */ +"Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." = "A feliratkozók az átjátszó hivatkozását használják a csatornához való kapcsolódáshoz.\nAz átjátszó címe ennek az átjátszónak a beállítására szolgált a csatornához."; + /* No comment provided by engineer. */ "Subscription errors" = "Feliratkozási hibák"; @@ -5262,6 +5851,9 @@ report reason */ /* No comment provided by engineer. */ "Take picture" = "Kép készítése"; +/* No comment provided by engineer. */ +"Talk to someone" = "Beszélgessen valakivel"; + /* No comment provided by engineer. */ "Tap button " = "Koppintson a "; @@ -5275,7 +5867,7 @@ report reason */ "Tap Connect to use bot" = "Koppintson a „Kapcsolódás” gombra a bot használatához"; /* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz."; +"Tap Join channel" = "Koppintson a „Csatlakozás a csatornához” gombra"; /* No comment provided by engineer. */ "Tap Join group" = "Koppintson a „Csatlakozás a csoporthoz” gombra"; @@ -5292,6 +5884,9 @@ report reason */ /* No comment provided by engineer. */ "Tap to join incognito" = "Koppintson ide az inkognitóban való kapcsolódáshoz"; +/* No comment provided by engineer. */ +"Tap to open" = "Koppintson ide a megnyitáshoz"; + /* No comment provided by engineer. */ "Tap to paste link" = "Koppintson ide a hivatkozás beillesztéséhez"; @@ -5322,12 +5917,16 @@ report reason */ /* file error alert title */ "Temporary file error" = "Ideiglenes fájlhiba"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "A teszt a(z) %@ lépésnél sikertelen volt."; /* No comment provided by engineer. */ "Test notifications" = "Értesítések tesztelése"; +/* No comment provided by engineer. */ +"Test relay" = "Átjátszó tesztelése"; + /* No comment provided by engineer. */ "Test server" = "Kiszolgáló tesztelése"; @@ -5355,6 +5954,9 @@ report reason */ /* No comment provided by engineer. */ "The app protects your privacy by using different operators in each conversation." = "Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ."; +/* No comment provided by engineer. */ +"The app removed this message after %lld attempts to receive it." = "Az alkalmazás %lld sikertelen letöltési kísérlet után eltávolította ezt az üzenetet."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról (kivéve .onion) történő letöltések megerősítését."; @@ -5364,6 +5966,9 @@ report reason */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "A beolvasott QR-kód nem egy SimpleX-hivatkozás."; +/* conn error description */ +"The connection reached the limit of undelivered messages" = "A kapcsolat elérte a kézbesítetlen üzenetek korlátját"; + /* No comment provided by engineer. */ "The connection reached the limit of undelivered messages, your contact may be offline." = "A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van."; @@ -5380,7 +5985,7 @@ report reason */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet!"; /* No comment provided by engineer. */ -"The future of messaging" = "Az üzenetváltás jövője"; +"The first network where you own\nyour contacts and groups." = "Az első hálózat, ahol Ön birtokolja\na saját kapcsolatait és csoportjait."; /* No comment provided by engineer. */ "The hash of the previous message is different." = "Az előző üzenet kivonata különbözik."; @@ -5406,6 +6011,9 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "A régi adatbázis nem lett eltávolítva az átköltöztetéskor, ezért törölhető."; +/* No comment provided by engineer. */ +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "A legrégebbi emberi szabadság - beszélgetni az emberekkel, anélkül, hogy mások megfigyelnének - olyan infrastruktúrán alapul, amely nem tudja elárulni."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető számára is: **%@**."; @@ -5413,10 +6021,10 @@ report reason */ "The second preset operator in the app!" = "A második előre beállított üzemeltető az alkalmazásban!"; /* No comment provided by engineer. */ -"The second tick we missed! ✅" = "A második jelölés, amit kihagytunk! ✅"; +"The second tick we missed! ✅" = "A második pipa, ami már nagyon hiányzott! ✅"; /* alert message */ -"The sender will NOT be notified" = "A kérés küldője NEM fog értesítést kapni"; +"The sender will NOT be notified" = "A kérés küldője NEM lesz értesítve"; /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegési profiljához tartozó új kapcsolatok kiszolgálói."; @@ -5433,6 +6041,12 @@ report reason */ /* No comment provided by engineer. */ "Themes" = "Témák"; +/* No comment provided by engineer. */ +"Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "Aztán felléptünk az internetre, és minden platform kért belőlünk egy darabot - nevet, telefonszámot, baráti kapcsolatokat. Elfogadtuk, hogy a kommunikáció ára az, hogy mások megtudják, hogy kivel beszélünk. Minden generáció, az emberek és a technológia is eddig így működött - telefon, e-mail, üzenetküldő programok, közösségi média. Úgy tűnt, ez az egyetlen lehetséges mód."; + +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "De van egy másik lehetőség is. Egy hálózat, amelyben nincsenek telefonszámok. Nincsenek felhasználónevek. Nincsenek fiókok. Nincsenek semmiféle felhasználói azonosítók. Egy hálózat, amely összeköti az embereket és titkosított üzeneteket továbbít, anélkül, hogy tudná, ki csatlakozik hozzá."; + /* No comment provided by engineer. */ "These conditions will also apply for: **%@**." = "Ezek a feltételek lesznek elfogadva a következő számára is: **%@**."; @@ -5446,10 +6060,10 @@ report reason */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak."; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; /* alert message */ -"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek."; @@ -5475,6 +6089,12 @@ report reason */ /* No comment provided by engineer. */ "This group no longer exists." = "Ez a csoport már nem létezik."; +/* alert message */ +"This is a chat relay address, it cannot be used to connect." = "Ez egy csevegési átjátszó címe, nem használható kapcsolódásra."; + +/* new chat action */ +"This is your link for channel %@!" = "Ez a saját hivatkozása a(z) %@ nevű csatornához!"; + /* No comment provided by engineer. */ "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől."; @@ -5508,6 +6128,9 @@ report reason */ /* No comment provided by engineer. */ "To make a new connection" = "Új kapcsolat létrehozásához"; +/* No comment provided by engineer. */ +"To make SimpleX Network last." = "A SimpleX hálózat hosszú távú működésének biztosítása érdekében."; + /* No comment provided by engineer. */ "To protect against your link being replaced, you can compare contact security codes." = "A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével."; @@ -5545,7 +6168,7 @@ report reason */ "To send commands you must be connected." = "A parancsok küldéséhez kapcsolódva kell lennie."; /* No comment provided by engineer. */ -"To support instant push notifications the chat database has to be migrated." = "Az azonnali push-értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges."; +"To support instant push notifications the chat database has to be migrated." = "Az azonnali leküldéses értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges."; /* alert message */ "To use another profile after connection attempt, delete the chat and use the link again." = "Másik profil használatához a kapcsolatfelvételi kísérlet után törölje a csevegést, és használja újra a hivatkozást."; @@ -5554,10 +6177,7 @@ report reason */ "To use the servers of **%@**, accept conditions of use." = "A(z) **%@** kiszolgálóinak használatához fogadja el a használati feltételeket."; /* No comment provided by engineer. */ -"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal."; - -/* No comment provided by engineer. */ -"Toggle chat list:" = "Csevegési lista ki/be:"; +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal."; /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Inkognitó profil használata kapcsolódáskor ki/be."; @@ -5568,6 +6188,9 @@ report reason */ /* No comment provided by engineer. */ "Toolbar opacity" = "Eszköztár átlátszatlansága"; +/* No comment provided by engineer. */ +"Top bar" = "Felső sáv"; + /* No comment provided by engineer. */ "Total" = "Összes kapcsolat"; @@ -5577,11 +6200,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Munkamenetek átvitele"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "Turkish interface" = "Török kezelőfelület"; @@ -5610,6 +6230,9 @@ report reason */ /* No comment provided by engineer. */ "Unblock member?" = "Feloldja a tag letiltását?"; +/* No comment provided by engineer. */ +"Unblock subscriber for all?" = "Az összes feliratkozó számára feloldja a feliratkozó letiltását?"; + /* rcv group event chat item */ "unblocked %@" = "feloldotta %@ letiltását"; @@ -5647,7 +6270,7 @@ report reason */ "Unknown error" = "Ismeretlen hiba"; /* No comment provided by engineer. */ -"unknown servers" = "ismeretlen átjátszók"; +"unknown servers" = "ismeretlen kiszolgálók"; /* alert title */ "Unknown servers!" = "Ismeretlen kiszolgálók!"; @@ -5662,7 +6285,7 @@ report reason */ "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." = "Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e."; /* No comment provided by engineer. */ -"Unlink" = "Szétkapcsolás"; +"Unlink" = "Leválasztás"; /* No comment provided by engineer. */ "Unlink desktop?" = "Leválasztja a számítógépet?"; @@ -5682,17 +6305,20 @@ report reason */ /* swipe action */ "Unread" = "Olvasatlan"; -/* No comment provided by engineer. */ +/* conn error description */ "Unsupported connection link" = "Nem támogatott kapcsolattartási hivatkozás"; /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára."; +/* No comment provided by engineer. */ +"Up to 100 last messages are sent to new subscribers." = "Legfeljebb az utolsó 100 üzenet lesz elküldve az új feliratkozók számára."; + /* No comment provided by engineer. */ "Update" = "Frissítés"; /* No comment provided by engineer. */ -"Update database passphrase" = "Az adatbázis jelmondatának módosítása"; +"Update database passphrase" = "Adatbázis jelmondatának módosítása"; /* No comment provided by engineer. */ "Update network settings?" = "Módosítja a hálózati beállításokat?"; @@ -5700,6 +6326,9 @@ report reason */ /* No comment provided by engineer. */ "Update settings?" = "Frissíti a beállításokat?"; +/* rcv group event chat item */ +"updated channel profile" = "frissített csatornaprofil"; + /* No comment provided by engineer. */ "Updated conditions" = "Frissített feltételek"; @@ -5757,9 +6386,6 @@ report reason */ /* No comment provided by engineer. */ "Use %@" = "%@ használata"; -/* No comment provided by engineer. */ -"Use chat" = "SimpleX Chat használata"; - /* new chat action */ "Use current profile" = "Jelenlegi profil használata"; @@ -5769,6 +6395,9 @@ report reason */ /* No comment provided by engineer. */ "Use for messages" = "Használat az üzenetekhez"; +/* No comment provided by engineer. */ +"Use for new channels" = "Használat új csatornákhoz"; + /* No comment provided by engineer. */ "Use for new connections" = "Használat új kapcsolatokhoz"; @@ -5791,7 +6420,10 @@ report reason */ "Use private routing with unknown servers when IP address is not protected." = "Privát útválasztás használata az ismeretlen kiszolgálókkal, ha az IP-cím nem védett."; /* No comment provided by engineer. */ -"Use private routing with unknown servers." = "Privát útválasztás használata ismeretlen kiszolgálókkal."; +"Use private routing with unknown servers." = "Privát útválasztás használata az ismeretlen kiszolgálókhoz."; + +/* No comment provided by engineer. */ +"Use relay" = "Átjátszó használata"; /* No comment provided by engineer. */ "Use server" = "Kiszolgáló használata"; @@ -5809,7 +6441,7 @@ report reason */ "Use TCP port %@ when no port is specified." = "A következő TCP-port használata, amikor nincs port megadva: %@."; /* No comment provided by engineer. */ -"Use TCP port 443 for preset servers only." = "A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz."; +"Use TCP port 443 for preset servers only." = "A 443-as TCP-port használata kizárólag az előre beállított kiszolgálókhoz."; /* No comment provided by engineer. */ "Use the app while in the call." = "Alkalmazás használata hívás közben."; @@ -5817,11 +6449,14 @@ report reason */ /* No comment provided by engineer. */ "Use the app with one hand." = "Alkalmazás egy kézzel való használata."; +/* No comment provided by engineer. */ +"Use this address in your social media profile, website, or email signature." = "Használja ezt a címet a közösségi oldalakon használt profiljaiban, weboldalakon vagy az e-mail aláírásában."; + /* No comment provided by engineer. */ "Use web port" = "Webport használata"; /* No comment provided by engineer. */ -"User selection" = "Felhasználó kijelölése"; +"User selection" = "Felhasználó kiválasztása"; /* No comment provided by engineer. */ "Username" = "Felhasználónév"; @@ -5835,26 +6470,32 @@ report reason */ /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; -/* No comment provided by engineer. */ -"Verify code with desktop" = "Kód hitelesítése a számítógépen"; +/* relay test step */ +"Verify" = "Ellenőrzés"; /* No comment provided by engineer. */ -"Verify connection" = "Kapcsolat hitelesítése"; +"Verify code with desktop" = "Kód ellenőrzése a számítógépen"; /* No comment provided by engineer. */ -"Verify connection security" = "Biztonságos kapcsolat hitelesítése"; +"Verify connection" = "Kapcsolat ellenőrzése"; /* No comment provided by engineer. */ -"Verify connections" = "Kapcsolatok hitelesítése"; +"Verify connection security" = "Biztonságos kapcsolat ellenőrzése"; /* No comment provided by engineer. */ -"Verify database passphrase" = "Az adatbázis jelmondatának hitelesítése"; +"Verify connections" = "Kapcsolatok ellenőrzése"; /* No comment provided by engineer. */ -"Verify passphrase" = "Jelmondat hitelesítése"; +"Verify database passphrase" = "Adatbázis jelmondatának ellenőrzése"; /* No comment provided by engineer. */ -"Verify security code" = "Biztonsági kód hitelesítése"; +"Verify passphrase" = "Jelmondat ellenőrzése"; + +/* No comment provided by engineer. */ +"Verify security code" = "Biztonsági kód ellenőrzése"; + +/* relay hostname */ +"via %@" = "a következőn keresztül: %@"; /* No comment provided by engineer. */ "Via browser" = "Böngészőn keresztül"; @@ -5869,7 +6510,7 @@ report reason */ "via one-time link" = "egy egyszer használható meghívón keresztül"; /* No comment provided by engineer. */ -"via relay" = "továbbítókiszolgálón keresztül"; +"via relay" = "átjátszón keresztül"; /* No comment provided by engineer. */ "Via secure quantum resistant protocol." = "Biztonságos kvantumbiztos protokollon keresztül."; @@ -5881,7 +6522,7 @@ report reason */ "Video call" = "Videóhívás"; /* No comment provided by engineer. */ -"video call (not e2e encrypted)" = "videóhívás (nem e2e titkosított)"; +"video call (not e2e encrypted)" = "videóhívás (végpontok között NEM titkosított)"; /* No comment provided by engineer. */ "Video will be received when your contact completes uploading it." = "A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését."; @@ -5889,6 +6530,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!"; +/* No comment provided by engineer. */ +"Videos" = "Videók"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Videók és fájlok legfeljebb 1GB méretig"; @@ -5922,9 +6566,18 @@ report reason */ /* No comment provided by engineer. */ "Voice messages prohibited!" = "A hangüzenetek le vannak tiltva!"; +/* alert action */ +"Wait" = "Várakozás"; + +/* relay test step */ +"Wait response" = "Várakozás a válaszra"; + /* No comment provided by engineer. */ "waiting for answer…" = "várakozás a válaszra…"; +/* No comment provided by engineer. */ +"Waiting for channel owner to add relays." = "Várakozás a csatorna tulajdonosára az átjátszók hozzáadásához."; + /* No comment provided by engineer. */ "waiting for confirmation…" = "várakozás a visszaigazolásra…"; @@ -5955,6 +6608,9 @@ report reason */ /* No comment provided by engineer. */ "Warning: you may lose some data!" = "Figyelmeztetés: néhány adat elveszhet!"; +/* No comment provided by engineer. */ +"We made connecting simpler for new users." = "Az új felhasználók számára egyszerűbbé tettük a kapcsolatok létrehozását."; + /* No comment provided by engineer. */ "WebRTC ICE servers" = "WebRTC ICE-kiszolgálók"; @@ -5991,6 +6647,9 @@ report reason */ /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Ha egy inkognitóprofilt oszt meg valamelyik partnerével, a rendszer ezt az inkognitóprofilt fogja használni azokban a csoportokban, ahová az adott partnere meghívja Önt."; +/* No comment provided by engineer. */ +"Why SimpleX is built." = "Miért jött létre a SimpleX?"; + /* No comment provided by engineer. */ "WiFi" = "Wi-Fi"; @@ -6013,7 +6672,7 @@ report reason */ "Without Tor or VPN, your IP address will be visible to file servers." = "Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára."; /* alert message */ -"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára: %@."; +"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-átjátszók számára: %@."; /* No comment provided by engineer. */ "Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; @@ -6075,18 +6734,24 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nMegismétli a csatlakozási kérést?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "You are invited to group" = "Ön meghívást kapott a csoportba"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál."; /* No comment provided by engineer. */ "you are observer" = "Ön megfigyelő"; +/* No comment provided by engineer. */ +"you are subscriber" = "Ön feliratkozó"; + /* snd group event chat item */ "you blocked %@" = "Ön letiltotta őt: %@"; @@ -6129,6 +6794,9 @@ report reason */ /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "A lezárási képernyő értesítési előnézetét az „Értesítések” menüben állíthatja be."; +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the channel." = "Megoszthat egy hivatkozást vagy egy QR-kódot – bárki képes lesz csatlakozni a csatornához."; + /* No comment provided by engineer. */ "You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoporthivatkozást később törli, akkor nem fogja elveszíteni a csoport meglévő tagjait."; @@ -6136,7 +6804,7 @@ report reason */ "You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: **%@**."; /* No comment provided by engineer. */ -"You can start chat via app Settings / Database or by restarting the app" = "A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el"; +"You can start chat via app Settings / Database or by restarting the app" = "A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával"; /* No comment provided by engineer. */ "You can still view conversation with %@ in the list of chats." = "A(z) %@ nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában."; @@ -6154,7 +6822,7 @@ report reason */ "You can view your reports in Chat with admins." = "A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben."; /* alert title */ -"You can't send messages!" = "Nem lehet üzeneteket küldeni!"; +"You can't send messages!" = "Ön nem tud üzeneteket küldeni!"; /* chat item text */ "you changed address" = "Ön módosította a címet"; @@ -6169,10 +6837,13 @@ report reason */ "you changed role of %@ to %@" = "Ön a következőre módosította %1$@ szerepkörét: „%2$@”"; /* No comment provided by engineer. */ -"You could not be verified; please try again." = "Nem sikerült hitelesíteni; próbálja meg újra."; +"You commit to:\n- Only legal content in public groups\n- Respect other users - no spam" = "Ön kijelenti, hogy:\n- nyilvános csoportokban kizárólag megengedett tartalmakat oszt meg\n- tiszteletben tartja a többi felhasználót – nem küld senkinek kéretlen tartalmat"; /* No comment provided by engineer. */ -"You decide who can connect." = "Ön dönti el, hogy kivel beszélget."; +"You connected to the channel via this relay link." = "Ön ezen az átjátszóhivatkozáson keresztül kapcsolódott a csatornához."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "Nem sikerült ellenőrizni; próbálja meg újra."; /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Ön már küldött egy kapcsolódási kérést!\nMegismétli a kapcsolódási kérést?"; @@ -6228,6 +6899,9 @@ report reason */ /* snd group event chat item */ "you unblocked %@" = "Ön feloldotta %@ letiltását"; +/* No comment provided by engineer. */ +"You were born without an account" = "Fiók nélkül születtünk."; + /* No comment provided by engineer. */ "You will be able to send messages **only after your request is accepted**." = "Csak azután tud üzeneteket küldeni, **miután a kérését elfogadták**."; @@ -6250,10 +6924,13 @@ report reason */ "You will still receive calls and notifications from muted profiles when they are active." = "Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak."; /* No comment provided by engineer. */ -"You will stop receiving messages from this chat. Chat history will be preserved." = "Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak."; +"You will stop receiving messages from this channel. Chat history will be preserved." = "Ön nem fog több üzenetet kapni ebből a csatornából. A csevegési előzmények megmaradnak."; /* No comment provided by engineer. */ -"You will stop receiving messages from this group. Chat history will be preserved." = "Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak."; +"You will stop receiving messages from this chat. Chat history will be preserved." = "Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak."; + +/* No comment provided by engineer. */ +"You will stop receiving messages from this group. Chat history will be preserved." = "Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak."; /* No comment provided by engineer. */ "You won't lose your contacts if you later delete your address." = "Nem veszíti el a partnereit, ha később törli a címét."; @@ -6273,6 +6950,9 @@ report reason */ /* No comment provided by engineer. */ "Your calls" = "Hívások"; +/* No comment provided by engineer. */ +"Your channel" = "Saját csatorna"; + /* No comment provided by engineer. */ "Your chat database" = "Csevegési adatbázis"; @@ -6295,16 +6975,19 @@ report reason */ "Your contact" = "Partner"; /* No comment provided by engineer. */ -"Your contact sent a file that is larger than currently supported maximum size (%@)." = "A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött."; +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "A partnere a jelenleg támogatott legnagyobb (%@) fájlméretnél nagyobbat küldött."; /* No comment provided by engineer. */ "Your contacts can allow full message deletion." = "A partnerei engedélyezhetik a teljes üzenet törlését."; /* No comment provided by engineer. */ -"Your contacts will remain connected." = "A partnerei továbbra is kapcsolódva maradnak."; +"Your contacts will remain connected." = "A partnereivel továbbra is kapcsolatban marad."; /* No comment provided by engineer. */ -"Your credentials may be sent unencrypted." = "A hitelesítési adati titkosítatlanul is elküldhetők."; +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "A beszélgetései Önhöz tartoznak, ahogy az internet megjelenése előtt is mindig így volt. A hálózat nem egy hely, amelyet meglátogat. Ez egy olyan hely, amelyet Ön hoz létre saját magának. És senki sem veheti el Öntől, függetlenül attól, hogy privát vagy nyilvános."; + +/* No comment provided by engineer. */ +"Your credentials may be sent unencrypted." = "A hitelesítési adatai titkosítatlanul is elküldhetők."; /* No comment provided by engineer. */ "Your current chat database will be DELETED and REPLACED with the imported one." = "A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra."; @@ -6318,6 +7001,9 @@ report reason */ /* No comment provided by engineer. */ "Your ICE servers" = "Saját ICE-kiszolgálók"; +/* No comment provided by engineer. */ +"Your network" = "Saját hálózat"; + /* No comment provided by engineer. */ "Your preferences" = "Beállítások"; @@ -6325,7 +7011,10 @@ report reason */ "Your privacy" = "Adatvédelem"; /* No comment provided by engineer. */ -"Your profile" = "Profil"; +"Your profile" = "Saját profil"; + +/* No comment provided by engineer. */ +"Your profile **%@** will be shared with channel relays and subscribers.\nRelays can access channel messages." = "A(z) **%@** nevű profilja meg lesz osztva a csatorna átjátszóival és feliratkozóival.\nAz átjátszók hozzáférhetnek a csatornaüzenetekhez."; /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "A(z) **%@** nevű profilja meg lesz osztva."; @@ -6339,11 +7028,20 @@ report reason */ /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "A profilja módosult. Ha menti, akkor a profilfrissítés el lesz küldve a partnerei számára."; +/* No comment provided by engineer. */ +"Your public address" = "Saját nyilvános cím"; + /* No comment provided by engineer. */ "Your random profile" = "Véletlenszerű profil"; /* No comment provided by engineer. */ -"Your server address" = "Saját SMP-kiszolgálójának címe"; +"Your relay address" = "Saját átjátszó címe"; + +/* No comment provided by engineer. */ +"Your relay name" = "Saját átjátszó neve"; + +/* No comment provided by engineer. */ +"Your server address" = "Saját SMP-kiszolgáló címe"; /* No comment provided by engineer. */ "Your servers" = "Saját kiszolgálók"; diff --git a/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings index 8b56c51595..d1b68ad52c 100644 --- a/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings @@ -2,7 +2,7 @@ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ -"NSCameraUsageDescription" = "A SimpleXnek kamera-hozzáférésre van szüksége a QR-kódok beolvasásához, hogy kapcsolódhasson más felhasználókhoz és videohívásokhoz."; +"NSCameraUsageDescription" = "A SimpleXnek hozzáférésre van szüksége a kamerához a QR-kódok beolvasásához, hogy kapcsolódhasson más felhasználókhoz és videohívásokhoz."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "A SimpleX Face ID-t használ a helyi hitelesítéshez"; @@ -11,7 +11,7 @@ "NSLocalNetworkUsageDescription" = "A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegési profil használatát számítógépen keresztül ugyanazon a hálózaton."; /* Privacy - Microphone Usage Description */ -"NSMicrophoneUsageDescription" = "A SimpleXnek mikrofon-hozzáférésre van szüksége hang- és videohívásokhoz, valamint hangüzenetek rögzítéséhez."; +"NSMicrophoneUsageDescription" = "A SimpleXnek hozzáférésre van szüksége a mikrofonhoz a hang- és videohívásokhoz, valamint hangüzenetek rögzítéséhez."; /* Privacy - Photo Library Additions Usage Description */ "NSPhotoLibraryAddUsageDescription" = "A SimpleXnek hozzáférésre van szüksége a galériához a rögzített és fogadott média mentéséhez"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 2eba58ab47..96b117eeca 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -10,6 +10,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- recapito dei messaggi più stabile.\n- gruppi un po' migliorati.\n- e altro ancora!"; +/* No comment provided by engineer. */ +"- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking." = "- scegli se inviare anteprime dei link.\n- previeni il phishing dei collegamenti ipertestuali.\n- rimuovi il tracciamento dei link."; + /* No comment provided by engineer. */ "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- avvisa facoltativamente i contatti eliminati.\n- nomi del profilo con spazi.\n- e molto altro!"; @@ -19,21 +22,21 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 colorato!"; +/* chat link info line */ +"(from owner)" = "(dal proprietario)"; + /* No comment provided by engineer. */ "(new)" = "(nuovo)"; +/* chat link info line */ +"(signed)" = "(firmato)"; + /* No comment provided by engineer. */ "(this device v%@)" = "(questo dispositivo v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuisci](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Inviaci un'email](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Dai una stella su GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Aggiungi contatto**: per creare un nuovo link di invito."; @@ -64,6 +67,9 @@ /* No comment provided by engineer. */ "**Scan / Paste link**: to connect via a link you received." = "**Scansiona / Incolla link**: per connetterti tramite un link che hai ricevuto."; +/* No comment provided by engineer. */ +"**Test relay** to retrieve its name." = "**Prova il relay** per recuperare il suo nome."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Attenzione**: le notifiche push istantanee richiedono una password salvata nel portachiavi."; @@ -175,6 +181,18 @@ /* time interval */ "%d months" = "%d mesi"; +/* channel relay bar +channel subscriber relay bar */ +"%d relays failed" = "%d relay falliti"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays not active" = "%d relay non attivi"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays removed" = "%d relay rimossi"; + /* time interval */ "%d sec" = "%d sec"; @@ -184,15 +202,50 @@ /* integrity error chat item */ "%d skipped message(s)" = "%d messaggio/i saltato/i"; +/* channel subscriber count */ +"%d subscriber" = "%d iscritto"; + +/* channel subscriber count */ +"%d subscribers" = "%d iscritti"; + /* time interval */ "%d weeks" = "%d settimane"; +/* channel creation progress +channel relay bar progress */ +"%d/%d relays active" = "%1$d/%2$d relay attivo/i"; + +/* channel relay bar */ +"%d/%d relays active, %d errors" = "%1$d/%2$d relay attivi, %3$d errori"; + +/* channel creation progress with errors +channel relay bar */ +"%d/%d relays active, %d failed" = "%1$d/%2$d relay attivo/i, %3$d fallito/i"; + +/* channel relay bar */ +"%d/%d relays active, %d removed" = "%1$d/%2$d relay attivi, %3$d rimossi"; + +/* channel subscriber relay bar progress */ +"%d/%d relays connected" = "%1$d/%2$d relay connesso/i"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d errors" = "%1$d/%2$d relay connesso/i, %3$d errori"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d failed" = "%1$d/%2$d relay connessi, %3$d falliti"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d removed" = "%1$d/%2$d relay connessi, %3$d rimossi"; + /* No comment provided by engineer. */ "%lld" = "%lld"; /* No comment provided by engineer. */ "%lld %@" = "%lld %@"; +/* No comment provided by engineer. */ +"%lld channel events" = "%lld eventi del canale"; + /* No comment provided by engineer. */ "%lld contact(s) selected" = "%lld contatto/i selezionato/i"; @@ -262,6 +315,9 @@ /* No comment provided by engineer. */ "~strike~" = "\\~barrato~"; +/* owner verification */ +"⚠️ Signature verification failed: %@." = "⚠️ Verifica della firma fallita: %@."; + /* time to disappear */ "0 sec" = "0 sec"; @@ -307,6 +363,9 @@ time interval */ /* No comment provided by engineer. */ "A few more things" = "Qualche altra cosa"; +/* No comment provided by engineer. */ +"A link for one person to connect" = "Un link per una persona da connettere"; + /* notification title */ "A new contact" = "Un contatto nuovo"; @@ -371,6 +430,9 @@ swipe action */ /* alert title */ "Accept member" = "Accetta membro"; +/* No comment provided by engineer. */ +"accepted" = "accettato"; + /* rcv group event chat item */ "accepted %@" = "%@ accettato"; @@ -392,6 +454,9 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledgement errors" = "Errori di riconoscimento"; +/* No comment provided by engineer. */ +"active" = "attivo"; + /* token status text */ "Active" = "Attivo"; @@ -399,7 +464,7 @@ swipe action */ "Active connections" = "Connessioni attive"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Aggiungi l'indirizzo al tuo profilo, in modo che i tuoi contatti possano condividerlo con altre persone. L'aggiornamento del profilo verrà inviato ai tuoi contatti."; +"Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts." = "Aggiungi l'indirizzo al tuo profilo, in modo che i tuoi contatti di SimpleX possano condividerlo con altre persone. L'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX."; /* No comment provided by engineer. */ "Add friends" = "Aggiungi amici"; @@ -440,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Server dei messaggi aggiunti"; +/* No comment provided by engineer. */ +"Adding relays will be supported later." = "L'aggiunta di relay verrà supportata prossimamente."; + /* No comment provided by engineer. */ "Additional accent" = "Principale aggiuntivo"; @@ -512,6 +580,9 @@ swipe action */ /* feature role */ "all members" = "tutti i membri"; +/* No comment provided by engineer. */ +"All messages" = "Tutti i messaggi"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Tutti i messaggi e i file vengono inviati **crittografati end-to-end**, con sicurezza resistenti alla quantistica nei messaggi diretti."; @@ -527,6 +598,12 @@ swipe action */ /* profile dropdown */ "All profiles" = "Tutti gli profili"; +/* No comment provided by engineer. */ +"All relays failed" = "Tutti i relay falliti"; + +/* No comment provided by engineer. */ +"All relays removed" = "Tutti i relay rimossi"; + /* No comment provided by engineer. */ "All reports will be archived for you." = "Tutte le segnalazioni verranno archiviate per te."; @@ -563,6 +640,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Consenti l'eliminazione irreversibile dei messaggi solo se il contatto la consente a te. (24 ore)"; +/* No comment provided by engineer. */ +"Allow members to chat with admins." = "Consenti ai membri di chattare con gli amministratori."; + /* No comment provided by engineer. */ "Allow message reactions only if your contact allows them." = "Consenti reazioni ai messaggi solo se il tuo contatto le consente."; @@ -572,12 +652,18 @@ swipe action */ /* No comment provided by engineer. */ "Allow sending direct messages to members." = "Permetti l'invio di messaggi diretti ai membri."; +/* No comment provided by engineer. */ +"Allow sending direct messages to subscribers." = "Permetti l'invio di messaggi diretti agli iscritti."; + /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Permetti l'invio di messaggi a tempo."; /* No comment provided by engineer. */ "Allow sharing" = "Consenti la condivisione"; +/* No comment provided by engineer. */ +"Allow subscribers to chat with admins." = "Consenti agli iscritti di chattare con gli amministratori."; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Permetti di eliminare irreversibilmente i messaggi inviati. (24 ore)"; @@ -647,9 +733,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Rispondi alla chiamata"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Chiunque può installare i server."; - /* No comment provided by engineer. */ "App build: %@" = "Build dell'app: %@"; @@ -734,6 +817,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Chiamate audio e video"; +/* No comment provided by engineer. */ +"Audio call" = "Chiamata audio"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "chiamata audio (non crittografata e2e)"; @@ -765,7 +851,7 @@ swipe action */ "Auto-accept contact requests" = "Auto-accetta le richieste di contatto"; /* No comment provided by engineer. */ -"Auto-accept images" = "Auto-accetta le immagini"; +"Auto-accept images" = "Accetta automaticamente le immagini"; /* No comment provided by engineer. */ "Back" = "Indietro"; @@ -788,6 +874,15 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "ID del messaggio errato"; +/* No comment provided by engineer. */ +"Be free\nin your network" = "Vivi libero\nnella tua rete"; + +/* No comment provided by engineer. */ +"Be free in your network." = "Vivi libero nella tua rete."; + +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "Perché abbiamo distrutto il potere di sapere chi sei. In modo che il tuo potere non possa mai esserti sottratto."; + /* No comment provided by engineer. */ "Better calls" = "Chiamate migliorate"; @@ -845,6 +940,9 @@ swipe action */ /* No comment provided by engineer. */ "Block member?" = "Bloccare il membro?"; +/* No comment provided by engineer. */ +"Block subscriber for all?" = "Bloccare l'iscritto per tutti?"; + /* marked deleted chat item preview text */ "blocked" = "bloccato"; @@ -889,9 +987,15 @@ marked deleted chat item preview text */ "Both you and your contact can send voice messages." = "Sia tu che il tuo contatto potete inviare messaggi vocali."; /* No comment provided by engineer. */ -"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgaro, finlandese, tailandese e ucraino - grazie agli utenti e a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +"Bottom bar" = "Barra inferiore"; + +/* compose placeholder for channel owner */ +"Broadcast" = "Trasmetti"; /* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgaro, finlandese, tailandese e ucraino - grazie agli utenti e a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + +/* chat link info line */ "Business address" = "Indirizzo di lavoro"; /* No comment provided by engineer. */ @@ -906,9 +1010,6 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Usando SimpleX Chat accetti di:\n- inviare solo contenuto legale nei gruppi pubblici.\n- rispettare gli altri utenti - niente spam."; - /* No comment provided by engineer. */ "call" = "chiama"; @@ -933,6 +1034,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Camera not available" = "Fotocamera non disponibile"; +/* No comment provided by engineer. */ +"can't broadcast" = "impossibile trasmettere"; + /* No comment provided by engineer. */ "Can't call contact" = "Impossibile chiamare il contatto"; @@ -1032,6 +1136,58 @@ set passcode view */ /* chat item text */ "changing address…" = "cambio indirizzo…"; +/* shown as sender role for channel messages */ +"channel" = "canale"; + +/* No comment provided by engineer. */ +"Channel" = "Canale"; + +/* No comment provided by engineer. */ +"Channel display name" = "Nome da mostrare del canale"; + +/* No comment provided by engineer. */ +"Channel full name (optional)" = "Nome completo del canale (facoltativo)"; + +/* alert message +alert subtitle */ +"Channel has no active relays. Please try to join later." = "Il canale non ha relay attivi. Prova a iscriverti più tardi."; + +/* No comment provided by engineer. */ +"Channel image" = "Immagine del canale"; + +/* chat link info line */ +"Channel link" = "Link del canale"; + +/* No comment provided by engineer. */ +"Channel preferences" = "Preferenze del canale"; + +/* No comment provided by engineer. */ +"Channel profile" = "Profilo del canale"; + +/* No comment provided by engineer. */ +"Channel profile is stored on subscribers' devices and on the chat relays." = "Il profilo del canale è memorizzato sui dispositivi degli iscritti e sui relay di chat."; + +/* snd group event chat item */ +"channel profile updated" = "profilo del canale aggiornato"; + +/* alert message */ +"Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers." = "Il profilo del canale è stato cambiato. Se lo salvi, il profilo aggiornato verrà inviato agli iscritti di canale."; + +/* alert title */ +"Channel temporarily unavailable" = "Canale non disponibile temporaneamente"; + +/* No comment provided by engineer. */ +"Channel will be deleted for all subscribers - this cannot be undone!" = "Il canale verrà eliminato per tutti gli iscritti, non è reversibile!"; + +/* No comment provided by engineer. */ +"Channel will be deleted for you - this cannot be undone!" = "Il canale verrà eliminato per te, non è reversibile!"; + +/* alert message */ +"Channel will start working with %d of %d relays. Proceed?" = "Il canale sarà operativo con %1$d di %2$d relay. Procedere?"; + +/* No comment provided by engineer. */ +"Channels" = "Canali"; + /* No comment provided by engineer. */ "Chat" = "Chat"; @@ -1083,6 +1239,18 @@ set passcode view */ /* No comment provided by engineer. */ "Chat profile" = "Profilo utente"; +/* No comment provided by engineer. */ +"Chat relay" = "Relay di chat"; + +/* No comment provided by engineer. */ +"Chat relays" = "Relay di chat"; + +/* No comment provided by engineer. */ +"Chat relays forward messages in channels you create." = "I relay di chat inoltrano i messaggi nei canali che crei."; + +/* No comment provided by engineer. */ +"Chat relays forward messages to channel subscribers." = "I relay di chat inoltrano i messaggi agli iscritti del canale."; + /* No comment provided by engineer. */ "Chat theme" = "Tema della chat"; @@ -1092,7 +1260,8 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "La chat verrà eliminata solo per te, non è reversibile!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Chat con amministratori"; /* No comment provided by engineer. */ @@ -1104,15 +1273,30 @@ set passcode view */ /* No comment provided by engineer. */ "Chats" = "Chat"; +/* No comment provided by engineer. */ +"Chats with admins are prohibited." = "Le chat con gli amministratori sono vietate."; + +/* alert message */ +"Chats with admins in public channels have no E2E encryption - use only with trusted chat relays." = "Le chat con amministratori in canali pubblici non hanno crittografia E2E: usale solo con relay di chat fidati."; + /* No comment provided by engineer. */ "Chats with members" = "Chat con membri"; +/* No comment provided by engineer. */ +"Chats with members are disabled" = "Le chat con i membri sono disattivate"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Controlla i messaggi ogni 20 min."; /* No comment provided by engineer. */ "Check messages when allowed." = "Controlla i messaggi quando consentito."; +/* alert message */ +"Check relay address and try again." = "Controlla l'indirizzo del relay e riprova."; + +/* alert message */ +"Check relay name and try again." = "Controlla il nome del relay e riprova."; + /* alert title */ "Check server address and try again." = "Controlla l'indirizzo del server e riprova."; @@ -1191,7 +1375,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Le condizioni sono già state accettate per i seguenti operatori: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Condizioni d'uso"; /* No comment provided by engineer. */ @@ -1207,7 +1391,7 @@ set passcode view */ "Configure ICE servers" = "Configura server ICE"; /* No comment provided by engineer. */ -"Configure server operators" = "Configura gli operatori dei server"; +"Configure relays" = "Configura i relay"; /* No comment provided by engineer. */ "Confirm" = "Conferma"; @@ -1242,7 +1426,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Confermato"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Connetti"; /* No comment provided by engineer. */ @@ -1272,6 +1457,9 @@ set passcode view */ /* new chat sheet title */ "Connect via link" = "Connetti via link"; +/* No comment provided by engineer. */ +"Connect via link or QR code" = "Connetti via link o codice QR"; + /* new chat sheet title */ "Connect via one-time link" = "Connetti via link una tantum"; @@ -1341,12 +1529,15 @@ set passcode view */ /* alert title */ "Connection error" = "Errore di connessione"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Errore di connessione (AUTH)"; /* chat list item title (it should not be shown */ "connection established" = "connessione stabilita"; +/* No comment provided by engineer. */ +"Connection failed" = "Connessione fallita"; + /* No comment provided by engineer. */ "Connection is blocked by server operator:\n%@" = "La connessione è bloccata dall'operatore del server:\n%@"; @@ -1383,6 +1574,9 @@ set passcode view */ /* profile update event chat item */ "contact %@ changed to %@" = "contatto %1$@ cambiato in %2$@"; +/* chat link info line */ +"Contact address" = "Indirizzo di contatto"; + /* No comment provided by engineer. */ "Contact allows" = "Il contatto lo consente"; @@ -1443,6 +1637,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Continua"; +/* No comment provided by engineer. */ +"Contribute" = "Contribuisci"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Conversazione eliminata!"; @@ -1458,12 +1655,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Angolo"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Correggere il nome a %@?"; -/* No comment provided by engineer. */ -"Create" = "Crea"; - /* No comment provided by engineer. */ "Create 1-time link" = "Crea link una tantum"; @@ -1491,6 +1685,12 @@ set passcode view */ /* No comment provided by engineer. */ "Create profile" = "Crea profilo"; +/* No comment provided by engineer. */ +"Create public channel" = "Crea canale pubblico"; + +/* No comment provided by engineer. */ +"Create public channel (BETA)" = "Crea canale pubblico (BETA)"; + /* server test step */ "Create queue" = "Crea coda"; @@ -1500,9 +1700,15 @@ set passcode view */ /* No comment provided by engineer. */ "Create your address" = "Crea il tuo indirizzo"; +/* No comment provided by engineer. */ +"Create your link" = "Connettiti con qualcuno"; + /* No comment provided by engineer. */ "Create your profile" = "Crea il tuo profilo"; +/* No comment provided by engineer. */ +"Create your public address" = "Crea il tuo indirizzo pubblico"; + /* No comment provided by engineer. */ "Created" = "Creato"; @@ -1515,6 +1721,9 @@ set passcode view */ /* No comment provided by engineer. */ "Creating archive link" = "Creazione link dell'archivio"; +/* No comment provided by engineer. */ +"Creating channel" = "Creazione canale"; + /* No comment provided by engineer. */ "Creating link…" = "Creazione link…"; @@ -1617,8 +1826,8 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Debug della consegna"; -/* No comment provided by engineer. */ -"Decentralized" = "Decentralizzato"; +/* relay test step */ +"Decode link" = "Decodifica il link"; /* message decrypt error item */ "Decryption error" = "Errore di decifrazione"; @@ -1661,6 +1870,12 @@ swipe action */ /* No comment provided by engineer. */ "Delete and notify contact" = "Elimina e avvisa il contatto"; +/* No comment provided by engineer. */ +"Delete channel" = "Elimina canale"; + +/* No comment provided by engineer. */ +"Delete channel?" = "Eliminare il canale?"; + /* No comment provided by engineer. */ "Delete chat" = "Elimina chat"; @@ -1730,10 +1945,17 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Eliminare il messaggio del membro?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Elimina i messaggi del membro"; + +/* alert title */ +"Delete member messages?" = "Eliminare i messaggi del membro?"; + /* No comment provided by engineer. */ "Delete message?" = "Eliminare il messaggio?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Elimina messaggi"; /* No comment provided by engineer. */ @@ -1757,6 +1979,9 @@ swipe action */ /* server test step */ "Delete queue" = "Elimina coda"; +/* No comment provided by engineer. */ +"Delete relay" = "Elimina relay"; + /* No comment provided by engineer. */ "Delete report" = "Elimina la segnalazione"; @@ -1781,6 +2006,9 @@ swipe action */ /* copied message info */ "Deleted at: %@" = "Eliminato il: %@"; +/* rcv group event chat item */ +"deleted channel" = "canale eliminato"; + /* rcv direct event chat item */ "deleted contact" = "contatto eliminato"; @@ -1871,6 +2099,12 @@ swipe action */ /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "I messaggi diretti tra i membri sono vietati in questo gruppo."; +/* No comment provided by engineer. */ +"Direct messages between subscribers are prohibited." = "I messaggi diretti tra gli iscritti sono vietati."; + +/* alert button */ +"Disable" = "Disattiva"; + /* No comment provided by engineer. */ "Disable (keep overrides)" = "Disattiva (mantieni sostituzioni)"; @@ -1928,6 +2162,9 @@ swipe action */ /* No comment provided by engineer. */ "Do not send history to new members." = "Non inviare la cronologia ai nuovi membri."; +/* No comment provided by engineer. */ +"Do not send history to new subscribers." = "Non inviare la cronologia ai nuovi iscritti."; + /* No comment provided by engineer. */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "NON inviare messaggi direttamente, anche se il tuo server o quello di destinazione non supporta l'instradamento privato."; @@ -2007,27 +2244,39 @@ chat item action */ /* No comment provided by engineer. */ "E2E encrypted notifications." = "Notifiche crittografate E2E."; +/* No comment provided by engineer. */ +"Easier to invite your friends 👋" = "È più facile invitare i tuoi amici 👋"; + /* chat item action */ "Edit" = "Modifica"; +/* No comment provided by engineer. */ +"Edit channel profile" = "Modifica profilo canale"; + /* No comment provided by engineer. */ "Edit group profile" = "Modifica il profilo del gruppo"; /* No comment provided by engineer. */ "Empty message!" = "Messaggio vuoto!"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Attiva"; /* No comment provided by engineer. */ "Enable (keep overrides)" = "Attiva (mantieni sostituzioni)"; +/* channel creation warning */ +"Enable at least one chat relay in Network & Servers." = "Attiva almeno un relay di chat in \"Rete e server\"."; + /* alert title */ "Enable automatic message deletion?" = "Attivare l'eliminazione automatica dei messaggi?"; /* No comment provided by engineer. */ "Enable camera access" = "Attiva l'accesso alla fotocamera"; +/* alert title */ +"Enable chats with admins?" = "Attivare le chat con gli amministratori?"; + /* No comment provided by engineer. */ "Enable disappearing messages by default." = "Attiva i messaggi a tempo in modo predefinito."; @@ -2043,11 +2292,11 @@ chat item action */ /* No comment provided by engineer. */ "Enable instant notifications?" = "Attivare le notifiche istantanee?"; -/* No comment provided by engineer. */ -"Enable lock" = "Attiva blocco"; +/* alert title */ +"Enable link previews?" = "Attivare le anteprime dei link?"; /* No comment provided by engineer. */ -"Enable notifications" = "Attiva le notifiche"; +"Enable lock" = "Attiva blocco"; /* No comment provided by engineer. */ "Enable periodic notifications?" = "Attivare le notifiche periodiche?"; @@ -2154,6 +2403,9 @@ chat item action */ /* call status */ "ended call %@" = "chiamata terminata %@"; +/* No comment provided by engineer. */ +"Enter channel name…" = "Inserisci il nome del canale…"; + /* No comment provided by engineer. */ "Enter correct passphrase." = "Inserisci la password giusta."; @@ -2172,6 +2424,12 @@ chat item action */ /* No comment provided by engineer. */ "Enter password above to show!" = "Inserisci la password sopra per mostrare!"; +/* No comment provided by engineer. */ +"Enter profile name..." = "Inserisci nome profilo..."; + +/* No comment provided by engineer. */ +"Enter relay name…" = "Inserisci il nome del relay…"; + /* No comment provided by engineer. */ "Enter server manually" = "Inserisci il server a mano"; @@ -2190,7 +2448,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "errore"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Errore"; /* No comment provided by engineer. */ @@ -2208,6 +2466,9 @@ chat item action */ /* No comment provided by engineer. */ "Error adding member(s)" = "Errore di aggiunta membro/i"; +/* alert title */ +"Error adding relay" = "Errore di aggiunta del relay"; + /* alert title */ "Error adding server" = "Errore di aggiunta del server"; @@ -2238,9 +2499,15 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Errore di connessione al server di inoltro %@. Riprova più tardi."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Errore di connessione al server usato per ricevere messaggi da questa connessione: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Errore nella creazione dell'indirizzo"; +/* alert title */ +"Error creating channel" = "Errore di creazione del canale"; + /* No comment provided by engineer. */ "Error creating group" = "Errore nella creazione del gruppo"; @@ -2322,9 +2589,6 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "Errore di apertura della chat"; -/* No comment provided by engineer. */ -"Error opening group" = "Errore di preparazione del gruppo"; - /* alert title */ "Error receiving file" = "Errore nella ricezione del file"; @@ -2349,6 +2613,9 @@ chat item action */ /* No comment provided by engineer. */ "Error resetting statistics" = "Errore di azzeramento statistiche"; +/* No comment provided by engineer. */ +"Error saving channel profile" = "Errore di salvataggio del profilo del canale"; + /* alert title */ "Error saving chat list" = "Errore nel salvataggio dell'elenco di chat"; @@ -2391,6 +2658,9 @@ chat item action */ /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Errore nell'impostazione delle ricevute di consegna!"; +/* alert title */ +"Error sharing channel" = "Errore nella condivisione del canale"; + /* No comment provided by engineer. */ "Error starting chat" = "Errore di avvio della chat"; @@ -2433,11 +2703,18 @@ chat item action */ /* No comment provided by engineer. */ "Error: " = "Errore: "; +/* receive error chat item */ +"error: %@" = "errore: %@"; + /* alert message file error text snd error text */ "Error: %@" = "Errore: %@"; +/* relay test error +server test error */ +"Error: %@." = "Errore: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Errore: nessun file di database"; @@ -2483,6 +2760,9 @@ snd error text */ /* No comment provided by engineer. */ "Exporting database archive…" = "Esportazione archivio database…"; +/* No comment provided by engineer. */ +"failed" = "fallito"; + /* No comment provided by engineer. */ "Failed to remove passphrase" = "Rimozione della password fallita"; @@ -2558,6 +2838,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "File e contenuti multimediali vietati!"; +/* No comment provided by engineer. */ +"Filter" = "Filtro"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtra le chat non lette e preferite."; @@ -2573,8 +2856,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Trova le chat più velocemente"; -/* server test error */ -"Fingerprint in server address does not match certificate." = "Probabilmente l'impronta del certificato nell'indirizzo del server è sbagliata"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server di destinazione non corrisponde al certificato: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server di inoltro non corrisponde al certificato: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server non corrisponde al certificato: %@."; + +/* relay test error +server test error */ +"Fingerprint in server address does not match certificate." = "L'impronta digitale nell'indirizzo del server non corrisponde al certificato."; /* No comment provided by engineer. */ "Fix" = "Correggi"; @@ -2597,7 +2890,11 @@ snd error text */ /* No comment provided by engineer. */ "For all moderators" = "Per tutti i moderatori"; -/* servers error */ +/* No comment provided by engineer. */ +"For anyone to reach you" = "Per chiunque debba raggiungerti"; + +/* servers error +servers warning */ "For chat profile %@:" = "Per il profilo di chat %@:"; /* No comment provided by engineer. */ @@ -2681,9 +2978,15 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "Ulteriore riduzione del consumo della batteria"; +/* relay test step */ +"Get link" = "Ottieni link"; + /* No comment provided by engineer. */ "Get notified when mentioned." = "Ricevi una notifica quando menzionato."; +/* No comment provided by engineer. */ +"Get started" = "Cominciamo"; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF e adesivi"; @@ -2729,7 +3032,7 @@ snd error text */ /* No comment provided by engineer. */ "group is deleted" = "il gruppo è eliminato"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Link del gruppo"; /* No comment provided by engineer. */ @@ -2801,6 +3104,9 @@ snd error text */ /* No comment provided by engineer. */ "History is not sent to new members." = "La cronologia non viene inviata ai nuovi membri."; +/* No comment provided by engineer. */ +"History is not sent to new subscribers." = "La cronologia non viene inviata ai nuovi iscritti."; + /* time unit */ "hours" = "ore"; @@ -2840,6 +3146,9 @@ snd error text */ /* No comment provided by engineer. */ "If you enter your self-destruct passcode while opening the app:" = "Se inserisci il tuo codice di autodistruzione mentre apri l'app:"; +/* down migration warning */ +"If you joined or created channels, they will stop working permanently." = "Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente."; + /* No comment provided by engineer. */ "If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Se devi usare la chat adesso, tocca **Fallo più tardi** qui sotto (ti verrà offerto di migrare il database quando riavvii l'app)."; @@ -2853,10 +3162,10 @@ snd error text */ "Image will be received when your contact is online, please wait or check later!" = "L'immagine verrà ricevuta quando il tuo contatto sarà in linea, aspetta o controlla più tardi!"; /* No comment provided by engineer. */ -"Immediately" = "Immediatamente"; +"Images" = "Immagini"; /* No comment provided by engineer. */ -"Immune to spam" = "Immune a spam e abusi"; +"Immediately" = "Immediatamente"; /* No comment provided by engineer. */ "Import" = "Importa"; @@ -2958,7 +3267,7 @@ snd error text */ "Initial role" = "Ruolo iniziale"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Installa [Simplex Chat per terminale](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Installa Simplex Chat per terminale"; /* No comment provided by engineer. */ "Instant" = "Istantaneamente"; @@ -2993,7 +3302,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "dati chat non validi"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Link di connessione non valido"; /* invalid chat item */ @@ -3008,12 +3317,18 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Conferma di migrazione non valida"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Nome non valido!"; /* No comment provided by engineer. */ "Invalid QR code" = "Codice QR non valido"; +/* alert title */ +"Invalid relay address!" = "Indirizzo del relay non valido!"; + +/* alert title */ +"Invalid relay name!" = "Nome del relay non valido!"; + /* No comment provided by engineer. */ "Invalid response" = "Risposta non valida"; @@ -3035,9 +3350,15 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Invita amici"; +/* No comment provided by engineer. */ +"Invite member" = "Invita membro"; + /* No comment provided by engineer. */ "Invite members" = "Invita membri"; +/* No comment provided by engineer. */ +"Invite someone privately" = "Invita qualcuno in modo privato"; + /* No comment provided by engineer. */ "Invite to chat" = "Invita in chat"; @@ -3104,6 +3425,9 @@ snd error text */ /* No comment provided by engineer. */ "Join as %@" = "entra come %@"; +/* No comment provided by engineer. */ +"Join channel" = "Iscriviti al canale"; + /* new chat sheet title */ "Join group" = "Entra nel gruppo"; @@ -3152,6 +3476,12 @@ snd error text */ /* swipe action */ "Leave" = "Esci"; +/* No comment provided by engineer. */ +"Leave channel" = "Esci dal canale"; + +/* No comment provided by engineer. */ +"Leave channel?" = "Uscire dal canale?"; + /* No comment provided by engineer. */ "Leave chat" = "Esci dalla chat"; @@ -3170,6 +3500,9 @@ snd error text */ /* No comment provided by engineer. */ "Less traffic on mobile networks." = "Meno traffico sulle reti mobili."; +/* No comment provided by engineer. */ +"Let someone connect to you" = "Lascia che qualcuno si connetta a te"; + /* email subject */ "Let's talk in SimpleX Chat" = "Parliamo in SimpleX Chat"; @@ -3179,15 +3512,24 @@ snd error text */ /* No comment provided by engineer. */ "Limitations" = "Limitazioni"; +/* No comment provided by engineer. */ +"link" = "link"; + /* No comment provided by engineer. */ "Link mobile and desktop apps! 🔗" = "Collega le app mobile e desktop! 🔗"; +/* owner verification */ +"Link signature verified." = "Firma del link verificata."; + /* No comment provided by engineer. */ "Linked desktop options" = "Opzioni del desktop collegato"; /* No comment provided by engineer. */ "Linked desktops" = "Desktop collegati"; +/* No comment provided by engineer. */ +"Links" = "Link"; + /* swipe action */ "List" = "Elenco"; @@ -3281,6 +3623,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Il membro è eliminato - impossibile accettare la richiesta"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "I messaggi del membro verranno eliminati. Non è reversibile!"; + /* chat feature */ "Member reports" = "Segnalazioni dei membri"; @@ -3293,10 +3638,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Il ruolo del membro verrà cambiato in \"%@\". Il membro riceverà un invito nuovo."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Il membro verrà rimosso dalla chat, non è reversibile!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Il membro verrà rimosso dal gruppo, non è reversibile!"; /* alert message */ @@ -3305,6 +3650,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can add message reactions." = "I membri del gruppo possono aggiungere reazioni ai messaggi."; +/* No comment provided by engineer. */ +"Members can chat with admins." = "I membri possono chattare con gli amministratori."; + /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "I membri del gruppo possono eliminare irreversibilmente i messaggi inviati. (24 ore)"; @@ -3347,6 +3695,9 @@ snd error text */ /* No comment provided by engineer. */ "Message draft" = "Bozza del messaggio"; +/* No comment provided by engineer. */ +"Message error" = "Errore del messaggio"; + /* item status text */ "Message forwarded" = "Messaggio inoltrato"; @@ -3407,6 +3758,12 @@ snd error text */ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "I messaggi da %@ verranno mostrati!"; +/* No comment provided by engineer. */ +"Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages." = "I messaggi in questo canale **non sono crittografati end-to-end**. I relay di chat possono vedere questi messaggi."; + +/* E2EE info chat item */ +"Messages in this channel are not end-to-end encrypted. Chat relays can see these messages." = "I messaggi in questo canale non sono crittografati end-to-end. I relay di chat possono vedere questi messaggi."; + /* alert message */ "Messages in this chat will never be deleted." = "I messaggi in questa chat non verranno mai eliminati."; @@ -3426,10 +3783,10 @@ snd error text */ "Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "I messaggi, i file e le chiamate sono protetti da **crittografia e2e resistente alla quantistica** con perfect forward secrecy, ripudio e recupero da intrusione."; /* No comment provided by engineer. */ -"Migrate device" = "Migra dispositivo"; +"Migrate" = "Migra"; /* No comment provided by engineer. */ -"Migrate from another device" = "Migra da un altro dispositivo"; +"Migrate device" = "Migra dispositivo"; /* No comment provided by engineer. */ "Migrate here" = "Migra qui"; @@ -3521,12 +3878,18 @@ snd error text */ /* No comment provided by engineer. */ "Network & servers" = "Rete e server"; +/* No comment provided by engineer. */ +"Network commitments" = "Impegni sulla rete"; + /* No comment provided by engineer. */ "Network connection" = "Connessione di rete"; /* No comment provided by engineer. */ "Network decentralization" = "Decentralizzazione della rete"; +/* conn error description */ +"Network error" = "Errore di rete"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Problemi di rete - messaggio scaduto dopo molti tentativi di inviarlo."; @@ -3537,23 +3900,35 @@ snd error text */ "Network operator" = "Operatore di rete"; /* No comment provided by engineer. */ -"Network settings" = "Impostazioni di rete"; +"Network routers cannot know\nwho talks to whom" = "Gli instradatori di rete non possono\nsapere chi parla con chi"; /* No comment provided by engineer. */ +"Network settings" = "Impostazioni di rete"; + +/* alert title */ "Network status" = "Stato della rete"; /* delete after time */ "never" = "mai"; +/* No comment provided by engineer. */ +"new" = "nuovo"; + /* token status text */ "New" = "Nuovo"; +/* No comment provided by engineer. */ +"New 1-time link" = "Nuovo link una tantum"; + /* No comment provided by engineer. */ "New chat" = "Nuova chat"; /* No comment provided by engineer. */ "New chat experience 🎉" = "Una nuova esperienza di chat 🎉"; +/* No comment provided by engineer. */ +"New chat relay" = "Nuovo relay di chat"; + /* notification */ "New contact request" = "Nuova richiesta di contatto"; @@ -3611,9 +3986,21 @@ snd error text */ /* No comment provided by engineer. */ "No" = "No"; +/* No comment provided by engineer. */ +"No account. No phone. No email. No ID.\nThe most secure encryption." = "Nessun account. Nessun telefono. Nessuna email. Nessun identificatore.\nLa crittografia più sicura."; + +/* No comment provided by engineer. */ +"No active relays" = "Nessun relay attivo"; + /* Authentication unavailable */ "No app password" = "Nessuna password dell'app"; +/* No comment provided by engineer. */ +"No chat relays" = "Nessun relay di chat"; + +/* servers warning */ +"No chat relays enabled." = "Nessun relay di chat attivato."; + /* No comment provided by engineer. */ "No chats" = "Nessuna chat"; @@ -3698,6 +4085,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Nessun server per inviare file."; +/* No comment provided by engineer. */ +"no subscription" = "nessuna iscrizione"; + /* copied message info in history */ "no text" = "nessun testo"; @@ -3708,7 +4098,16 @@ snd error text */ "No unread chats" = "Nessuna chat non letta"; /* No comment provided by engineer. */ -"No user identifiers." = "Nessun identificatore utente."; +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Nessuno monitorava le tue conversazioni. Nessuno disegnava una mappa delle tue posizioni. La privacy non era mai stata una caratteristica, era uno stile di vita."; + +/* No comment provided by engineer. */ +"Non-profit governance" = "Organizzazione non a scopo di lucro"; + +/* No comment provided by engineer. */ +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Non una serratura migliore sulla porta di qualcun altro. Non un padrone di casa più gentile che rispetta la tua privacy, ma che continua a tenere traccia di tutti i visitatori. Non sei un ospite. Sei a casa tua. Nessun re può entrarvi: sei tu il sovrano."; + +/* alert title */ +"Not all relays connected" = "Non tutti i relay sono connessi"; /* No comment provided by engineer. */ "Not compatible!" = "Non compatibile!"; @@ -3766,7 +4165,7 @@ alert button new chat action */ "Ok" = "Ok"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "OK"; /* No comment provided by engineer. */ @@ -3775,9 +4174,15 @@ new chat action */ /* group pref value */ "on" = "on"; +/* No comment provided by engineer. */ +"On your phone, not on servers." = "Sul tuo telefono, non sui server."; + /* No comment provided by engineer. */ "One-time invitation link" = "Link di invito una tantum"; +/* chat link info line */ +"One-time link" = "Link una tantum"; + /* No comment provided by engineer. */ "Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Gli host Onion saranno **necessari** per la connessione.\nRichiede l'attivazione della VPN."; @@ -3787,6 +4192,9 @@ new chat action */ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Gli host Onion non verranno usati."; +/* No comment provided by engineer. */ +"Only channel owners can change channel preferences." = "Solo i proprietari del canale possono modificarne le preferenze."; + /* No comment provided by engineer. */ "Only chat owners can change preferences." = "Solo i proprietari della chat possono modificarne le preferenze."; @@ -3847,12 +4255,16 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Solo il tuo contatto può inviare messaggi vocali."; -/* alert action */ +/* alert action +alert button */ "Open" = "Apri"; /* No comment provided by engineer. */ "Open changes" = "Apri le modifiche"; +/* new chat action */ +"Open channel" = "Apri canale"; + /* new chat action */ "Open chat" = "Apri chat"; @@ -3865,6 +4277,9 @@ new chat action */ /* No comment provided by engineer. */ "Open conditions" = "Apri le condizioni"; +/* alert title */ +"Open external link?" = "Aprire il link esterno?"; + /* alert action */ "Open full link" = "Apri link completo"; @@ -3877,11 +4292,14 @@ new chat action */ /* authentication reason */ "Open migration to another device" = "Apri migrazione ad un altro dispositivo"; +/* new chat action */ +"Open new channel" = "Apri un canale nuovo"; + /* new chat action */ "Open new chat" = "Apri una chat nuova"; /* new chat action */ -"Open new group" = "Apri un gruppo nuovo"; +"Open new group" = "Apri il nuovo gruppo"; /* No comment provided by engineer. */ "Open Settings" = "Apri le impostazioni"; @@ -3908,10 +4326,13 @@ new chat action */ "Operator server" = "Server dell'operatore"; /* No comment provided by engineer. */ -"Or import archive file" = "O importa file archivio"; +"Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code" = "Gli operatori si impegnano a:\n- Essere indipendenti\n- Minimizzare l'uso di metadati\n- Eseguire codice open source verificato"; /* No comment provided by engineer. */ -"Or paste archive link" = "O incolla il link dell'archivio"; +"Or import archive file" = "O importa un file dell'archivio"; + +/* No comment provided by engineer. */ +"Or paste archive link" = "O incolla un link dell'archivio"; /* No comment provided by engineer. */ "Or scan QR code" = "O scansiona il codice QR"; @@ -3919,12 +4340,18 @@ new chat action */ /* No comment provided by engineer. */ "Or securely share this file link" = "O condividi in modo sicuro questo link del file"; +/* No comment provided by engineer. */ +"Or show QR in person or via video call." = "O mostra il QR di persona o via videochiamata."; + /* No comment provided by engineer. */ "Or show this code" = "O mostra questo codice"; /* No comment provided by engineer. */ "Or to share privately" = "O per condividere in modo privato"; +/* No comment provided by engineer. */ +"Or use this QR - print or show online." = "O usa questo QR: stampalo o mostralo online."; + /* No comment provided by engineer. */ "Organize chats into lists" = "Organizza le chat in elenchi"; @@ -3943,9 +4370,18 @@ new chat action */ /* member role */ "owner" = "proprietario"; +/* No comment provided by engineer. */ +"Owner" = "Proprietario"; + /* feature role */ "owners" = "proprietari"; +/* No comment provided by engineer. */ +"Owners" = "Proprietari"; + +/* No comment provided by engineer. */ +"Ownership: you can run your own relays." = "Proprietà: puoi gestire i tuoi relay personali."; + /* No comment provided by engineer. */ "Passcode" = "Codice di accesso"; @@ -3973,6 +4409,9 @@ new chat action */ /* No comment provided by engineer. */ "Paste image" = "Incolla immagine"; +/* No comment provided by engineer. */ +"Paste link / Scan" = "Incolla link / Scansiona"; + /* No comment provided by engineer. */ "Paste link to connect!" = "Incolla un link per connettere!"; @@ -4081,6 +4520,12 @@ new chat action */ /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Conserva la bozza dell'ultimo messaggio, con gli allegati."; +/* No comment provided by engineer. */ +"Preset relay address" = "Indirizzo relay preimpostato"; + +/* No comment provided by engineer. */ +"Preset relay name" = "Nome relay preimpostato"; + /* No comment provided by engineer. */ "Preset server address" = "Indirizzo server preimpostato"; @@ -4103,10 +4548,10 @@ new chat action */ "Privacy policy and conditions of use." = "Informativa sulla privacy e condizioni d'uso."; /* No comment provided by engineer. */ -"Privacy redefined" = "Privacy ridefinita"; +"Privacy: for owners and subscribers." = "Privacy: per i proprietari e gli iscritti."; /* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server."; +"Private and secure messaging." = "Messaggistica privata e sicura."; /* No comment provided by engineer. */ "Private filenames" = "Nomi di file privati"; @@ -4132,6 +4577,9 @@ new chat action */ /* alert title */ "Private routing timeout" = "Scadenza dell'instradamento privato"; +/* alert action */ +"Proceed" = "Procedi"; + /* No comment provided by engineer. */ "Profile and server connections" = "Profilo e connessioni al server"; @@ -4148,11 +4596,14 @@ new chat action */ "Profile theme" = "Tema del profilo"; /* alert message */ -"Profile update will be sent to your contacts." = "L'aggiornamento del profilo verrà inviato ai tuoi contatti."; +"Profile update will be sent to your SimpleX contacts." = "L'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Proibisci le chiamate audio/video."; +/* No comment provided by engineer. */ +"Prohibit chats with admins." = "Vieta le chat con gli amministratori."; + /* No comment provided by engineer. */ "Prohibit irreversible message deletion." = "Proibisci l'eliminazione irreversibile dei messaggi."; @@ -4168,6 +4619,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Proibisci l'invio di messaggi diretti ai membri."; +/* No comment provided by engineer. */ +"Prohibit sending direct messages to subscribers." = "Proibisci l'invio di messaggi diretti agli iscritti."; + /* No comment provided by engineer. */ "Prohibit sending disappearing messages." = "Proibisci l'invio di messaggi a tempo."; @@ -4210,6 +4664,9 @@ new chat action */ /* No comment provided by engineer. */ "Proxy requires password" = "Il proxy richiede una password"; +/* No comment provided by engineer. */ +"Public channels - speak freely 🚀" = "Canali pubblici - parla liberamente 🚀"; + /* No comment provided by engineer. */ "Push notifications" = "Notifiche push"; @@ -4238,16 +4695,10 @@ new chat action */ "Read more" = "Leggi tutto"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Leggi di più nella [Guida utente](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Maggiori informazioni nel nostro repository GitHub."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Maggiori informazioni nella [Guida per l'utente](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Maggiori informazioni nella [Guida per l'utente](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Maggiori informazioni nel nostro [repository GitHub](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Leggi di più nella Guida utente."; /* No comment provided by engineer. */ "Receipts are disabled" = "Le ricevute sono disattivate"; @@ -4267,9 +4718,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "conferma ricevuta…"; -/* notification */ -"Received file event" = "Evento file ricevuto"; - /* message info title */ "Received message" = "Messaggio ricevuto"; @@ -4359,6 +4807,24 @@ swipe action */ /* call status */ "rejected call" = "chiamata rifiutata"; +/* member role */ +"relay" = "relay"; + +/* No comment provided by engineer. */ +"Relay" = "Relay"; + +/* alert title */ +"Relay address" = "Indirizzo del relay"; + +/* alert title */ +"Relay connection failed" = "Connessione del relay fallita"; + +/* No comment provided by engineer. */ +"Relay link" = "Link del relay"; + +/* alert message */ +"Relay results:" = "Risultati relay:"; + /* No comment provided by engineer. */ "Relay server is only used if necessary. Another party can observe your IP address." = "Il server relay viene usato solo se necessario. Un altro utente può osservare il tuo indirizzo IP."; @@ -4366,8 +4832,17 @@ swipe action */ "Relay server protects your IP address, but it can observe the duration of the call." = "Il server relay protegge il tuo indirizzo IP, ma può osservare la durata della chiamata."; /* No comment provided by engineer. */ +"Relay test failed!" = "Prova del relay fallita!"; + +/* No comment provided by engineer. */ +"Reliability: many relays per channel." = "Affidabilità: relay multipli per canale."; + +/* alert action */ "Remove" = "Rimuovi"; +/* alert action */ +"Remove and delete messages" = "Rimuovi ed elimina i messaggi"; + /* No comment provided by engineer. */ "Remove archive?" = "Rimuovere l'archivio?"; @@ -4380,18 +4855,30 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Rimuovi membro"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Rimuovere il membro?"; /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Rimuovere la password dal portachiavi?"; +/* No comment provided by engineer. */ +"Remove subscriber" = "Rimuovi iscritto"; + +/* alert title */ +"Remove subscriber?" = "Rimuovere l'iscritto?"; + /* No comment provided by engineer. */ "removed" = "rimosso"; +/* receive error chat item */ +"removed (%d attempts)" = "rimosso (%d tentativi)"; + /* rcv group event chat item */ "removed %@" = "ha rimosso %@"; +/* No comment provided by engineer. */ +"removed by operator" = "rimosso da un operatore"; + /* profile update event chat item */ "removed contact address" = "indirizzo di contatto rimosso"; @@ -4560,6 +5047,9 @@ swipe action */ /* No comment provided by engineer. */ "Run chat" = "Avvia chat"; +/* No comment provided by engineer. */ +"Safe web links" = "Link web sicuri"; + /* No comment provided by engineer. */ "Safely receive files" = "Ricevi i file in sicurezza"; @@ -4574,7 +5064,10 @@ chat item action */ "Save (and notify contacts)" = "Salva (e avvisa i contatti)"; /* alert button */ -"Save (and notify members)" = "Salva (e informa i membri)"; +"Save (and notify members)" = "Salva (e avvisa i membri)"; + +/* alert button */ +"Save (and notify subscribers)" = "Salva (e avvisa gli iscritti)"; /* alert title */ "Save admission settings?" = "Salvare le impostazioni di ammissione?"; @@ -4585,12 +5078,21 @@ chat item action */ /* No comment provided by engineer. */ "Save and notify group members" = "Salva e avvisa i membri del gruppo"; +/* No comment provided by engineer. */ +"Save and notify subscribers" = "Salva e avvisa gli iscritti"; + /* No comment provided by engineer. */ "Save and reconnect" = "Salva e riconnetti"; /* No comment provided by engineer. */ "Save and update group profile" = "Salva e aggiorna il profilo del gruppo"; +/* No comment provided by engineer. */ +"Save channel profile" = "Salva il profilo del canale"; + +/* alert title */ +"Save channel profile?" = "Salva il profilo del canale?"; + /* No comment provided by engineer. */ "Save group profile" = "Salva il profilo del gruppo"; @@ -4655,7 +5157,7 @@ chat item action */ "Scan code" = "Scansiona codice"; /* No comment provided by engineer. */ -"Scan QR code" = "Scansiona codice QR"; +"Scan QR code" = "Scansiona un codice QR"; /* No comment provided by engineer. */ "Scan QR code from desktop" = "Scansiona codice QR dal desktop"; @@ -4675,9 +5177,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "La barra di ricerca accetta i link di invito."; +/* No comment provided by engineer. */ +"Search files" = "Cerca file"; + +/* No comment provided by engineer. */ +"Search images" = "Cerca immagini"; + +/* No comment provided by engineer. */ +"Search links" = "Cerca link"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Cerca o incolla un link SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Cerca video"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Cerca messaggi vocali"; + /* network option */ "sec" = "sec"; @@ -4705,6 +5222,9 @@ chat item action */ /* chat item text */ "security code changed" = "codice di sicurezza modificato"; +/* No comment provided by engineer. */ +"Security: owners hold channel keys." = "Sicurezza: solo i proprietari hanno le chiavi del canale."; + /* chat item action */ "Select" = "Seleziona"; @@ -4783,12 +5303,18 @@ chat item action */ /* No comment provided by engineer. */ "Send request without message" = "Invia richiesta senza messaggio"; +/* No comment provided by engineer. */ +"Send the link via any messenger - it's secure. Ask to paste into SimpleX." = "Invia il link tramite qualsiasi messenger, è sicuro. Chiedi di incollarlo in SimpleX."; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Inviali dalla galleria o dalle tastiere personalizzate."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Invia fino a 100 ultimi messaggi ai nuovi membri."; +/* No comment provided by engineer. */ +"Send up to 100 last messages to new subscribers." = "Invia fino a 100 ultimi messaggi ai nuovi iscritti."; + /* No comment provided by engineer. */ "Send your private feedback to groups." = "Invia i tuoi commenti privati ai gruppi."; @@ -4798,6 +5324,9 @@ chat item action */ /* No comment provided by engineer. */ "Sender may have deleted the connection request." = "Il mittente potrebbe aver eliminato la richiesta di connessione."; +/* alert message */ +"Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later." = "L'invio di un'anteprima del link può rivelare il tuo indirizzo IP al sito. Puoi modificarlo nelle impostazioni di Privacy più tardi."; + /* No comment provided by engineer. */ "Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "L'invio delle ricevute di consegna sarà attivo per tutti i contatti in tutti i profili di chat visibili."; @@ -4831,9 +5360,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Inviato direttamente"; -/* notification */ -"Sent file event" = "Evento file inviato"; - /* message info title */ "Sent message" = "Messaggio inviato"; @@ -4879,11 +5405,14 @@ chat item action */ /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "info coda server: %1$@\n\nultimo msg ricevuto: %2$@"; -/* server test error */ -"Server requires authorization to create queues, check password." = "Il server richiede l'autorizzazione di creare code, controlla la password"; +/* relay test error */ +"Server requires authorization to connect to relay, check password." = "Il server richiede l'autorizzazione per connettersi al relay, controlla la password."; /* server test error */ -"Server requires authorization to upload, check password." = "Il server richiede l'autorizzazione per il caricamento, controllare la password"; +"Server requires authorization to create queues, check password." = "Il server richiede l'autorizzazione di creare code, controlla la password."; + +/* server test error */ +"Server requires authorization to upload, check password." = "Il server richiede l'autorizzazione per l'invio, controlla la password."; /* No comment provided by engineer. */ "Server test failed!" = "Test del server fallito!"; @@ -4963,6 +5492,12 @@ chat item action */ /* alert message */ "Settings were changed." = "Le impostazioni sono state cambiate."; +/* No comment provided by engineer. */ +"Setup notifications" = "Configura le notifiche"; + +/* No comment provided by engineer. */ +"Setup routers" = "Configura gli instradatori"; + /* No comment provided by engineer. */ "Shape profile images" = "Forma delle immagini del profilo"; @@ -4983,7 +5518,10 @@ chat item action */ "Share address publicly" = "Condividi indirizzo pubblicamente"; /* alert title */ -"Share address with contacts?" = "Condividere l'indirizzo con i contatti?"; +"Share address with SimpleX contacts?" = "Condividere l'indirizzo con i contatti di SimpleX?"; + +/* No comment provided by engineer. */ +"Share channel" = "Condividi canale"; /* No comment provided by engineer. */ "Share from other apps." = "Condividi da altre app."; @@ -5000,6 +5538,9 @@ chat item action */ /* No comment provided by engineer. */ "Share profile" = "Condividi il profilo"; +/* No comment provided by engineer. */ +"Share relay address" = "Condividi l'indirizzo del relay"; + /* No comment provided by engineer. */ "Share SimpleX address on social media." = "Condividi l'indirizzo SimpleX sui social media."; @@ -5010,7 +5551,10 @@ chat item action */ "Share to SimpleX" = "Condividi in SimpleX"; /* No comment provided by engineer. */ -"Share with contacts" = "Condividi con i contatti"; +"Share via chat" = "Condividi via chat"; + +/* No comment provided by engineer. */ +"Share with SimpleX contacts" = "Condividi con i contatti di SimpleX"; /* No comment provided by engineer. */ "Share your address" = "Condividi il tuo indirizzo"; @@ -5115,7 +5659,7 @@ chat item action */ "SimpleX protocols reviewed by Trail of Bits." = "Protocolli di SimpleX esaminati da Trail of Bits."; /* simplex link type */ -"SimpleX relay link" = "Link del relay SimpleX"; +"SimpleX relay address" = "Indirizzo del relay SimpleX"; /* No comment provided by engineer. */ "Simplified incognito mode" = "Modalità incognito semplificata"; @@ -5169,6 +5713,9 @@ report reason */ /* chat item text */ "standard end-to-end encryption" = "crittografia end-to-end standard"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Dai una stella su GitHub"; + /* No comment provided by engineer. */ "Start chat" = "Avvia chat"; @@ -5233,7 +5780,49 @@ report reason */ "Submit" = "Invia"; /* No comment provided by engineer. */ -"Subscribed" = "Iscritto"; +"Subscribed" = "Iscritto/a"; + +/* No comment provided by engineer. */ +"Subscriber" = "Iscritto"; + +/* chat feature */ +"Subscriber reports" = "Segnalazioni degli iscritti"; + +/* alert message */ +"Subscriber will be removed from channel - this cannot be undone!" = "L'iscritto verrà rimosso dal canale, non è reversibile!"; + +/* No comment provided by engineer. */ +"Subscribers" = "Iscritti"; + +/* No comment provided by engineer. */ +"Subscribers can add message reactions." = "Gli iscritti al canale possono aggiungere reazioni ai messaggi."; + +/* No comment provided by engineer. */ +"Subscribers can chat with admins." = "Gli iscritti possono chattare con gli amministratori."; + +/* No comment provided by engineer. */ +"Subscribers can irreversibly delete sent messages. (24 hours)" = "Gli iscritti al canale possono eliminare irreversibilmente i messaggi inviati. (24 ore)"; + +/* No comment provided by engineer. */ +"Subscribers can report messsages to moderators." = "Gli iscritti possono segnalare messaggi ai moderatori."; + +/* No comment provided by engineer. */ +"Subscribers can send direct messages." = "Gli iscritti al canale possono inviare messaggi diretti."; + +/* No comment provided by engineer. */ +"Subscribers can send disappearing messages." = "Gli iscritti al canale possono inviare messaggi a tempo."; + +/* No comment provided by engineer. */ +"Subscribers can send files and media." = "Gli iscritti al canale possono inviare file e contenuti multimediali."; + +/* No comment provided by engineer. */ +"Subscribers can send SimpleX links." = "Gli iscritti al canale possono inviare link di Simplex."; + +/* No comment provided by engineer. */ +"Subscribers can send voice messages." = "Gli iscritti al canale possono inviare messaggi vocali."; + +/* No comment provided by engineer. */ +"Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." = "Gli iscritti usano il link del relay per connettersi al canale.\nL'indirizzo del relay è stato usato per impostare questo relay per il canale."; /* No comment provided by engineer. */ "Subscription errors" = "Errori di iscrizione"; @@ -5262,6 +5851,9 @@ report reason */ /* No comment provided by engineer. */ "Take picture" = "Scatta foto"; +/* No comment provided by engineer. */ +"Talk to someone" = "Parla con qualcuno"; + /* No comment provided by engineer. */ "Tap button " = "Tocca il pulsante "; @@ -5275,7 +5867,7 @@ report reason */ "Tap Connect to use bot" = "Tocca Connetti per usare il bot"; /* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Tocca Crea indirizzo SimpleX nel menu per crearlo più tardi."; +"Tap Join channel" = "Tocca Iscriviti al canale"; /* No comment provided by engineer. */ "Tap Join group" = "Tocca Entra nel gruppo"; @@ -5292,6 +5884,9 @@ report reason */ /* No comment provided by engineer. */ "Tap to join incognito" = "Toccare per entrare in incognito"; +/* No comment provided by engineer. */ +"Tap to open" = "Tocca per aprire"; + /* No comment provided by engineer. */ "Tap to paste link" = "Tocca per incollare il link"; @@ -5322,12 +5917,16 @@ report reason */ /* file error alert title */ "Temporary file error" = "Errore del file temporaneo"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Test fallito al passo %@."; /* No comment provided by engineer. */ "Test notifications" = "Prova le notifiche"; +/* No comment provided by engineer. */ +"Test relay" = "Prova relay"; + /* No comment provided by engineer. */ "Test server" = "Prova server"; @@ -5355,6 +5954,9 @@ report reason */ /* No comment provided by engineer. */ "The app protects your privacy by using different operators in each conversation." = "L'app protegge la tua privacy usando diversi operatori in ogni conversazione."; +/* No comment provided by engineer. */ +"The app removed this message after %lld attempts to receive it." = "L'app ha rimosso questo messaggio dopo %lld tentativi di riceverlo."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "L'app chiederà di confermare i download da server di file sconosciuti (eccetto .onion)."; @@ -5364,6 +5966,9 @@ report reason */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Il codice che hai scansionato non è un codice QR di link SimpleX."; +/* conn error description */ +"The connection reached the limit of undelivered messages" = "La connessione ha raggiunto il limite di messaggi non consegnati"; + /* No comment provided by engineer. */ "The connection reached the limit of undelivered messages, your contact may be offline." = "La connessione ha raggiunto il limite di messaggi non consegnati, il contatto potrebbe essere offline."; @@ -5380,7 +5985,7 @@ report reason */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "La crittografia funziona e il nuovo accordo sulla crittografia non è richiesto. Potrebbero verificarsi errori di connessione!"; /* No comment provided by engineer. */ -"The future of messaging" = "La nuova generazione di messaggistica privata"; +"The first network where you own\nyour contacts and groups." = "La prima rete in cui possiedi\ni tuoi contatti e i tuoi gruppi."; /* No comment provided by engineer. */ "The hash of the previous message is different." = "L'hash del messaggio precedente è diverso."; @@ -5406,6 +6011,9 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Il database vecchio non è stato rimosso durante la migrazione, può essere eliminato."; +/* No comment provided by engineer. */ +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "La più antica libertà umana, parlare con un'altra persona senza essere osservati, si basa su un'infrastruttura che non può tradirla."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Le stesse condizioni si applicheranno all'operatore **%@**."; @@ -5433,6 +6041,12 @@ report reason */ /* No comment provided by engineer. */ "Themes" = "Temi"; +/* No comment provided by engineer. */ +"Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "Poi ci siamo trasferiti online e ogni piattaforma ha chiesto un pezzo di noi: il nome, il numero, gli amici. Abbiamo accettato che il prezzo da pagare per comunicare con gli altri fosse quello di far sapere a qualcuno con chi parliamo. Ogni generazione, sia di persone che di tecnologia, ha funzionato così: telefono, email, messenger, social media. Sembrava l'unico modo possibile."; + +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "C'è un'altra via. Una rete senza numeri di telefono. Senza nomi utente. Senza account. Senza identificatori utente di alcun tipo. Una rete che connette le persone e trasferisce messaggi crittografati senza sapere chi è connesso."; + /* No comment provided by engineer. */ "These conditions will also apply for: **%@**." = "Queste condizioni si applicheranno anche per: **%@**."; @@ -5475,6 +6089,12 @@ report reason */ /* No comment provided by engineer. */ "This group no longer exists." = "Questo gruppo non esiste più."; +/* alert message */ +"This is a chat relay address, it cannot be used to connect." = "Questo è un indirizzo di relay di chat, non può essere usato per connettersi."; + +/* new chat action */ +"This is your link for channel %@!" = "Questo è il tuo link per il canale %@!"; + /* No comment provided by engineer. */ "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Questo link richiede una versione più recente dell'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile."; @@ -5508,6 +6128,9 @@ report reason */ /* No comment provided by engineer. */ "To make a new connection" = "Per creare una nuova connessione"; +/* No comment provided by engineer. */ +"To make SimpleX Network last." = "Per la sostenibilità della rete di SimpleX."; + /* No comment provided by engineer. */ "To protect against your link being replaced, you can compare contact security codes." = "Per proteggerti dalla sostituzione del tuo link, puoi confrontare i codici di sicurezza del contatto."; @@ -5556,9 +6179,6 @@ report reason */ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Per verificare la crittografia end-to-end con il tuo contatto, confrontate (o scansionate) il codice sui vostri dispositivi."; -/* No comment provided by engineer. */ -"Toggle chat list:" = "Cambia l'elenco delle chat:"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Attiva/disattiva l'incognito quando ti colleghi."; @@ -5568,6 +6188,9 @@ report reason */ /* No comment provided by engineer. */ "Toolbar opacity" = "Opacità barra degli strumenti"; +/* No comment provided by engineer. */ +"Top bar" = "Barra superiore"; + /* No comment provided by engineer. */ "Total" = "Totale"; @@ -5577,11 +6200,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Sessioni di trasporto"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Tentativo di connessione al server usato per ricevere messaggi da questo contatto."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Tentativo di connessione al server usato per ricevere messaggi da questa connessione."; /* No comment provided by engineer. */ "Turkish interface" = "Interfaccia in turco"; @@ -5610,6 +6230,9 @@ report reason */ /* No comment provided by engineer. */ "Unblock member?" = "Sbloccare il membro?"; +/* No comment provided by engineer. */ +"Unblock subscriber for all?" = "Sbloccare l'iscritto per tutti?"; + /* rcv group event chat item */ "unblocked %@" = "ha sbloccato %@"; @@ -5682,12 +6305,15 @@ report reason */ /* swipe action */ "Unread" = "Non letto"; -/* No comment provided by engineer. */ +/* conn error description */ "Unsupported connection link" = "Link di connessione non supportato"; /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Vengono inviati ai nuovi membri fino a 100 ultimi messaggi."; +/* No comment provided by engineer. */ +"Up to 100 last messages are sent to new subscribers." = "Vengono inviati ai nuovi iscritti fino a 100 ultimi messaggi."; + /* No comment provided by engineer. */ "Update" = "Aggiorna"; @@ -5700,6 +6326,9 @@ report reason */ /* No comment provided by engineer. */ "Update settings?" = "Aggiornare le impostazioni?"; +/* rcv group event chat item */ +"updated channel profile" = "profilo del canale aggiornato"; + /* No comment provided by engineer. */ "Updated conditions" = "Condizioni aggiornate"; @@ -5757,9 +6386,6 @@ report reason */ /* No comment provided by engineer. */ "Use %@" = "Usa %@"; -/* No comment provided by engineer. */ -"Use chat" = "Usa la chat"; - /* new chat action */ "Use current profile" = "Usa il profilo attuale"; @@ -5769,6 +6395,9 @@ report reason */ /* No comment provided by engineer. */ "Use for messages" = "Usa per i messaggi"; +/* No comment provided by engineer. */ +"Use for new channels" = "Usa per canali nuovi"; + /* No comment provided by engineer. */ "Use for new connections" = "Usa per connessioni nuove"; @@ -5793,6 +6422,9 @@ report reason */ /* No comment provided by engineer. */ "Use private routing with unknown servers." = "Usa l'instradamento privato con server sconosciuti."; +/* No comment provided by engineer. */ +"Use relay" = "Usa relay"; + /* No comment provided by engineer. */ "Use server" = "Usa il server"; @@ -5817,6 +6449,9 @@ report reason */ /* No comment provided by engineer. */ "Use the app with one hand." = "Usa l'app con una mano sola."; +/* No comment provided by engineer. */ +"Use this address in your social media profile, website, or email signature." = "Usa questo indirizzo nel tuo profilo di social media, sito web o firma email."; + /* No comment provided by engineer. */ "Use web port" = "Usa porta web"; @@ -5835,6 +6470,9 @@ report reason */ /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* relay test step */ +"Verify" = "Verifica"; + /* No comment provided by engineer. */ "Verify code with desktop" = "Verifica il codice con il desktop"; @@ -5856,6 +6494,9 @@ report reason */ /* No comment provided by engineer. */ "Verify security code" = "Verifica codice di sicurezza"; +/* relay hostname */ +"via %@" = "via %@"; + /* No comment provided by engineer. */ "Via browser" = "Via browser"; @@ -5889,6 +6530,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Il video verrà ricevuto quando il tuo contatto sarà in linea, attendi o controlla più tardi!"; +/* No comment provided by engineer. */ +"Videos" = "Video"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Video e file fino a 1 GB"; @@ -5922,9 +6566,18 @@ report reason */ /* No comment provided by engineer. */ "Voice messages prohibited!" = "Messaggi vocali vietati!"; +/* alert action */ +"Wait" = "Attendi"; + +/* relay test step */ +"Wait response" = "Attendi risposta"; + /* No comment provided by engineer. */ "waiting for answer…" = "in attesa di risposta…"; +/* No comment provided by engineer. */ +"Waiting for channel owner to add relays." = "In attesa che il proprietario del canale aggiunga dei relay."; + /* No comment provided by engineer. */ "waiting for confirmation…" = "in attesa di conferma…"; @@ -5955,6 +6608,9 @@ report reason */ /* No comment provided by engineer. */ "Warning: you may lose some data!" = "Attenzione: potresti perdere alcuni dati!"; +/* No comment provided by engineer. */ +"We made connecting simpler for new users." = "Abbiamo semplificato la connessione per i nuovi utenti."; + /* No comment provided by engineer. */ "WebRTC ICE servers" = "Server WebRTC ICE"; @@ -5991,6 +6647,9 @@ report reason */ /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Quando condividi un profilo in incognito con qualcuno, questo profilo verrà utilizzato per i gruppi a cui ti invitano."; +/* No comment provided by engineer. */ +"Why SimpleX is built." = "Perché costruiamo SimpleX."; + /* No comment provided by engineer. */ "WiFi" = "WiFi"; @@ -6075,18 +6734,24 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Stai già entrando nel gruppo!\nRipetere la richiesta di ingresso?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Sei connesso/a al server usato per ricevere messaggi da questo contatto."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Sei connesso/a al server usato per ricevere messaggi da questa connessione."; /* No comment provided by engineer. */ "You are invited to group" = "Sei stato/a invitato/a al gruppo"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Non sei connesso/a a questi server. L'instradamento privato è usato per consegnare loro i messaggi."; /* No comment provided by engineer. */ "you are observer" = "sei un osservatore"; +/* No comment provided by engineer. */ +"you are subscriber" = "sei iscritto/a"; + /* snd group event chat item */ "you blocked %@" = "hai bloccato %@"; @@ -6129,6 +6794,9 @@ report reason */ /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Puoi impostare l'anteprima della notifica nella schermata di blocco tramite le impostazioni."; +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the channel." = "Puoi condividere un link o un codice QR, chiunque sarà in grado di iscriversi al canale."; + /* No comment provided by engineer. */ "You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Puoi condividere un link o un codice QR: chiunque potrà unirsi al gruppo. Non perderai i membri del gruppo se in seguito lo elimini."; @@ -6169,10 +6837,13 @@ report reason */ "you changed role of %@ to %@" = "hai cambiato il ruolo di %1$@ in %2$@"; /* No comment provided by engineer. */ -"You could not be verified; please try again." = "Non è stato possibile verificarti, riprova."; +"You commit to:\n- Only legal content in public groups\n- Respect other users - no spam" = "Tu ti impegni a:\n- Pubblicare solo contenuto legale nei gruppi pubblici\n- Rispettare gli altri utenti. Niente spam"; /* No comment provided by engineer. */ -"You decide who can connect." = "Sei tu a decidere chi può connettersi."; +"You connected to the channel via this relay link." = "Ti sei connesso/a al canale attraverso questo link del relay."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "Non è stato possibile verificarti, riprova."; /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Hai già richiesto la connessione!\nRipetere la richiesta di connessione?"; @@ -6228,6 +6899,9 @@ report reason */ /* snd group event chat item */ "you unblocked %@" = "hai sbloccato %@"; +/* No comment provided by engineer. */ +"You were born without an account" = "Sei nato senza un account"; + /* No comment provided by engineer. */ "You will be able to send messages **only after your request is accepted**." = "Potrai inviare messaggi **solo dopo che la tua richiesta verrà accettata**."; @@ -6249,6 +6923,9 @@ report reason */ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Continuerai a ricevere chiamate e notifiche da profili silenziati quando sono attivi."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this channel. Chat history will be preserved." = "Smetterai di ricevere messaggi da questo canale. La cronologia della chat sarà preservata."; + /* No comment provided by engineer. */ "You will stop receiving messages from this chat. Chat history will be preserved." = "Non riceverai più messaggi da questa chat. La cronologia della chat verrà conservata."; @@ -6273,6 +6950,9 @@ report reason */ /* No comment provided by engineer. */ "Your calls" = "Le tue chiamate"; +/* No comment provided by engineer. */ +"Your channel" = "Il tuo canale"; + /* No comment provided by engineer. */ "Your chat database" = "Il tuo database della chat"; @@ -6303,6 +6983,9 @@ report reason */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "I tuoi contatti resteranno connessi."; +/* No comment provided by engineer. */ +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "Le tue conversazioni appartengono a te, come è sempre stato prima dell'avvento di internet. La rete non è un luogo che visiti. È un luogo che crei e possiedi. E nessuno può portartelo via, che tu lo renda privato o pubblico."; + /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "Le credenziali potrebbero essere inviate in chiaro."; @@ -6318,6 +7001,9 @@ report reason */ /* No comment provided by engineer. */ "Your ICE servers" = "I tuoi server ICE"; +/* No comment provided by engineer. */ +"Your network" = "La tua rete"; + /* No comment provided by engineer. */ "Your preferences" = "Le tue preferenze"; @@ -6327,6 +7013,9 @@ report reason */ /* No comment provided by engineer. */ "Your profile" = "Il tuo profilo"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared with channel relays and subscribers.\nRelays can access channel messages." = "Il tuo profilo **%@** verrà condiviso con i relay e gli iscritti.\nI relay hanno accesso ai messaggi del canale."; + /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Verrà condiviso il tuo profilo **%@**."; @@ -6339,9 +7028,18 @@ report reason */ /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Il tuo profilo è stato cambiato. Se lo salvi, il profilo aggiornato verrà inviato a tutti i tuoi contatti."; +/* No comment provided by engineer. */ +"Your public address" = "Il tuo indirizzo pubblico"; + /* No comment provided by engineer. */ "Your random profile" = "Il tuo profilo casuale"; +/* No comment provided by engineer. */ +"Your relay address" = "L'indirizzo del tuo relay"; + +/* No comment provided by engineer. */ +"Your relay name" = "Il nome del tuo relay"; + /* No comment provided by engineer. */ "Your server address" = "L'indirizzo del tuo server"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index de2f23ccce..b14777244f 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(このデバイス v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[貢献する](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[メールを送信](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[GitHub でスターを付ける](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**コンタクトの追加**: 新しい招待リンクを作成するか、受け取ったリンクから接続します。"; @@ -375,7 +369,13 @@ swipe action */ "Acknowledged" = "了承済み"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "プロフィールにアドレスを追加し、連絡先があなたのアドレスを他の人と共有できるようにします。プロフィールの更新は連絡先に送信されます。"; +"Active connections" = "アクティブな接続"; + +/* No comment provided by engineer. */ +"Add friends" = "友達を追加"; + +/* No comment provided by engineer. */ +"Add list" = "リストを追加"; /* No comment provided by engineer. */ "Add profile" = "プロフィールを追加"; @@ -386,9 +386,15 @@ swipe action */ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "QRコードでサーバを追加する。"; +/* No comment provided by engineer. */ +"Add team members" = "チームメンバーを追加"; + /* No comment provided by engineer. */ "Add to another device" = "別の端末に追加"; +/* No comment provided by engineer. */ +"Add to list" = "リストに追加"; + /* No comment provided by engineer. */ "Add welcome message" = "ウェルカムメッセージを追加"; @@ -404,6 +410,9 @@ swipe action */ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "アドレス変更は中止されます。古い受信アドレスが使用されます。"; +/* No comment provided by engineer. */ +"Address settings" = "アドレス設定"; + /* member role */ "admin" = "管理者"; @@ -422,6 +431,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "暗号化に同意しています…"; +/* No comment provided by engineer. */ +"All" = "すべて"; + /* No comment provided by engineer. */ "All app data is deleted." = "すべてのアプリデータが削除されます。"; @@ -434,6 +446,9 @@ swipe action */ /* No comment provided by engineer. */ "All group members will remain connected." = "グループ全員の接続が継続します。"; +/* No comment provided by engineer. */ +"All messages will be deleted - this cannot be undone!" = "すべてのメッセージが削除されます。この操作は元に戻せません!"; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "全てのメッセージが削除されます(※注意:元に戻せません!※)。削除されるのは片方あなたのメッセージのみ。"; @@ -455,9 +470,15 @@ swipe action */ /* No comment provided by engineer. */ "Allow calls only if your contact allows them." = "連絡先が通話を許可している場合のみ通話を許可する。"; +/* No comment provided by engineer. */ +"Allow calls?" = "通話を許可しますか?"; + /* No comment provided by engineer. */ "Allow disappearing messages only if your contact allows it to you." = "連絡先が許可している場合のみ消えるメッセージを許可する。"; +/* No comment provided by engineer. */ +"Allow downgrade" = "ダウングレードを許可する"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間)"; @@ -530,11 +551,11 @@ swipe action */ /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "指定された名前の空のチャット プロファイルが作成され、アプリが通常どおり開きます。"; -/* No comment provided by engineer. */ -"Answer call" = "通話に応答"; +/* report reason */ +"Another reason" = "他の理由"; /* No comment provided by engineer. */ -"Anybody can host servers." = "プロトコル技術とコードはオープンソースで、どなたでもご自分のサーバを運用できます。"; +"Answer call" = "通話に応答"; /* No comment provided by engineer. */ "App build: %@" = "アプリのビルド: %@"; @@ -569,9 +590,15 @@ swipe action */ /* No comment provided by engineer. */ "Apply to" = "に適用する"; +/* No comment provided by engineer. */ +"Archive" = "アーカイブ"; + /* No comment provided by engineer. */ "Archive and upload" = "アーカイブとアップロード"; +/* No comment provided by engineer. */ +"Archived contacts" = "アーカイブされた連絡先"; + /* No comment provided by engineer. */ "Attach" = "添付する"; @@ -668,6 +695,9 @@ swipe action */ /* No comment provided by engineer. */ "Calls" = "通話"; +/* alert title */ +"Can't change profile" = "プロフィールを変更できません"; + /* No comment provided by engineer. */ "Can't invite contact!" = "連絡先を招待できません!"; @@ -679,12 +709,18 @@ alert button new chat action */ "Cancel" = "中止"; +/* No comment provided by engineer. */ +"Cancel migration" = "移行を中止する"; + /* feature offered item */ "cancelled %@" = "キャンセルされました %@"; /* No comment provided by engineer. */ "Cannot access keychain to save database password" = "データベースのパスワードを保存するためのキーチェーンにアクセスできません"; +/* No comment provided by engineer. */ +"Cannot forward message" = "メッセージを転送できません"; + /* alert title */ "Cannot receive file" = "ファイル受信ができません"; @@ -734,6 +770,9 @@ set passcode view */ /* chat item text */ "changing address…" = "アドレスを変更しています…"; +/* No comment provided by engineer. */ +"Chat" = "チャット"; + /* No comment provided by engineer. */ "Chat console" = "チャットのコンソール"; @@ -752,6 +791,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chat is stopped" = "チャットが停止してます"; +/* No comment provided by engineer. */ +"Chat list" = "チャット一覧"; + /* No comment provided by engineer. */ "Chat preferences" = "チャット設定"; @@ -764,6 +806,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chats" = "チャット"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "20分おきにメッセージを確認する。"; + /* alert title */ "Check server address and try again." = "サーバのアドレスを確認してから再度試してください。"; @@ -833,7 +878,8 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm password" = "パスワードを確認"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "接続"; /* No comment provided by engineer. */ @@ -911,7 +957,7 @@ set passcode view */ /* alert title */ "Connection error" = "接続エラー"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "接続エラー (AUTH)"; /* chat list item title (it should not be shown */ @@ -962,15 +1008,15 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "続ける"; +/* No comment provided by engineer. */ +"Contribute" = "貢献する"; + /* No comment provided by engineer. */ "Copy" = "コピー"; /* No comment provided by engineer. */ "Core version: v%@" = "コアのバージョン: v%@"; -/* No comment provided by engineer. */ -"Create" = "作成"; - /* server test step */ "Create file" = "ファイルを作成"; @@ -1085,9 +1131,6 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "配信のデバッグ"; -/* No comment provided by engineer. */ -"Decentralized" = "分散型"; - /* message decrypt error item */ "Decryption error" = "復号化エラー"; @@ -1168,7 +1211,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "メッセージを削除しますか?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "メッセージを削除"; /* No comment provided by engineer. */ @@ -1327,7 +1371,7 @@ swipe action */ /* No comment provided by engineer. */ "Edit group profile" = "グループのプロフィールを編集"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "有効"; /* No comment provided by engineer. */ @@ -1345,9 +1389,6 @@ swipe action */ /* No comment provided by engineer. */ "Enable lock" = "ロックモード"; -/* No comment provided by engineer. */ -"Enable notifications" = "通知を有効化"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "定期的な通知を有効にしますか?"; @@ -1459,7 +1500,7 @@ swipe action */ /* No comment provided by engineer. */ "error" = "エラー"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "エラー"; /* No comment provided by engineer. */ @@ -1662,7 +1703,8 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "チャットを素早く検索"; -/* server test error */ +/* relay test error +server test error */ "Fingerprint in server address does not match certificate." = "サーバアドレスの証明証IDが正しくないかもしれません"; /* No comment provided by engineer. */ @@ -1728,7 +1770,7 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "グループ招待が無効となり、送信元によって取り消されました。"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "グループのリンク"; /* No comment provided by engineer. */ @@ -1830,9 +1872,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "即座に"; -/* No comment provided by engineer. */ -"Immune to spam" = "スパムや悪質送信を防止"; - /* No comment provided by engineer. */ "Import" = "読み込む"; @@ -1897,7 +1936,7 @@ snd error text */ "Initial role" = "初期の役割"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "インストール [ターミナル用SimpleX Chat](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "インストール ターミナル用SimpleX Chat"; /* No comment provided by engineer. */ "Instant" = "即時"; @@ -1914,7 +1953,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "無効なチャットデータ"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "無効な接続リンク"; /* invalid chat item */ @@ -2103,7 +2142,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "メンバーの役割が \"%@\" に変更されます。 メンバーは新たな招待を受け取ります。"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "メンバーをグループから除名する (※元に戻せません※)!"; /* No comment provided by engineer. */ @@ -2157,9 +2196,6 @@ snd error text */ /* No comment provided by engineer. */ "Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "メッセージ、ファイル、通話は、前方秘匿性、否認可能性および侵入復元性を備えた**耐量子E2E暗号化**によって保護されます。"; -/* No comment provided by engineer. */ -"Migrate from another device" = "別の端末から移行"; - /* No comment provided by engineer. */ "Migrating database archive…" = "データベースのアーカイブを移行しています…"; @@ -2223,7 +2259,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "ネットワーク設定"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "ネットワーク状況"; /* delete after time */ @@ -2304,9 +2340,6 @@ snd error text */ /* copied message info in history */ "no text" = "テキストなし"; -/* No comment provided by engineer. */ -"No user identifiers." = "世界初のユーザーIDのないプラットフォーム|設計も元からプライベート。"; - /* No comment provided by engineer. */ "Notifications" = "通知"; @@ -2399,7 +2432,8 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "音声メッセージを送れるのはあなたの連絡相手だけです。"; -/* alert action */ +/* alert action +alert button */ "Open" = "開く"; /* new chat action */ @@ -2501,9 +2535,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "プライバシーとセキュリティ"; -/* No comment provided by engineer. */ -"Privacy redefined" = "プライバシーの基準を新境地に"; - /* No comment provided by engineer. */ "Private filenames" = "プライベートなファイル名"; @@ -2519,9 +2550,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile password" = "プロフィールのパスワード"; -/* alert message */ -"Profile update will be sent to your contacts." = "連絡先にプロフィール更新のお知らせが届きます。"; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "音声/ビデオ通話を禁止する 。"; @@ -2574,13 +2602,10 @@ new chat action */ "Read more" = "続きを読む"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "詳しくは[ユーザーガイド](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)をご覧ください。"; +"Read more in our GitHub repository." = "詳しくはGitHubリポジトリをご覧ください。"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "詳しくは[ユーザーガイド](https://simplex.chat/docs/guide/readme.html#connect-to-friends)をご覧ください。"; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "詳しくは[GitHubリポジトリ](https://github.com/simplex-chat/simplex-chat#readme)をご覧ください。"; +"Read more in User Guide." = "詳しくはユーザーガイドをご覧ください。"; /* No comment provided by engineer. */ "received answer…" = "回答を受け取りました…"; @@ -2594,9 +2619,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "確認を受け取りました…"; -/* notification */ -"Received file event" = "ファイル受信イベント"; - /* message info title */ "Received message" = "受信したメッセージ"; @@ -2647,13 +2669,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "リレー サーバーは IP アドレスを保護しますが、通話時間は監視されます。"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "削除"; /* No comment provided by engineer. */ "Remove member" = "メンバーを除名する"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "メンバーを除名しますか?"; /* No comment provided by engineer. */ @@ -2867,9 +2889,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "送信日時: %@"; -/* notification */ -"Sent file event" = "送信済みファイルイベント"; - /* message info title */ "Sent message" = "送信"; @@ -2925,15 +2944,9 @@ chat item action */ /* No comment provided by engineer. */ "Share address" = "アドレスを共有する"; -/* alert title */ -"Share address with contacts?" = "アドレスを連絡先と共有しますか?"; - /* No comment provided by engineer. */ "Share link" = "リンクを送る"; -/* No comment provided by engineer. */ -"Share with contacts" = "連絡先と共有する"; - /* No comment provided by engineer. */ "Show calls in phone history" = "通話履歴を表示"; @@ -3003,6 +3016,9 @@ chat item action */ /* notification title */ "Somebody" = "誰か"; +/* No comment provided by engineer. */ +"Star on GitHub" = "GitHub でスターを付ける"; + /* No comment provided by engineer. */ "Start chat" = "チャットを開始する"; @@ -3081,7 +3097,8 @@ chat item action */ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "テストはステップ %@ で失敗しました。"; /* No comment provided by engineer. */ @@ -3120,9 +3137,6 @@ chat item action */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "暗号化は機能しており、新しい暗号化への同意は必要ありません。接続エラーが発生する可能性があります!"; -/* No comment provided by engineer. */ -"The future of messaging" = "次世代のプライバシー・メッセンジャー"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "以前のメッセージとハッシュ値が異なります。"; @@ -3204,12 +3218,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "トランスポート隔離"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "この連絡先からのメッセージの受信に使用されるサーバーに接続しようとしています (エラー: %@)。"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "このコンタクトから受信するメッセージのサーバに接続しようとしてます。"; - /* No comment provided by engineer. */ "Turn off" = "オフにする"; @@ -3291,9 +3299,6 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = ".onionホストを使う"; -/* No comment provided by engineer. */ -"Use chat" = "チャット"; - /* new chat action */ "Use current profile" = "現在のプロファイルを使用する"; @@ -3438,9 +3443,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "すでに %@ に接続されています。"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "この連絡先から受信するメッセージのサーバに既に接続してます。"; - /* No comment provided by engineer. */ "You are invited to group" = "グループ招待が届きました"; @@ -3504,9 +3506,6 @@ chat item action */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "確認できませんでした。 もう一度お試しください。"; -/* No comment provided by engineer. */ -"You decide who can connect." = "あなたと繋がることができるのは、あなたからリンクを頂いた方のみです。"; - /* No comment provided by engineer. */ "You have to enter passphrase every time the app starts - it is not stored on the device." = "アプリ起動時にパスフレーズを入力しなければなりません。端末に保存されてません。"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 1d5fef3fe3..e12e255488 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(dit apparaat v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Bijdragen](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Stuur ons een e-mail](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Contact toevoegen**: om een nieuwe uitnodigingslink aan te maken, of verbinding te maken via een link die u heeft ontvangen."; @@ -395,9 +389,6 @@ swipe action */ /* No comment provided by engineer. */ "Active connections" = "Actieve verbindingen"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Voeg een adres toe aan uw profiel, zodat uw contacten het met andere mensen kunnen delen. Profiel update wordt naar uw contacten verzonden."; - /* No comment provided by engineer. */ "Add friends" = "Vrienden toevoegen"; @@ -635,9 +626,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Beantwoord oproep"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Iedereen kan servers hosten."; - /* No comment provided by engineer. */ "App build: %@" = "App build: %@"; @@ -867,7 +855,7 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgaars, Fins, Thais en Oekraïens - dankzij de gebruikers en [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; -/* No comment provided by engineer. */ +/* chat link info line */ "Business address" = "Zakelijk adres"; /* No comment provided by engineer. */ @@ -879,9 +867,6 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Door SimpleX Chat te gebruiken, gaat u ermee akkoord:\n- alleen legale content te versturen in openbare groepen.\n- andere gebruikers te respecteren – geen spam."; - /* No comment provided by engineer. */ "call" = "bellen"; @@ -1062,7 +1047,8 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "De chat wordt voor je verwijderd - dit kan niet ongedaan worden gemaakt!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Chat met beheerders"; /* No comment provided by engineer. */ @@ -1158,7 +1144,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Voorwaarden zijn reeds geaccepteerd voor de volgende operator(s): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Gebruiksvoorwaarden"; /* No comment provided by engineer. */ @@ -1173,9 +1159,6 @@ set passcode view */ /* No comment provided by engineer. */ "Configure ICE servers" = "ICE servers configureren"; -/* No comment provided by engineer. */ -"Configure server operators" = "Serveroperators configureren"; - /* No comment provided by engineer. */ "Confirm" = "Bevestigen"; @@ -1209,7 +1192,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Bevestigd"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Verbind"; /* No comment provided by engineer. */ @@ -1305,7 +1289,7 @@ set passcode view */ /* alert title */ "Connection error" = "Verbindingsfout"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Verbindingsfout (AUTH)"; /* chat list item title (it should not be shown */ @@ -1401,6 +1385,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Doorgaan"; +/* No comment provided by engineer. */ +"Contribute" = "Bijdragen"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Gesprek verwijderd!"; @@ -1416,12 +1403,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Hoek"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Juiste naam voor %@?"; -/* No comment provided by engineer. */ -"Create" = "Maak"; - /* No comment provided by engineer. */ "Create 1-time link" = "Eenmalige link maken"; @@ -1572,9 +1556,6 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Foutopsporing bezorging"; -/* No comment provided by engineer. */ -"Decentralized" = "Gedecentraliseerd"; - /* message decrypt error item */ "Decryption error" = "Decodering fout"; @@ -1688,7 +1669,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Verwijder bericht?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Verwijder berichten"; /* No comment provided by engineer. */ @@ -1962,7 +1944,7 @@ chat item action */ /* No comment provided by engineer. */ "Edit group profile" = "Groep profiel bewerken"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Inschakelen"; /* No comment provided by engineer. */ @@ -1989,9 +1971,6 @@ chat item action */ /* No comment provided by engineer. */ "Enable lock" = "Vergrendeling inschakelen"; -/* No comment provided by engineer. */ -"Enable notifications" = "Meldingen aanzetten"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "Periodieke meldingen inschakelen?"; @@ -2133,7 +2112,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "fout"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Fout"; /* No comment provided by engineer. */ @@ -2498,7 +2477,8 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Vind chats sneller"; -/* server test error */ +/* relay test error +server test error */ "Fingerprint in server address does not match certificate." = "Mogelijk is de certificaat vingerafdruk in het server adres onjuist"; /* No comment provided by engineer. */ @@ -2522,7 +2502,8 @@ snd error text */ /* No comment provided by engineer. */ "For all moderators" = "Voor alle moderators"; -/* servers error */ +/* servers error +servers warning */ "For chat profile %@:" = "Voor chatprofiel %@:"; /* No comment provided by engineer. */ @@ -2651,7 +2632,7 @@ snd error text */ /* No comment provided by engineer. */ "group is deleted" = "groep is verwijderd"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Groep link"; /* No comment provided by engineer. */ @@ -2774,9 +2755,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "Onmiddellijk"; -/* No comment provided by engineer. */ -"Immune to spam" = "Immuun voor spam en misbruik"; - /* No comment provided by engineer. */ "Import" = "Importeren"; @@ -2877,7 +2855,7 @@ snd error text */ "Initial role" = "Initiële rol"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Installeer [SimpleX Chat voor terminal](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Installeer SimpleX Chat voor terminal"; /* No comment provided by engineer. */ "Instant" = "Direct"; @@ -2912,7 +2890,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "ongeldige gesprek gegevens"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Ongeldige verbinding link"; /* invalid chat item */ @@ -2927,7 +2905,7 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Ongeldige migratie bevestiging"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Ongeldige naam!"; /* No comment provided by engineer. */ @@ -3197,10 +3175,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "De rol van lid wordt gewijzigd in \"%@\". Het lid ontvangt een nieuwe uitnodiging."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Lid wordt verwijderd uit de chat - dit kan niet ongedaan worden gemaakt!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt!"; /* alert message */ @@ -3326,9 +3304,6 @@ snd error text */ /* No comment provided by engineer. */ "Migrate device" = "Apparaat migreren"; -/* No comment provided by engineer. */ -"Migrate from another device" = "Migreer vanaf een ander apparaat"; - /* No comment provided by engineer. */ "Migrate here" = "Migreer hierheen"; @@ -3437,7 +3412,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Netwerk instellingen"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Netwerk status"; /* delete after time */ @@ -3599,9 +3574,6 @@ snd error text */ /* No comment provided by engineer. */ "No unread chats" = "Geen ongelezen chats"; -/* No comment provided by engineer. */ -"No user identifiers." = "Geen gebruikers-ID's."; - /* No comment provided by engineer. */ "Not compatible!" = "Niet compatibel!"; @@ -3658,7 +3630,7 @@ alert button new chat action */ "Ok" = "OK"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "OK"; /* No comment provided by engineer. */ @@ -3733,7 +3705,8 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Alleen uw contact kan spraak berichten verzenden."; -/* alert action */ +/* alert action +alert button */ "Open" = "Open"; /* No comment provided by engineer. */ @@ -3964,12 +3937,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy policy and conditions of use." = "Privacybeleid en gebruiksvoorwaarden."; -/* No comment provided by engineer. */ -"Privacy redefined" = "Privacy opnieuw gedefinieerd"; - -/* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders."; - /* No comment provided by engineer. */ "Private filenames" = "Privé bestandsnamen"; @@ -4006,9 +3973,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile theme" = "Profiel thema"; -/* alert message */ -"Profile update will be sent to your contacts." = "Profiel update wordt naar uw contacten verzonden."; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Audio/video gesprekken verbieden."; @@ -4094,16 +4058,10 @@ new chat action */ "Read more" = "Lees meer"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Lees meer in de [Gebruikershandleiding](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Lees meer in onze GitHub-repository."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Lees meer in de [Gebruikershandleiding](https://simplex.chat/docs/guide/app-settings.html#uw-simplex-contactadres)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Lees meer in de [Gebruikershandleiding](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Lees meer in onze [GitHub-repository](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Lees meer in de Gebruikershandleiding."; /* No comment provided by engineer. */ "Receipts are disabled" = "Bevestigingen zijn uitgeschakeld"; @@ -4123,9 +4081,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "bevestiging ontvangen…"; -/* notification */ -"Received file event" = "Ontvangen bestandsgebeurtenis"; - /* message info title */ "Received message" = "Ontvangen bericht"; @@ -4221,7 +4176,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relay server beschermt uw IP-adres, maar kan de duur van het gesprek observeren."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Verwijderen"; /* No comment provided by engineer. */ @@ -4233,7 +4188,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Lid verwijderen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Lid verwijderen?"; /* No comment provided by engineer. */ @@ -4651,9 +4606,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direct verzonden"; -/* notification */ -"Sent file event" = "Verzonden bestandsgebeurtenis"; - /* message info title */ "Sent message" = "Verzonden bericht"; @@ -4799,9 +4751,6 @@ chat item action */ /* No comment provided by engineer. */ "Share address publicly" = "Adres openbaar delen"; -/* alert title */ -"Share address with contacts?" = "Adres delen met contacten?"; - /* No comment provided by engineer. */ "Share from other apps." = "Delen vanuit andere apps."; @@ -4820,9 +4769,6 @@ chat item action */ /* No comment provided by engineer. */ "Share to SimpleX" = "Delen op SimpleX"; -/* No comment provided by engineer. */ -"Share with contacts" = "Delen met contacten"; - /* No comment provided by engineer. */ "Short link" = "Korte link"; @@ -4968,6 +4914,9 @@ report reason */ /* chat item text */ "standard end-to-end encryption" = "standaard end-to-end encryptie"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Star on GitHub"; + /* No comment provided by engineer. */ "Start chat" = "Begin gesprek"; @@ -5064,9 +5013,6 @@ report reason */ /* No comment provided by engineer. */ "Tap button " = "Tik op de knop "; -/* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Tik op SimpleX-adres maken in het menu om het later te maken."; - /* No comment provided by engineer. */ "Tap to activate profile." = "Tik hier om profiel te activeren."; @@ -5106,7 +5052,8 @@ report reason */ /* file error alert title */ "Temporary file error" = "Tijdelijke bestandsfout"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Test mislukt bij stap %@."; /* No comment provided by engineer. */ @@ -5160,9 +5107,6 @@ report reason */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "De versleuteling werkt en de nieuwe versleutelingsovereenkomst is niet vereist. Dit kan leiden tot verbindingsfouten!"; -/* No comment provided by engineer. */ -"The future of messaging" = "De volgende generatie privéberichten"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "De hash van het vorige bericht is anders."; @@ -5322,9 +5266,6 @@ report reason */ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Vergelijk (of scan) de code op uw apparaten om end-to-end-codering met uw contact te verifiëren."; -/* No comment provided by engineer. */ -"Toggle chat list:" = "Chatlijst wisselen:"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Schakel incognito in tijdens het verbinden."; @@ -5343,12 +5284,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Transportsessies"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen."; - /* No comment provided by engineer. */ "Turkish interface" = "Turkse interface"; @@ -5448,7 +5383,7 @@ report reason */ /* swipe action */ "Unread" = "Ongelezen"; -/* No comment provided by engineer. */ +/* conn error description */ "Unsupported connection link" = "Niet-ondersteunde verbindingslink"; /* No comment provided by engineer. */ @@ -5505,9 +5440,6 @@ report reason */ /* No comment provided by engineer. */ "Use %@" = "Gebruik %@"; -/* No comment provided by engineer. */ -"Use chat" = "Gebruik chat"; - /* new chat action */ "Use current profile" = "Gebruik het huidige profiel"; @@ -5817,9 +5749,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Je sluit je al aan bij de groep!\nDeelnameverzoek herhalen?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen."; - /* No comment provided by engineer. */ "You are invited to group" = "Je bent uitgenodigd voor de groep"; @@ -5913,9 +5842,6 @@ report reason */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "U kon niet worden geverifieerd; probeer het opnieuw."; -/* No comment provided by engineer. */ -"You decide who can connect." = "Jij bepaalt wie er verbinding mag maken."; - /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Je hebt al verbinding aangevraagd!\nVerbindingsverzoek herhalen?"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index cf08c2b40a..e2e46590c9 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(to urządzenie v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Przyczyń się](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Wyślij do nas email](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Daj gwiazdkę na GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Dodaj kontakt**: aby utworzyć nowy link z zaproszeniem lub połączyć się za pomocą otrzymanego linku."; @@ -257,7 +251,7 @@ "`a + b`" = "\\`a + b`"; /* email text */ -"

Hi!

\n

Connect to me via SimpleX Chat

" = "

Cześć!

\n

Połącz się ze mną poprzez SimpleX Chat.

"; +"

Hi!

\n

Connect to me via SimpleX Chat

" = "

Cześć!

\n

Połącz się ze mną poprzez SimpleX Chat

"; /* No comment provided by engineer. */ "~strike~" = "\\~strajk~"; @@ -346,12 +340,21 @@ alert action swipe action */ "Accept" = "Akceptuj"; +/* alert action */ +"Accept as member" = "Zaakceptuj jako członka"; + +/* alert action */ +"Accept as observer" = "Zaakceptuj jako obserwatora"; + /* No comment provided by engineer. */ "Accept conditions" = "Zaakceptuj warunki"; /* No comment provided by engineer. */ "Accept connection request?" = "Zaakceptować prośbę o połączenie?"; +/* alert title */ +"Accept contact request" = "Zaakceptuj prośby o kontakt"; + /* notification body */ "Accept contact request from %@?" = "Zaakceptuj prośbę o kontakt od %@?"; @@ -359,12 +362,24 @@ swipe action */ swipe action */ "Accept incognito" = "Akceptuj incognito"; +/* alert title */ +"Accept member" = "Zaakceptuj członka"; + +/* rcv group event chat item */ +"accepted %@" = "zaakceptowano %@"; + /* call status */ "accepted call" = "zaakceptowane połączenie"; /* No comment provided by engineer. */ "Accepted conditions" = "Zaakceptowano warunki"; +/* chat list item title */ +"accepted invitation" = "zaproszenie zaakceptowane"; + +/* rcv group event chat item */ +"accepted you" = "przyjął cię"; + /* No comment provided by engineer. */ "Acknowledged" = "Potwierdzono"; @@ -377,15 +392,15 @@ swipe action */ /* No comment provided by engineer. */ "Active connections" = "Aktywne połączenia"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów."; - /* No comment provided by engineer. */ "Add friends" = "Dodaj znajomych"; /* No comment provided by engineer. */ "Add list" = "Dodaj listę"; +/* placeholder for sending contact request */ +"Add message" = "Dodaj wiadomość"; + /* No comment provided by engineer. */ "Add profile" = "Dodaj profil"; @@ -461,6 +476,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "uzgadnianie szyfrowania…"; +/* member criteria value */ +"all" = "wszystkie"; + /* No comment provided by engineer. */ "All" = "Wszystko"; @@ -485,6 +503,9 @@ swipe action */ /* feature role */ "all members" = "wszyscy członkowie"; +/* No comment provided by engineer. */ +"All messages" = "Wszystkie wiadomości"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich."; @@ -503,6 +524,9 @@ swipe action */ /* No comment provided by engineer. */ "All reports will be archived for you." = "Wszystkie raporty zostaną dla Ciebie zarchiwizowane."; +/* No comment provided by engineer. */ +"All servers" = "Wszystkie serwery"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Wszystkie Twoje kontakty pozostaną połączone."; @@ -527,6 +551,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow downgrade" = "Zezwól na obniżenie wersji"; +/* No comment provided by engineer. */ +"Allow files and media only if your contact allows them." = "Zezwalaj na pliki i media tylko wtedy, gdy Twój kontakt na to pozwala."; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Zezwalaj na nieodwracalne usuwanie wiadomości tylko wtedy, gdy Twój kontakt Ci na to pozwoli. (24 godziny)"; @@ -578,6 +605,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "Zezwól swoim kontaktom na wysyłanie znikających wiadomości."; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "Pozwól kontaktom wysyłać pliki i media."; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "Zezwól swoim kontaktom na wysyłanie wiadomości głosowych."; @@ -611,9 +641,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Odbierz połączenie"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Każdy może hostować serwery."; - /* No comment provided by engineer. */ "App build: %@" = "Kompilacja aplikacji: %@"; @@ -680,6 +707,9 @@ swipe action */ /* No comment provided by engineer. */ "Archived contacts" = "Zarchiwizowane kontakty"; +/* No comment provided by engineer. */ +"archived report" = "zarchiwizowany raport"; + /* No comment provided by engineer. */ "Archiving database" = "Archiwizowanie bazy danych"; @@ -695,6 +725,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Połączenia audio i wideo"; +/* No comment provided by engineer. */ +"Audio call" = "Połączenie audio"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "połączenie audio (nie szyfrowane e2e)"; @@ -749,12 +782,21 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "Zły identyfikator wiadomości"; +/* No comment provided by engineer. */ +"Be free in your network." = "Ciesz się swobodą w swojej sieci."; + +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "Ponieważ zniszczyliśmy moc pozwalającą poznać, kim jesteś. Więc twoja moc nigdy nie będzie Ci odebrana."; + /* No comment provided by engineer. */ "Better calls" = "Lepsze połączenia"; /* No comment provided by engineer. */ "Better groups" = "Lepsze grupy"; +/* No comment provided by engineer. */ +"Better groups performance" = "Lepsze działanie grup"; + /* No comment provided by engineer. */ "Better message dates." = "Lepsze daty wiadomości."; @@ -767,12 +809,21 @@ swipe action */ /* No comment provided by engineer. */ "Better notifications" = "Lepsze powiadomienia"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Lepsza prywatność i bezpieczeństwo"; + /* No comment provided by engineer. */ "Better security ✅" = "Lepsze zabezpieczenia ✅"; /* No comment provided by engineer. */ "Better user experience" = "Lepszy interfejs użytkownika"; +/* No comment provided by engineer. */ +"Bio" = "Bio"; + +/* alert title */ +"Bio too large" = "Bio jest za długie"; + /* No comment provided by engineer. */ "Black" = "Czarny"; @@ -816,6 +867,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "pogrubiona"; +/* No comment provided by engineer. */ +"Bot" = "Bot"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "Zarówno Ty, jak i Twój kontakt możecie dodawać reakcje wiadomości."; @@ -828,18 +882,24 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać pliki i media."; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać wiadomości głosowe."; /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bułgarski, fiński, tajski i ukraiński – dzięki użytkownikom i [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; -/* No comment provided by engineer. */ +/* chat link info line */ "Business address" = "Adres firmowy"; /* No comment provided by engineer. */ "Business chats" = "Czaty biznesowe"; +/* No comment provided by engineer. */ +"Business connection" = "Kontakty biznesowe"; + /* No comment provided by engineer. */ "Businesses" = "Firmy"; @@ -876,6 +936,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't call member" = "Nie można zadzwonić do członka"; +/* alert title */ +"Can't change profile" = "Nie można zmienić profilu"; + /* No comment provided by engineer. */ "Can't invite contact!" = "Nie można zaprosić kontaktu!"; @@ -885,6 +948,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "Nie można wysłać wiadomości do członka"; +/* No comment provided by engineer. */ +"can't send messages" = "nie można wysłać wiadomości"; + /* alert action alert button new chat action */ @@ -914,6 +980,9 @@ new chat action */ /* No comment provided by engineer. */ "Change" = "Zmień"; +/* alert title */ +"Change automatic message deletion?" = "Zmienić automatyczne usuwanie wiadomości?"; + /* authentication reason */ "Change chat profiles" = "Zmień profil czatu"; @@ -1020,9 +1089,22 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć!"; +/* chat feature +chat toolbar */ +"Chat with admins" = "Czatuj z administratorami"; + +/* No comment provided by engineer. */ +"Chat with member" = "Czatuj z członkiem"; + +/* No comment provided by engineer. */ +"Chat with members before they join." = "Porozmawiaj z członkami, zanim dołączą."; + /* No comment provided by engineer. */ "Chats" = "Czaty"; +/* No comment provided by engineer. */ +"Chats with members" = "Czaty z członkami"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Sprawdzaj wiadomości co 20 min."; @@ -1062,6 +1144,12 @@ set passcode view */ /* No comment provided by engineer. */ "Clear conversation?" = "Wyczyścić rozmowę?"; +/* No comment provided by engineer. */ +"Clear group?" = "Wyczyścić grupę?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Wyczyścić lub usunąć grupę?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Wyczyścić prywatne notatki?"; @@ -1077,6 +1165,9 @@ set passcode view */ /* No comment provided by engineer. */ "colored" = "kolorowy"; +/* report reason */ +"Community guidelines violation" = "Naruszenie zasad społeczności"; + /* server test step */ "Compare file" = "Porównaj plik"; @@ -1098,9 +1189,18 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Warunki użytkowania"; +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Warunki zostaną zaakceptowane dla operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Warunki zostaną zaakceptowane w dniu: %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Warunki zostaną automatycznie zaakceptowane dla aktywnych operatorów w dniu: %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Skonfiguruj serwery ICE"; @@ -1134,12 +1234,19 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm upload" = "Potwierdź wgranie"; -/* server test step */ +/* token status text */ +"Confirmed" = "Potwierdzony"; + +/* relay test step +server test step */ "Connect" = "Połącz"; /* No comment provided by engineer. */ "Connect automatically" = "Łącz automatycznie"; +/* No comment provided by engineer. */ +"Connect faster! 🚀" = "Połącz się szybciej! 🚀"; + /* No comment provided by engineer. */ "Connect to desktop" = "Połącz do komputera"; @@ -1224,21 +1331,39 @@ set passcode view */ /* No comment provided by engineer. */ "Connection and servers status." = "Stan połączenia i serwerów."; +/* No comment provided by engineer. */ +"Connection blocked" = "Połączenie zablokowane"; + /* alert title */ "Connection error" = "Błąd połączenia"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Błąd połączenia (UWIERZYTELNIANIE)"; /* chat list item title (it should not be shown */ "connection established" = "połączenie ustanowione"; +/* No comment provided by engineer. */ +"Connection failed" = "Połączenie nie powiodło się"; + +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Połączenie zostało zablokowane przez operatora serwera:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Połączenie nie jest gotowe."; + /* No comment provided by engineer. */ "Connection notifications" = "Powiadomienia o połączeniu"; /* No comment provided by engineer. */ "Connection request sent!" = "Prośba o połączenie wysłana!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Połączenie wymaga renegocjacji szyfrowania."; + +/* No comment provided by engineer. */ +"Connection security" = "Bezpieczeństwo połączenia"; + /* No comment provided by engineer. */ "Connection terminated" = "Połączenie zakończone"; @@ -1263,9 +1388,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "Kontakt już istnieje"; +/* No comment provided by engineer. */ +"contact deleted" = "kontakt usunięty"; + /* No comment provided by engineer. */ "Contact deleted!" = "Kontakt usunięty!"; +/* No comment provided by engineer. */ +"contact disabled" = "kontakt wyłączony"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "kontakt posiada szyfrowanie e2e"; @@ -1284,9 +1415,18 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "Nazwa kontaktu"; +/* No comment provided by engineer. */ +"contact not ready" = "kontakt nie gotowy"; + /* No comment provided by engineer. */ "Contact preferences" = "Preferencje kontaktu"; +/* No comment provided by engineer. */ +"Contact requests from groups" = "Prośby o kontakt od grup"; + +/* No comment provided by engineer. */ +"contact should accept…" = "kontakt powinien zaakceptować…"; + /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "Kontakt zostanie usunięty – nie można tego cofnąć!"; @@ -1296,9 +1436,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Kontakty mogą oznaczać wiadomości do usunięcia; będziesz mógł je zobaczyć."; +/* blocking reason */ +"Content violates conditions of use" = "Treść narusza warunki użytkowania"; + /* No comment provided by engineer. */ "Continue" = "Kontynuuj"; +/* No comment provided by engineer. */ +"Contribute" = "Przyczyń się"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Rozmowa usunięta!"; @@ -1314,11 +1460,11 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Róg"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Poprawić imię na %@?"; /* No comment provided by engineer. */ -"Create" = "Utwórz"; +"Create 1-time link" = "Utwórz jednorazowy link"; /* No comment provided by engineer. */ "Create a group using a random profile." = "Utwórz grupę używając losowego profilu."; @@ -1335,6 +1481,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create link" = "Utwórz link"; +/* No comment provided by engineer. */ +"Create list" = "Utwórz listę"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Utwórz nowy profil w [aplikacji desktopowej](https://simplex.chat/downloads/). 💻"; @@ -1347,6 +1496,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create SimpleX address" = "Utwórz adres SimpleX"; +/* No comment provided by engineer. */ +"Create your address" = "Utwórz swój adres"; + /* No comment provided by engineer. */ "Create your profile" = "Utwórz swój profil"; @@ -1368,6 +1520,9 @@ set passcode view */ /* No comment provided by engineer. */ "creator" = "twórca"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Nie można załadować tekstu dotyczącego aktualnych warunków. Możesz zapoznać się z warunkami, klikając ten link:"; + /* No comment provided by engineer. */ "Current Passcode" = "Aktualny Pin"; @@ -1386,6 +1541,9 @@ set passcode view */ /* No comment provided by engineer. */ "Custom time" = "Niestandardowy czas"; +/* No comment provided by engineer. */ +"Customizable message shape." = "Konfigurowalny kształt wiadomości."; + /* No comment provided by engineer. */ "Customize theme" = "Dostosuj motyw"; @@ -1458,9 +1616,6 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Dostarczenie debugowania"; -/* No comment provided by engineer. */ -"Decentralized" = "Zdecentralizowane"; - /* message decrypt error item */ "Decryption error" = "Błąd odszyfrowania"; @@ -1502,12 +1657,24 @@ swipe action */ /* No comment provided by engineer. */ "Delete and notify contact" = "Usuń i powiadom kontakt"; +/* No comment provided by engineer. */ +"Delete chat" = "Usuń czat"; + +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Usuń wiadomości czatu ze swojego urządzenia."; + /* No comment provided by engineer. */ "Delete chat profile" = "Usuń profil czatu"; /* No comment provided by engineer. */ "Delete chat profile?" = "Usunąć profil czatu?"; +/* alert title */ +"Delete chat with member?" = "Usunąć czat z członkiem?"; + +/* No comment provided by engineer. */ +"Delete chat?" = "Usunąć czat?"; + /* No comment provided by engineer. */ "Delete connection" = "Usuń połączenie"; @@ -1553,13 +1720,23 @@ swipe action */ /* No comment provided by engineer. */ "Delete link?" = "Usunąć link?"; +/* alert title */ +"Delete list?" = "Usunąć listę?"; + /* No comment provided by engineer. */ "Delete member message?" = "Usunąć wiadomość członka?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Usuń wiadomości członków"; + +/* alert title */ +"Delete member messages?" = "Usunąć wiadomości członków?"; + /* No comment provided by engineer. */ "Delete message?" = "Usunąć wiadomość?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Usuń wiadomości"; /* No comment provided by engineer. */ @@ -1571,6 +1748,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete old database?" = "Usunąć starą bazę danych?"; +/* No comment provided by engineer. */ +"Delete or moderate up to 200 messages." = "Usuń lub moderuj do 200 wiadomości."; + /* No comment provided by engineer. */ "Delete pending connection?" = "Usunąć oczekujące połączenie?"; @@ -1580,6 +1760,9 @@ swipe action */ /* server test step */ "Delete queue" = "Usuń kolejkę"; +/* No comment provided by engineer. */ +"Delete report" = "Usuń raport"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Usuń do 20 wiadomości na raz."; @@ -1610,6 +1793,9 @@ swipe action */ /* No comment provided by engineer. */ "Deletion errors" = "Błędy usuwania"; +/* No comment provided by engineer. */ +"Delivered even when Apple drops them." = "Dostarczane nawet wtedy, gdy Apple je wycofa."; + /* No comment provided by engineer. */ "Delivery" = "Dostarczenie"; @@ -1619,9 +1805,15 @@ swipe action */ /* No comment provided by engineer. */ "Delivery receipts!" = "Potwierdzenia dostawy!"; +/* No comment provided by engineer. */ +"Deprecated options" = "Opcje wycofane"; + /* No comment provided by engineer. */ "Description" = "Opis"; +/* alert title */ +"Description too large" = "Opis jest zbyt długi"; + /* No comment provided by engineer. */ "Desktop address" = "Adres komputera"; @@ -1676,12 +1868,21 @@ swipe action */ /* chat feature */ "Direct messages" = "Bezpośrednie wiadomości"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "W tym czacie zabronione jest wysyłanie bezpośrednich wiadomości między członkami."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Bezpośrednie wiadomości między członkami są zabronione w tej grupie."; /* No comment provided by engineer. */ "Disable (keep overrides)" = "Wyłącz (zachowaj nadpisania)"; +/* alert title */ +"Disable automatic message deletion?" = "Wyłączyć automatyczne usuwanie wiadomości?"; + +/* alert button */ +"Disable delete messages" = "Wyłącz usuwanie wiadomości"; + /* No comment provided by engineer. */ "Disable for all" = "Wyłącz dla wszystkich"; @@ -1742,15 +1943,24 @@ swipe action */ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NIE używaj SimpleX do połączeń alarmowych."; +/* No comment provided by engineer. */ +"Documents:" = "Dokumenty:"; + /* No comment provided by engineer. */ "Don't create address" = "Nie twórz adresu"; /* No comment provided by engineer. */ "Don't enable" = "Nie włączaj"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "Nie przegap ważnych wiadomości."; + /* alert action */ "Don't show again" = "Nie pokazuj ponownie"; +/* No comment provided by engineer. */ +"Done" = "Gotowe"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Obniż wersję i otwórz czat"; @@ -1797,6 +2007,9 @@ chat item action */ /* No comment provided by engineer. */ "e2e encrypted" = "zaszyfrowany e2e"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Powiadomienia szyfrowane E2E."; + /* chat item action */ "Edit" = "Edytuj"; @@ -1804,6 +2017,9 @@ chat item action */ "Edit group profile" = "Edytuj profil grupy"; /* No comment provided by engineer. */ +"Empty message!" = "Pusta wiadomość!"; + +/* alert button */ "Enable" = "Włącz"; /* No comment provided by engineer. */ @@ -1815,6 +2031,12 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Włącz dostęp do kamery"; +/* No comment provided by engineer. */ +"Enable disappearing messages by default." = "Włącz domyślnie znikające wiadomości."; + +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Włącz opcję Flux w ustawieniach sieci i serwerów, aby zapewnić lepszą prywatność metadanych."; + /* No comment provided by engineer. */ "Enable for all" = "Włącz dla wszystkich"; @@ -1827,9 +2049,6 @@ chat item action */ /* No comment provided by engineer. */ "Enable lock" = "Włącz blokadę"; -/* No comment provided by engineer. */ -"Enable notifications" = "Włącz powiadomienia"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "Włączyć okresowe powiadomienia?"; @@ -1926,6 +2145,9 @@ chat item action */ /* chat item text */ "encryption re-negotiation required for %@" = "renegocjacja szyfrowania wymagana dla %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Trwa renegocjacja szyfrowania."; + /* No comment provided by engineer. */ "ended" = "zakończona"; @@ -1968,21 +2190,36 @@ chat item action */ /* No comment provided by engineer. */ "error" = "błąd"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Błąd"; /* No comment provided by engineer. */ "Error aborting address change" = "Błąd przerwania zmiany adresu"; +/* alert title */ +"Error accepting conditions" = "Błąd podczas akceptacji warunków"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Błąd przyjmowania prośby o kontakt"; +/* alert title */ +"Error accepting member" = "Błąd podczas akceptacji członka"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Błąd dodawania członka(ów)"; +/* alert title */ +"Error adding server" = "Błąd podczas dodawania serwera"; + +/* No comment provided by engineer. */ +"Error adding short link" = "Błąd dodawania krótkiego linku"; + /* No comment provided by engineer. */ "Error changing address" = "Błąd zmiany adresu"; +/* alert title */ +"Error changing chat profile" = "Błąd zmiany profilu czatu"; + /* No comment provided by engineer. */ "Error changing connection profile" = "Błąd zmiany połączenia profilu"; @@ -1995,9 +2232,15 @@ chat item action */ /* No comment provided by engineer. */ "Error changing to incognito!" = "Błąd zmiany na incognito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Błąd sprawdzania statusu tokenu"; + /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Błąd połączenia z serwerem używanym do odbierania wiadomości z tego połączenia: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Błąd tworzenia adresu"; @@ -2007,6 +2250,9 @@ chat item action */ /* No comment provided by engineer. */ "Error creating group link" = "Błąd tworzenia linku grupy"; +/* alert title */ +"Error creating list" = "Błąd tworzenia listy"; + /* No comment provided by engineer. */ "Error creating member contact" = "Błąd tworzenia kontaktu członka"; @@ -2016,9 +2262,15 @@ chat item action */ /* No comment provided by engineer. */ "Error creating profile!" = "Błąd tworzenia profilu!"; +/* No comment provided by engineer. */ +"Error creating report" = "Błąd tworzenia raportu"; + /* No comment provided by engineer. */ "Error decrypting file" = "Błąd odszyfrowania pliku"; +/* alert title */ +"Error deleting chat" = "Błąd usuwania czatu"; + /* alert title */ "Error deleting chat database" = "Błąd usuwania bazy danych czatu"; @@ -2064,6 +2316,9 @@ chat item action */ /* No comment provided by engineer. */ "Error joining group" = "Błąd dołączenia do grupy"; +/* alert title */ +"Error loading servers" = "Błąd ładowania serwerów"; + /* No comment provided by engineer. */ "Error migrating settings" = "Błąd migracji ustawień"; @@ -2079,12 +2334,24 @@ chat item action */ /* No comment provided by engineer. */ "Error reconnecting servers" = "Błąd ponownego łączenia serwerów"; +/* alert title */ +"Error registering for notifications" = "Błąd rejestracji powiadomień"; + +/* alert title */ +"Error rejecting contact request" = "Błąd odrzucenia prośby o kontakt"; + /* alert title */ "Error removing member" = "Błąd usuwania członka"; +/* alert title */ +"Error reordering lists" = "Błąd ponownego porządkowania list"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Błąd resetowania statystyk"; +/* alert title */ +"Error saving chat list" = "Błąd zapisywania listy czatów"; + /* No comment provided by engineer. */ "Error saving group profile" = "Błąd zapisu profilu grupy"; @@ -2097,6 +2364,9 @@ chat item action */ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Błąd zapisu hasła do pęku kluczy"; +/* alert title */ +"Error saving servers" = "Błąd zapisywania serwerów"; + /* when migrating */ "Error saving settings" = "Błąd zapisywania ustawień"; @@ -2115,6 +2385,9 @@ chat item action */ /* No comment provided by engineer. */ "Error sending message" = "Błąd wysyłania wiadomości"; +/* No comment provided by engineer. */ +"Error setting auto-accept" = "Błąd ustawiania automatycznego akceptowania"; + /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Błąd ustawiania potwierdzeń dostawy!"; @@ -2133,12 +2406,18 @@ chat item action */ /* No comment provided by engineer. */ "Error synchronizing connection" = "Błąd synchronizacji połączenia"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Błąd testowania połączenia z serwerem"; + /* No comment provided by engineer. */ "Error updating group link" = "Błąd aktualizacji linku grupy"; /* No comment provided by engineer. */ "Error updating message" = "Błąd aktualizacji wiadomości"; +/* alert title */ +"Error updating server" = "Błąd aktualizacji serwera"; + /* No comment provided by engineer. */ "Error updating settings" = "Błąd aktualizacji ustawień"; @@ -2159,6 +2438,10 @@ file error text snd error text */ "Error: %@" = "Błąd: %@"; +/* relay test error +server test error */ +"Error: %@." = "Błąd: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Błąd: brak pliku bazy danych"; @@ -2168,6 +2451,9 @@ snd error text */ /* No comment provided by engineer. */ "Errors" = "Błędy"; +/* servers error */ +"Errors in servers configuration." = "Błędy w konfiguracji serwerów."; + /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Nawet po wyłączeniu w rozmowie."; @@ -2180,6 +2466,9 @@ snd error text */ /* No comment provided by engineer. */ "expired" = "wygasły"; +/* token status text */ +"Expired" = "Wygasło"; + /* No comment provided by engineer. */ "Export database" = "Eksportuj bazę danych"; @@ -2198,24 +2487,39 @@ snd error text */ /* No comment provided by engineer. */ "Exporting database archive…" = "Eksportowanie archiwum bazy danych…"; +/* No comment provided by engineer. */ +"failed" = "nieudane"; + /* No comment provided by engineer. */ "Failed to remove passphrase" = "Nie udało się usunąć hasła"; /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Szybko i bez czekania aż nadawca będzie online!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Szybsze usuwanie grup."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Szybsze dołączenie i bardziej niezawodne wiadomości."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Szybsze wysyłanie wiadomości."; + /* swipe action */ "Favorite" = "Ulubione"; +/* No comment provided by engineer. */ +"Favorites" = "Ulubione"; + /* file error alert title */ "File error" = "Błąd pliku"; /* alert message */ "File errors:\n%@" = "Błędy pliku:\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Plik jest zablokowany przez operatora serwera:\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Nie odnaleziono pliku - najprawdopodobniej plik został usunięty lub anulowany."; @@ -2249,6 +2553,9 @@ snd error text */ /* chat feature */ "Files and media" = "Pliki i media"; +/* No comment provided by engineer. */ +"Files and media are prohibited in this chat." = "W tym czacie nie wolno przesyłać plików ani multimediów."; + /* No comment provided by engineer. */ "Files and media are prohibited." = "Pliki i media są zabronione w tej grupie."; @@ -2258,6 +2565,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "Pliki i media zabronione!"; +/* No comment provided by engineer. */ +"Filter" = "Filtr"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtruj nieprzeczytane i ulubione czaty."; @@ -2273,8 +2583,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Szybciej znajduj czaty"; -/* server test error */ -"Fingerprint in server address does not match certificate." = "Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "Odcisk palca w adresie serwera docelowego nie zgadza się z certyfikatem: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "Odcisk palca w adresie serwera przekazującego nie zgadza się z certyfikatem: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "Odcisk palca w adresie serwera nie zgadza się z certyfikatem: %@."; + +/* relay test error +server test error */ +"Fingerprint in server address does not match certificate." = "Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy."; /* No comment provided by engineer. */ "Fix" = "Napraw"; @@ -2294,9 +2614,28 @@ snd error text */ /* No comment provided by engineer. */ "Fix not supported by group member" = "Naprawa nie jest obsługiwana przez członka grupy"; +/* No comment provided by engineer. */ +"For all moderators" = "Dla wszystkich moderatorów"; + +/* servers error +servers warning */ +"For chat profile %@:" = "Dla profilu czatu %@:"; + /* No comment provided by engineer. */ "For console" = "Dla konsoli"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Na przykład, jeśli Twój kontakt odbiera wiadomości za pośrednictwem serwera SimpleX Chat, Twoja aplikacja będzie je dostarczać za pośrednictwem serwera Flux."; + +/* No comment provided by engineer. */ +"For me" = "Dla mnie"; + +/* No comment provided by engineer. */ +"For private routing" = "Dla prywatnego routingu"; + +/* No comment provided by engineer. */ +"For social media" = "Dla mediów społecznościowych"; + /* chat item action */ "Forward" = "Przekaż dalej"; @@ -2312,6 +2651,9 @@ snd error text */ /* alert message */ "Forward messages without files?" = "Przekazać wiadomości bez plików?"; +/* No comment provided by engineer. */ +"Forward up to 20 messages at once." = "Przekaż jednocześnie do 20 wiadomości."; + /* No comment provided by engineer. */ "forwarded" = "przekazane dalej"; @@ -2360,6 +2702,9 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "Jeszcze mniejsze zużycie baterii"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Otrzymuj powiadomienia, gdy ktoś wspomni o Tobie."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF-y i naklejki"; @@ -2369,6 +2714,9 @@ snd error text */ /* message preview */ "Good morning!" = "Dzień dobry!"; +/* shown on group welcome message */ +"group" = "grupa"; + /* No comment provided by engineer. */ "Group" = "Grupa"; @@ -2400,6 +2748,9 @@ snd error text */ "Group invitation is no longer valid, it was removed by sender." = "Zaproszenie do grupy jest już nieważne, zostało usunięte przez nadawcę."; /* No comment provided by engineer. */ +"group is deleted" = "grupa została usunięta"; + +/* chat link info line */ "Group link" = "Link do grupy"; /* No comment provided by engineer. */ @@ -2423,6 +2774,9 @@ snd error text */ /* snd group event chat item */ "group profile updated" = "zaktualizowano profil grupy"; +/* alert message */ +"Group profile was changed. If you save it, the updated profile will be sent to group members." = "Profil grupy został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do członków grupy."; + /* No comment provided by engineer. */ "Group welcome message" = "Wiadomość powitalna grupy"; @@ -2432,9 +2786,15 @@ snd error text */ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Grupa zostanie usunięta dla Ciebie - nie można tego cofnąć!"; +/* No comment provided by engineer. */ +"Groups" = "Grupy"; + /* No comment provided by engineer. */ "Help" = "Pomoc"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Pomóż administratorom moderować ich grupy."; + /* No comment provided by engineer. */ "Hidden" = "Ukryte"; @@ -2465,6 +2825,15 @@ snd error text */ /* time unit */ "hours" = "godziny"; +/* No comment provided by engineer. */ +"How it affects privacy" = "Jak to wpływa na prywatność"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Jak to pomaga chronić prywatność"; + +/* alert button */ +"How it works" = "Jak to działa"; + /* No comment provided by engineer. */ "How SimpleX works" = "Jak działa SimpleX"; @@ -2492,6 +2861,9 @@ snd error text */ /* No comment provided by engineer. */ "If you enter your self-destruct passcode while opening the app:" = "Jeśli wpiszesz swój pin samodestrukcji podczas otwierania aplikacji:"; +/* down migration warning */ +"If you joined or created channels, they will stop working permanently." = "Jeśli dołączyłeś do kanałów lub je utworzyłeś, przestaną one działać na stałe."; + /* No comment provided by engineer. */ "If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Jeśli potrzebujesz użyć czatu teraz, dotknij **Zrób to później** poniżej (zostanie Ci zaproponowana migracja bazy danych po ponownym uruchomieniu aplikacji)."; @@ -2505,10 +2877,10 @@ snd error text */ "Image will be received when your contact is online, please wait or check later!" = "Obraz zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później!"; /* No comment provided by engineer. */ -"Immediately" = "Natychmiast"; +"Images" = "Zdjęcia"; /* No comment provided by engineer. */ -"Immune to spam" = "Odporność na spam i nadużycia"; +"Immediately" = "Natychmiast"; /* No comment provided by engineer. */ "Import" = "Importuj"; @@ -2528,6 +2900,9 @@ snd error text */ /* No comment provided by engineer. */ "Importing archive" = "Importowanie archiwum"; +/* No comment provided by engineer. */ +"Improved delivery, reduced traffic usage.\nMore improvements are coming soon!" = "Ulepszona dostawa, mniejsze zużycie ruchu.\nWkrótce pojawią się kolejne ulepszenia!"; + /* No comment provided by engineer. */ "Improved message delivery" = "Ulepszona dostawa wiadomości"; @@ -2549,6 +2924,12 @@ snd error text */ /* No comment provided by engineer. */ "inactive" = "nieaktywny"; +/* report reason */ +"Inappropriate content" = "Nieodpowiednia treść"; + +/* report reason */ +"Inappropriate profile" = "Nieodpowiedni profil"; + /* No comment provided by engineer. */ "Incognito" = "Incognito"; @@ -2601,7 +2982,7 @@ snd error text */ "Initial role" = "Rola początkowa"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Zainstaluj [SimpleX Chat na terminal](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Zainstaluj SimpleX Chat na terminal"; /* No comment provided by engineer. */ "Instant" = "Natychmiastowo"; @@ -2615,13 +2996,28 @@ snd error text */ /* No comment provided by engineer. */ "Interface colors" = "Kolory interfejsu"; +/* token status text */ +"Invalid" = "Nieprawidłowy"; + +/* token status text */ +"Invalid (bad token)" = "Nieprawidłowy (zły token)"; + +/* token status text */ +"Invalid (expired)" = "Nieważny (wygasły)"; + +/* token status text */ +"Invalid (unregistered)" = "Nieprawidłowy (niezarejestrowany)"; + +/* token status text */ +"Invalid (wrong topic)" = "Nieprawidłowy (niewłaściwy temat)"; + /* invalid chat data */ "invalid chat" = "nieprawidłowy czat"; /* No comment provided by engineer. */ "invalid chat data" = "nieprawidłowe dane czatu"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Nieprawidłowy link połączenia"; /* invalid chat item */ @@ -2636,7 +3032,7 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Nieprawidłowe potwierdzenie migracji"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Nieprawidłowa nazwa!"; /* No comment provided by engineer. */ @@ -2663,9 +3059,15 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Zaproś znajomych"; +/* No comment provided by engineer. */ +"Invite member" = "Zaproś członka"; + /* No comment provided by engineer. */ "Invite members" = "Zaproś członków"; +/* No comment provided by engineer. */ +"Invite to chat" = "Zaproś do czatu"; + /* No comment provided by engineer. */ "Invite to group" = "Zaproś do grupy"; @@ -2756,6 +3158,9 @@ snd error text */ /* alert title */ "Keep unused invitation?" = "Zachować nieużyte zaproszenie?"; +/* No comment provided by engineer. */ +"Keep your chats clean" = "Utrzymuj czystość swoich czatów"; + /* No comment provided by engineer. */ "Keep your connections" = "Zachowaj swoje połączenia"; @@ -2774,6 +3179,12 @@ snd error text */ /* swipe action */ "Leave" = "Opuść"; +/* No comment provided by engineer. */ +"Leave chat" = "Opuść czat"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Opuścić czat?"; + /* No comment provided by engineer. */ "Leave group" = "Opuść grupę"; @@ -2783,6 +3194,9 @@ snd error text */ /* rcv group event chat item */ "left" = "opuścił"; +/* No comment provided by engineer. */ +"Less traffic on mobile networks." = "Mniejszy ruch w sieciach komórkowych."; + /* email subject */ "Let's talk in SimpleX Chat" = "Porozmawiajmy w SimpleX Chat"; @@ -2801,6 +3215,18 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Połączone komputery"; +/* No comment provided by engineer. */ +"Links" = "Linki"; + +/* swipe action */ +"List" = "Lista"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Nazwa listy i emoji powinny być różne dla wszystkich list."; + +/* No comment provided by engineer. */ +"List name..." = "Nazwa listy..."; + /* No comment provided by engineer. */ "LIVE" = "NA ŻYWO"; @@ -2810,6 +3236,9 @@ snd error text */ /* No comment provided by engineer. */ "Live messages" = "Wiadomości na żywo"; +/* in progress text */ +"Loading profile…" = "Ładowanie profilu…"; + /* No comment provided by engineer. */ "Local name" = "Nazwa lokalna"; @@ -2861,30 +3290,60 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Członek"; +/* past/unknown group member */ +"Member %@" = "Członek %@"; + /* profile update event chat item */ "member %@ changed to %@" = "członek %1$@ zmieniony na %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "Przyjmowanie członków"; + /* rcv group event chat item */ "member connected" = "połączony"; +/* No comment provided by engineer. */ +"member has old version" = "członek posiada starą wersję"; + /* item status text */ "Member inactive" = "Członek nieaktywny"; +/* No comment provided by engineer. */ +"Member is deleted - can't accept request" = "Członek został usunięty – nie można zaakceptować prośby"; + +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Wiadomości członków zostaną usunięte – nie można tego cofnąć!"; + +/* chat feature */ +"Member reports" = "Raporty członków"; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Rola członka zostanie zmieniona na \"%@\". Wszyscy członkowie czatu zostaną o tym poinformowani."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Rola członka grupy zostanie zmieniona na \"%@\". Wszyscy członkowie grupy zostaną powiadomieni."; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Rola członka zostanie zmieniona na \"%@\". Członek otrzyma nowe zaproszenie."; -/* No comment provided by engineer. */ +/* alert message */ +"Member will be removed from chat - this cannot be undone!" = "Członek zostanie usunięty z czatu – nie można tego cofnąć!"; + +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Członek zostanie usunięty z grupy - nie można tego cofnąć!"; +/* alert message */ +"Member will join the group, accept member?" = "Członek dołączy do grupy, zaakceptować członka?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "Członkowie grupy mogą dodawać reakcje wiadomości."; /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Członkowie mogą zgłaszać wiadomości moderatorom."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Członkowie grupy mogą wysyłać bezpośrednie wiadomości."; @@ -2900,6 +3359,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can send voice messages." = "Członkowie grupy mogą wysyłać wiadomości głosowe."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Wspomnij członków 👋"; + /* No comment provided by engineer. */ "Menus" = "Menu"; @@ -2921,6 +3383,9 @@ snd error text */ /* item status text */ "Message forwarded" = "Wiadomość przekazana"; +/* No comment provided by engineer. */ +"Message instantly once you tap Connect." = "Wysyłaj wiadomości natychmiast po dotknięciu przycisku „Połącz”."; + /* item status description */ "Message may be delivered later if member becomes active." = "Wiadomość może zostać dostarczona później jeśli członek stanie się aktywny."; @@ -2969,9 +3434,15 @@ snd error text */ /* No comment provided by engineer. */ "Messages & files" = "Wiadomości i pliki"; +/* No comment provided by engineer. */ +"Messages are protected by **end-to-end encryption**." = "Wiadomości są chronione przez **szyfrowanie typu end-to-end**."; + /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Wiadomości od %@ zostaną pokazane!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Wiadomości na tym czacie nigdy nie zostaną usunięte."; + /* No comment provided by engineer. */ "Messages received" = "Otrzymane wiadomości"; @@ -2990,9 +3461,6 @@ snd error text */ /* No comment provided by engineer. */ "Migrate device" = "Zmigruj urządzenie"; -/* No comment provided by engineer. */ -"Migrate from another device" = "Zmigruj z innego urządzenia"; - /* No comment provided by engineer. */ "Migrate here" = "Zmigruj tutaj"; @@ -3044,15 +3512,24 @@ snd error text */ /* marked deleted chat item preview text */ "moderated by %@" = "moderowany przez %@"; +/* member role */ +"moderator" = "moderator"; + /* time unit */ "months" = "miesiące"; +/* swipe action */ +"More" = "Więcej"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Więcej ulepszeń już wkrótce!"; /* No comment provided by engineer. */ "More reliable network connection." = "Bardziej niezawodne połączenia sieciowe."; +/* No comment provided by engineer. */ +"More reliable notifications" = "Bardziej niezawodne powiadomienia"; + /* item status description */ "Most likely this connection is deleted." = "Najprawdopodobniej to połączenie jest usunięte."; @@ -3062,6 +3539,9 @@ snd error text */ /* notification label action */ "Mute" = "Wycisz"; +/* notification label action */ +"Mute all" = "Wycisz wszystko"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Wyciszony, gdy jest nieaktywny!"; @@ -3074,6 +3554,9 @@ snd error text */ /* No comment provided by engineer. */ "Network connection" = "Połączenie z siecią"; +/* No comment provided by engineer. */ +"Network decentralization" = "Decentralizacja sieci"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Błąd sieciowy - wiadomość wygasła po wielu próbach wysłania jej."; @@ -3081,14 +3564,20 @@ snd error text */ "Network management" = "Zarządzenie sieciowe"; /* No comment provided by engineer. */ -"Network settings" = "Ustawienia sieci"; +"Network operator" = "Operator sieci"; /* No comment provided by engineer. */ +"Network settings" = "Ustawienia sieci"; + +/* alert title */ "Network status" = "Status sieci"; /* delete after time */ "never" = "nigdy"; +/* token status text */ +"New" = "Nowy"; + /* No comment provided by engineer. */ "New chat" = "Nowy czat"; @@ -3107,6 +3596,12 @@ snd error text */ /* No comment provided by engineer. */ "New display name" = "Nowa wyświetlana nazwa"; +/* notification */ +"New events" = "Nowe wydarzenia"; + +/* No comment provided by engineer. */ +"New group role: Moderator" = "Nowa rola w grupie: Moderator"; + /* No comment provided by engineer. */ "New in %@" = "Nowość w %@"; @@ -3116,6 +3611,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "Nowa rola członka"; +/* rcv group event chat item */ +"New member wants to join the group." = "Nowy członek chce dołączyć do grupy."; + /* notification */ "new message" = "nowa wiadomość"; @@ -3128,6 +3626,9 @@ snd error text */ /* No comment provided by engineer. */ "New passphrase…" = "Nowe hasło…"; +/* No comment provided by engineer. */ +"New server" = "Nowy serwer"; + /* No comment provided by engineer. */ "New SOCKS credentials will be used every time you start the app." = "Nowe poświadczenia SOCKS będą używane przy każdym uruchomieniu aplikacji."; @@ -3143,6 +3644,18 @@ snd error text */ /* Authentication unavailable */ "No app password" = "Brak hasła aplikacji"; +/* No comment provided by engineer. */ +"No chats" = "Żadnych czatów"; + +/* No comment provided by engineer. */ +"No chats found" = "Nie znaleziono żadnych czatów"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Brak czatów na liście %@"; + +/* No comment provided by engineer. */ +"No chats with members" = "Żadnych rozmów z członkami"; + /* No comment provided by engineer. */ "No contacts selected" = "Nie wybrano kontaktów"; @@ -3173,6 +3686,15 @@ snd error text */ /* No comment provided by engineer. */ "No info, try to reload" = "Brak informacji, spróbuj przeładować"; +/* servers error */ +"No media & file servers." = "Brak mediów i serwerów plików multimedialnych."; + +/* No comment provided by engineer. */ +"No message" = "Brak wiadomości"; + +/* servers error */ +"No message servers." = "Brak serwerów wiadomości."; + /* No comment provided by engineer. */ "No network connection" = "Brak połączenia z siecią"; @@ -3185,21 +3707,54 @@ snd error text */ /* No comment provided by engineer. */ "No permission to record voice message" = "Brak uprawnień do nagrywania wiadomości głosowej"; +/* alert title */ +"No private routing session" = "Brak prywatnej sesji routingu"; + /* No comment provided by engineer. */ "No push server" = "Lokalnie"; /* No comment provided by engineer. */ "No received or sent files" = "Brak odebranych lub wysłanych plików"; +/* servers error */ +"No servers for private message routing." = "Brak serwerów prywatnej sesji routingu."; + +/* servers error */ +"No servers to receive files." = "Brak serwerów do otrzymania plików."; + +/* servers error */ +"No servers to receive messages." = "Brak serwerów aby otrzymać wiadomości."; + +/* servers error */ +"No servers to send files." = "Brak serwerów do wysyłania plików."; + +/* No comment provided by engineer. */ +"no subscription" = "brak subskrypcji"; + /* copied message info in history */ "no text" = "brak tekstu"; +/* alert title */ +"No token!" = "Brak tokenu!"; + /* No comment provided by engineer. */ -"No user identifiers." = "Brak identyfikatorów użytkownika."; +"No unread chats" = "Brak nieprzeczytanych czatów"; + +/* No comment provided by engineer. */ +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Nikt nie śledził twoich rozmów. Nikt nie rysował mapy miejsc, w których byłeś. Prywatność nigdy nie była funkcją - była sposobem na życie."; + +/* No comment provided by engineer. */ +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Nie chodzi o lepszy zamek w drzwiach kogoś innego. Nie chodzi o milszego właściciela, który szanuje twoją prywatność, ale nadal prowadzi rejestr wszystkich odwiedzających. Nie jesteś gościem. Jesteś w domu. Żaden król nie może do niego wejść - jesteś suwerenem."; /* No comment provided by engineer. */ "Not compatible!" = "Nie kompatybilny!"; +/* No comment provided by engineer. */ +"not synchronized" = "nie zsynchronizowano"; + +/* No comment provided by engineer. */ +"Notes" = "Notatki"; + /* No comment provided by engineer. */ "Nothing selected" = "Nic nie jest zaznaczone"; @@ -3212,6 +3767,15 @@ snd error text */ /* No comment provided by engineer. */ "Notifications are disabled!" = "Powiadomienia są wyłączone!"; +/* alert title */ +"Notifications error" = "Błąd powiadomień"; + +/* No comment provided by engineer. */ +"Notifications privacy" = "Prywatność powiadomień"; + +/* alert title */ +"Notifications status" = "Stan powiadomień"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Teraz administratorzy mogą:\n- usuwać wiadomości członków.\n- wyłączyć członków (rola \"obserwatora\")"; @@ -3238,7 +3802,7 @@ alert button new chat action */ "Ok" = "Ok"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "OK"; /* No comment provided by engineer. */ @@ -3259,6 +3823,9 @@ new chat action */ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Hosty onion nie będą używane."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Tylko właściciele czatu mogą zmieniać preferencje."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Tylko urządzenia klienckie przechowują profile użytkowników, kontakty, grupy i wiadomości wysyłane za pomocą **2-warstwowego szyfrowania end-to-end**."; @@ -3274,6 +3841,12 @@ new chat action */ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Tylko właściciele grup mogą włączyć wiadomości głosowe."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Widzą to tylko nadawca i moderatorzy"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Widzisz to tylko Ty i moderatorzy"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Tylko Ty możesz dodawać reakcje wiadomości."; @@ -3286,6 +3859,9 @@ new chat action */ /* No comment provided by engineer. */ "Only you can send disappearing messages." = "Tylko Ty możesz wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Only you can send files and media." = "Tylko Ty możesz wysyłać pliki i multimedia."; + /* No comment provided by engineer. */ "Only you can send voice messages." = "Tylko Ty możesz wysyłać wiadomości głosowe."; @@ -3301,30 +3877,76 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send disappearing messages." = "Tylko Twój kontakt może wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Only your contact can send files and media." = "Tylko Twój kontakt może wysyłać pliki i multimedia."; + /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Tylko Twój kontakt może wysyłać wiadomości głosowe."; -/* alert action */ +/* alert action +alert button */ "Open" = "Otwórz"; +/* No comment provided by engineer. */ +"Open changes" = "Otwórz zmiany"; + /* new chat action */ "Open chat" = "Otwórz czat"; /* authentication reason */ "Open chat console" = "Otwórz konsolę czatu"; +/* alert action */ +"Open clean link" = "Otwórz czysty link"; + +/* No comment provided by engineer. */ +"Open conditions" = "Otwórz warunki"; + +/* alert action */ +"Open full link" = "Otwórz pełny link"; + /* new chat action */ "Open group" = "Grupa otwarta"; +/* alert title */ +"Open link?" = "Otworzyć link?"; + /* authentication reason */ "Open migration to another device" = "Otwórz migrację na innym urządzeniu"; +/* new chat action */ +"Open new chat" = "Otwórz nowy czat"; + +/* new chat action */ +"Open new group" = "Otwórz nową grupę"; + /* No comment provided by engineer. */ "Open Settings" = "Otwórz Ustawienia"; +/* No comment provided by engineer. */ +"Open to accept" = "Otwórz by zaakceptować"; + +/* No comment provided by engineer. */ +"Open to connect" = "Otwórz aby się połączyć"; + +/* No comment provided by engineer. */ +"Open to join" = "Otwórz aby dołączyć"; + +/* No comment provided by engineer. */ +"Open to use bot" = "Otwórz aby skorzystać z bota"; + /* No comment provided by engineer. */ "Opening app…" = "Otwieranie aplikacji…"; +/* No comment provided by engineer. */ +"Operator" = "Operator"; + +/* alert title */ +"Operator server" = "Serwer Operatora"; + +/* No comment provided by engineer. */ +"Or import archive file" = "Lub zaimportuj plik archiwalny"; + /* No comment provided by engineer. */ "Or paste archive link" = "Lub wklej link archiwum"; @@ -3337,6 +3959,12 @@ new chat action */ /* No comment provided by engineer. */ "Or show this code" = "Lub pokaż ten kod"; +/* No comment provided by engineer. */ +"Or to share privately" = "Lub udostępnij prywatnie"; + +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organizuj czaty jako listy"; + /* No comment provided by engineer. */ "other" = "inne"; @@ -3391,9 +4019,18 @@ new chat action */ /* No comment provided by engineer. */ "peer-to-peer" = "peer-to-peer"; +/* No comment provided by engineer. */ +"pending" = "oczekuje"; + /* No comment provided by engineer. */ "Pending" = "Oczekujące"; +/* No comment provided by engineer. */ +"pending approval" = "oczekuje na zatwierdzenie"; + +/* No comment provided by engineer. */ +"pending review" = "oczekuje na ocenę"; + /* No comment provided by engineer. */ "Periodic" = "Okresowo"; @@ -3460,6 +4097,18 @@ new chat action */ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Przechowuj kod dostępu w bezpieczny sposób, w przypadku jego utraty NIE będzie można go zmienić."; +/* token info */ +"Please try to disable and re-enable notfications." = "Spróbuj wyłączyć, a następnie ponownie włączyć powiadomienia."; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy."; + +/* token info */ +"Please wait for token activation to complete." = "Proszę poczekać na zakończenie aktywacji tokenu."; + +/* token info */ +"Please wait for token to be registered." = "Proszę poczekać na zarejestrowanie tokenu."; + /* No comment provided by engineer. */ "Polish interface" = "Polski interfejs"; @@ -3472,6 +4121,9 @@ new chat action */ /* No comment provided by engineer. */ "Preset server address" = "Wstępnie ustawiony adres serwera"; +/* No comment provided by engineer. */ +"Preset servers" = "Domyślne serwery"; + /* No comment provided by engineer. */ "Preview" = "Podgląd"; @@ -3482,11 +4134,17 @@ new chat action */ "Privacy & security" = "Prywatność i bezpieczeństwo"; /* No comment provided by engineer. */ -"Privacy redefined" = "Redefinicja prywatności"; +"Privacy for your customers." = "Prywatność dla Twoich klientów."; + +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Polityka prywatności i warunki korzystania."; /* No comment provided by engineer. */ "Private filenames" = "Prywatne nazwy plików"; +/* No comment provided by engineer. */ +"Private media file names." = "Nazwy prywatnych plików multimedialnych."; + /* No comment provided by engineer. */ "Private message routing" = "Trasowanie prywatnych wiadomości"; @@ -3502,6 +4160,9 @@ new chat action */ /* alert title */ "Private routing error" = "Błąd prywatnego trasowania"; +/* alert title */ +"Private routing timeout" = "Limit czasu routingu prywatnego"; + /* No comment provided by engineer. */ "Profile and server connections" = "Profil i połączenia z serwerem"; @@ -3517,9 +4178,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile theme" = "Motyw profilu"; -/* alert message */ -"Profile update will be sent to your contacts." = "Aktualizacja profilu zostanie wysłana do Twoich kontaktów."; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Zabroń połączeń audio/wideo."; @@ -3532,6 +4190,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Zabroń reakcje wiadomości."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Zabroń raportowania wiadomości moderatorom."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Zabroń wysyłania bezpośrednich wiadomości do członków."; @@ -3559,6 +4220,9 @@ new chat action */ /* No comment provided by engineer. */ "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Chroni Twój adres IP przed przekaźnikami wiadomości wybranych przez Twoje kontakty.\nWłącz w ustawianiach *Sieć i serwery* ."; +/* No comment provided by engineer. */ +"Protocol background timeout" = "Limit czasu protokołu w tle"; + /* No comment provided by engineer. */ "Protocol timeout" = "Limit czasu protokołu"; @@ -3602,16 +4266,10 @@ new chat action */ "Read more" = "Przeczytaj więcej"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Przeczytaj więcej w [Poradniku Użytkownika](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Przeczytaj więcej na naszym repozytorium GitHub."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Przeczytaj więcej w [Podręczniku Użytkownika](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Przeczytaj więcej w [Podręczniku Użytkownika](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Przeczytaj więcej na naszym [repozytorium GitHub](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Przeczytaj więcej w Poradniku Użytkownika."; /* No comment provided by engineer. */ "Receipts are disabled" = "Potwierdzenia są wyłączone"; @@ -3631,9 +4289,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "otrzymano potwierdzenie…"; -/* notification */ -"Received file event" = "Otrzymano zdarzenie pliku"; - /* message info title */ "Received message" = "Otrzymano wiadomość"; @@ -3694,6 +4349,15 @@ new chat action */ /* No comment provided by engineer. */ "Reduced battery usage" = "Zmniejszone zużycie baterii"; +/* No comment provided by engineer. */ +"Register" = "Zarejestruj"; + +/* token info */ +"Register notification token?" = "Zarejestrować token powiadomień?"; + +/* token status text */ +"Registered" = "Zarejestrowany"; + /* alert action reject incoming call via notification swipe action */ @@ -3705,6 +4369,12 @@ swipe action */ /* alert title */ "Reject contact request" = "Odrzuć prośbę kontaktu"; +/* alert title */ +"Reject member?" = "Odrzucić członka?"; + +/* No comment provided by engineer. */ +"rejected" = "odrzucono"; + /* call status */ "rejected call" = "odrzucone połączenie"; @@ -3714,9 +4384,12 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Serwer przekaźnikowy chroni Twój adres IP, ale może obserwować czas trwania połączenia."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Usuń"; +/* alert action */ +"Remove and delete messages" = "Usuń i skasuj wiadomości"; + /* No comment provided by engineer. */ "Remove archive?" = "Usunąć archiwum?"; @@ -3724,9 +4397,12 @@ swipe action */ "Remove image" = "Usuń obraz"; /* No comment provided by engineer. */ -"Remove member" = "Usuń członka"; +"Remove link tracking" = "Usuń śledzenie linków"; /* No comment provided by engineer. */ +"Remove member" = "Usuń członka"; + +/* alert title */ "Remove member?" = "Usunąć członka?"; /* No comment provided by engineer. */ @@ -3741,12 +4417,18 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "usunięto adres kontaktu"; +/* No comment provided by engineer. */ +"removed from group" = "usunięty z grupy"; + /* profile update event chat item */ "removed profile picture" = "usunięto zdjęcie profilu"; /* rcv group event chat item */ "removed you" = "usunął cię"; +/* No comment provided by engineer. */ +"Removes messages and blocks members." = "Usuwa wiadomości i blokuje członków."; + /* No comment provided by engineer. */ "Renegotiate" = "Renegocjuj"; @@ -3768,6 +4450,54 @@ swipe action */ /* chat item action */ "Reply" = "Odpowiedz"; +/* chat item action */ +"Report" = "Zgłoś"; + +/* report reason */ +"Report content: only group moderators will see it." = "Zgłoś treść: zobaczą ją tylko moderatorzy grupy."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy."; + +/* report reason */ +"Report other: only group moderators will see it." = "Zgłoś inne: zobaczą to tylko moderatorzy grupy."; + +/* No comment provided by engineer. */ +"Report reason?" = "Jaki jest powód zgłoszenia?"; + +/* alert title */ +"Report sent to moderators" = "Zgłoszenia wysłane do moderatorów"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Zgłoś spam: tylko moderatorzy grupy będą to widzieć."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy."; + +/* report in notification */ +"Report: %@" = "Zgłoszenie: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Zgłaszanie wiadomości moderatorom jest zabronione."; + +/* No comment provided by engineer. */ +"Reports" = "Zgłoszenia"; + +/* No comment provided by engineer. */ +"request is sent" = "prośba została wysłana"; + +/* No comment provided by engineer. */ +"request to join rejected" = "prośba o dołączenie została odrzucona"; + +/* rcv group event chat item */ +"requested connection" = "prośba o połączenie"; + +/* rcv direct event chat item */ +"requested connection from group %@" = "prośba o połączenie od grupy %@"; + +/* chat list item title */ +"requested to connect" = "poproszono o połączenie"; + /* No comment provided by engineer. */ "Required" = "Wymagane"; @@ -3819,6 +4549,24 @@ swipe action */ /* chat item action */ "Reveal" = "Ujawnij"; +/* No comment provided by engineer. */ +"review" = "ocena"; + +/* No comment provided by engineer. */ +"Review conditions" = "Przejrzyj warunki"; + +/* No comment provided by engineer. */ +"Review group members" = "Przejrzyj członków grupy"; + +/* admission stage */ +"Review members" = "Przejrzyj członków"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Przejrzyj członków przed dopuszczeniem (\"zapukaj\")."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "sprawdzone przez administratorów"; + /* No comment provided by engineer. */ "Revoke" = "Odwołaj"; @@ -3847,6 +4595,12 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "Zapisz (i powiadom kontakty)"; +/* alert button */ +"Save (and notify members)" = "Zapisz (i powiadom członków)"; + +/* alert title */ +"Save admission settings?" = "Zapisać ustawienia wstępu?"; + /* alert button */ "Save and notify contact" = "Zapisz i powiadom kontakt"; @@ -3862,6 +4616,12 @@ chat item action */ /* No comment provided by engineer. */ "Save group profile" = "Zapisz profil grupy"; +/* alert title */ +"Save group profile?" = "Zapisać profil grupy?"; + +/* No comment provided by engineer. */ +"Save list" = "Zapisz listę"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Zapisz hasło i otwórz czat"; @@ -3937,9 +4697,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "Pasek wyszukiwania akceptuje linki zaproszenia."; +/* No comment provided by engineer. */ +"Search files" = "Szukaj plików"; + +/* No comment provided by engineer. */ +"Search images" = "Szukaj zdjęć"; + +/* No comment provided by engineer. */ +"Search links" = "Szukaj linków"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Wyszukaj lub wklej link SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Szukaj wideo"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Szukaj wiadomości głosowych"; + /* network option */ "sec" = "sek"; @@ -3997,6 +4772,9 @@ chat item action */ /* No comment provided by engineer. */ "Send a live message - it will update for the recipient(s) as you type it" = "Wysyłaj wiadomości na żywo - będą one aktualizowane dla odbiorcy(ów) w trakcie ich wpisywania"; +/* No comment provided by engineer. */ +"Send contact request?" = "Wysłać prośbę o kontakt?"; + /* No comment provided by engineer. */ "Send delivery receipts to" = "Wyślij potwierdzenia dostawy do"; @@ -4027,18 +4805,30 @@ chat item action */ /* No comment provided by engineer. */ "Send notifications" = "Wyślij powiadomienia"; +/* No comment provided by engineer. */ +"Send private reports" = "Wyślij prywatne zgłoszenia"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Wyślij pytania i pomysły"; /* No comment provided by engineer. */ "Send receipts" = "Wyślij potwierdzenia"; +/* No comment provided by engineer. */ +"Send request" = "Wyślij prośbę"; + +/* No comment provided by engineer. */ +"Send request without message" = "Wyślij prośbę bez wiadomości"; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Wyślij je z galerii lub niestandardowych klawiatur."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Wysyłaj do 100 ostatnich wiadomości do nowych członków."; +/* No comment provided by engineer. */ +"Send your private feedback to groups." = "Wyślij swoją prywatną opinię do grup."; + /* alert message */ "Sender cancelled file transfer." = "Nadawca anulował transfer pliku."; @@ -4078,9 +4868,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Wysłano bezpośrednio"; -/* notification */ -"Sent file event" = "Wyślij zdarzenie pliku"; - /* message info title */ "Sent message" = "Wyślij wiadomość"; @@ -4102,6 +4889,9 @@ chat item action */ /* No comment provided by engineer. */ "Server" = "Serwer"; +/* alert message */ +"Server added to operator %@." = "Serwer został dodany do operatora %@."; + /* No comment provided by engineer. */ "Server address" = "Adres serwera"; @@ -4111,14 +4901,23 @@ chat item action */ /* srv error text. */ "Server address is incompatible with network settings." = "Adres serwera jest niekompatybilny z ustawieniami sieciowymi."; +/* alert title */ +"Server operator changed." = "Operator serwera został zmieniony."; + +/* No comment provided by engineer. */ +"Server operators" = "Operatorzy serwera"; + +/* alert title */ +"Server protocol changed." = "Protokół serwera zmieniony."; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "Informacje kolejki serwera: %1$@\n\nostatnia otrzymana wiadomość: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło"; +"Server requires authorization to create queues, check password." = "Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło."; /* server test error */ -"Server requires authorization to upload, check password." = "Serwer wymaga autoryzacji do przesłania, sprawdź hasło"; +"Server requires authorization to upload, check password." = "Serwer wymaga autoryzacji do przesłania, sprawdź hasło."; /* No comment provided by engineer. */ "Server test failed!" = "Test serwera nie powiódł się!"; @@ -4147,6 +4946,9 @@ chat item action */ /* No comment provided by engineer. */ "Set 1 day" = "Ustaw 1 dzień"; +/* No comment provided by engineer. */ +"Set chat name…" = "Ustaw nazwę czatu…"; + /* No comment provided by engineer. */ "Set contact name…" = "Ustaw nazwę kontaktu…"; @@ -4159,6 +4961,12 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Ustaw go zamiast uwierzytelniania systemowego."; +/* No comment provided by engineer. */ +"Set member admission" = "Ustaw przyjmowanie członków"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Ustaw datę wygaśnięcia wiadomości na czatach."; + /* profile update event chat item */ "set new contact address" = "ustaw nowy adres kontaktu"; @@ -4174,6 +4982,9 @@ chat item action */ /* No comment provided by engineer. */ "Set passphrase to export" = "Ustaw hasło do eksportu"; +/* No comment provided by engineer. */ +"Set profile bio and welcome message." = "Ustaw biografię profilu i wiadomość powitalną."; + /* No comment provided by engineer. */ "Set the message shown to new members!" = "Ustaw wiadomość wyświetlaną nowym członkom!"; @@ -4196,11 +5007,14 @@ chat item action */ /* No comment provided by engineer. */ "Share 1-time link" = "Udostępnij 1-razowy link"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "Udostępnij jednorazowy link znajomemu"; + /* No comment provided by engineer. */ "Share address" = "Udostępnij adres"; -/* alert title */ -"Share address with contacts?" = "Udostępnić adres kontaktom?"; +/* No comment provided by engineer. */ +"Share address publicly" = "Udostępnij adres publicznie"; /* No comment provided by engineer. */ "Share from other apps." = "Udostępnij z innych aplikacji."; @@ -4208,9 +5022,18 @@ chat item action */ /* No comment provided by engineer. */ "Share link" = "Udostępnij link"; +/* alert button */ +"Share old address" = "Udostępnij stary adres"; + +/* alert button */ +"Share old link" = "Udostępnij stary link"; + /* No comment provided by engineer. */ "Share profile" = "Udostępnij profil"; +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "Udostępnij adres SimpleX w mediach społecznościowych."; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "Udostępnij ten jednorazowy link"; @@ -4218,7 +5041,16 @@ chat item action */ "Share to SimpleX" = "Udostępnij do SimpleX"; /* No comment provided by engineer. */ -"Share with contacts" = "Udostępnij kontaktom"; +"Share your address" = "Udostępnij swój adres"; + +/* No comment provided by engineer. */ +"Short description" = "Krótki opis"; + +/* No comment provided by engineer. */ +"Short link" = "Krótki link"; + +/* No comment provided by engineer. */ +"Short SimpleX address" = "Krótki adres SimpleX"; /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Pokaż → na wiadomościach wysłanych przez prywatne trasowanie."; @@ -4256,9 +5088,21 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX Address" = "Adres SimpleX"; +/* No comment provided by engineer. */ +"SimpleX address and 1-time links are safe to share via any messenger." = "Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator."; + +/* No comment provided by engineer. */ +"SimpleX address or 1-time link?" = "Adres SimpleX czy link jednorazowy?"; + /* alert title */ "SimpleX address settings" = "Ustawienia automatycznej akceptacji"; +/* simplex link type */ +"SimpleX channel link" = "Link do kanału na SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "Bezpieczeństwo SimpleX Chat zostało zaudytowane przez Trail of Bits."; @@ -4295,6 +5139,9 @@ chat item action */ /* simplex link type */ "SimpleX one-time invitation" = "Zaproszenie jednorazowe SimpleX"; +/* No comment provided by engineer. */ +"SimpleX protocols reviewed by Trail of Bits." = "Protokoły SimpleX sprawdzone przez Trail of Bits."; + /* No comment provided by engineer. */ "Simplified incognito mode" = "Uproszczony tryb incognito"; @@ -4331,15 +5178,25 @@ chat item action */ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "Podczas importu wystąpiły niekrytyczne błędy:"; +/* alert message */ +"Some servers failed the test:\n%@" = "Niektóre serwery nie przeszły testu:\n%@"; + /* notification title */ "Somebody" = "Ktoś"; +/* blocking reason +report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Kwadrat, okrąg lub cokolwiek pomiędzy."; /* chat item text */ "standard end-to-end encryption" = "standardowe szyfrowanie end-to-end"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Daj gwiazdkę na GitHub"; + /* No comment provided by engineer. */ "Start chat" = "Rozpocznij czat"; @@ -4391,6 +5248,9 @@ chat item action */ /* No comment provided by engineer. */ "Stopping chat" = "Zatrzymywanie czatu"; +/* No comment provided by engineer. */ +"Storage" = "Magazyn"; + /* No comment provided by engineer. */ "strike" = "strajk"; @@ -4412,6 +5272,12 @@ chat item action */ /* No comment provided by engineer. */ "Support SimpleX Chat" = "Wspieraj SimpleX Chat"; +/* No comment provided by engineer. */ +"Switch audio and video during the call." = "Przełączanie audio i wideo podczas połączenia."; + +/* No comment provided by engineer. */ +"Switch chat profile for 1-time invitations." = "Przełącz profil czatu dla zaproszeń jednorazowych."; + /* No comment provided by engineer. */ "System" = "System"; @@ -4427,6 +5293,18 @@ chat item action */ /* No comment provided by engineer. */ "Tap button " = "Naciśnij przycisk "; +/* No comment provided by engineer. */ +"Tap Connect to chat" = "Dotknij Połącz aby rozpocząć czat"; + +/* No comment provided by engineer. */ +"Tap Connect to send request" = "Dotknij Połącz, aby wysłać prośbę"; + +/* No comment provided by engineer. */ +"Tap Connect to use bot" = "Dotknij Połącz aby użyć bota"; + +/* No comment provided by engineer. */ +"Tap Join group" = "Dotknij Dołącz do grupy"; + /* No comment provided by engineer. */ "Tap to activate profile." = "Dotknij, aby aktywować profil."; @@ -4448,9 +5326,15 @@ chat item action */ /* No comment provided by engineer. */ "TCP connection" = "Połączenie TCP"; +/* No comment provided by engineer. */ +"TCP connection bg timeout" = "Przekroczono limit czasu połączenia TCP"; + /* No comment provided by engineer. */ "TCP connection timeout" = "Limit czasu połączenia TCP"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "Port TCP dla wiadomości"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4463,9 +5347,13 @@ chat item action */ /* file error alert title */ "Temporary file error" = "Tymczasowy błąd pliku"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Test nie powiódł się na etapie %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Powiadomienia testowe"; + /* No comment provided by engineer. */ "Test server" = "Przetestuj serwer"; @@ -4484,9 +5372,15 @@ chat item action */ /* No comment provided by engineer. */ "Thanks to the users – contribute via Weblate!" = "Podziękowania dla użytkowników - wkład za pośrednictwem Weblate!"; +/* alert message */ +"The address will be short, and your profile will be shared via the address." = "Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu."; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Aplikacja może powiadamiać Cię, gdy otrzymujesz wiadomości lub prośby o kontakt — otwórz ustawienia, aby włączyć."; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "Aplikacja zapyta o potwierdzenie pobierania od nieznanych serwerów plików (poza .onion)."; @@ -4496,6 +5390,9 @@ chat item action */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Kod, który zeskanowałeś nie jest kodem QR linku SimpleX."; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline."; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "Zaakceptowane przez Ciebie połączenie zostanie anulowane!"; @@ -4508,15 +5405,15 @@ chat item action */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Szyfrowanie działa, a nowe uzgodnienie szyfrowania nie jest wymagane. Może to spowodować błędy w połączeniu!"; -/* No comment provided by engineer. */ -"The future of messaging" = "Następna generacja prywatnych wiadomości"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Hash poprzedniej wiadomości jest inny."; /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Identyfikator następnej wiadomości jest nieprawidłowy (mniejszy lub równy poprzedniej).\nMoże się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skompromitowane."; +/* alert message */ +"The link will be short, and group profile will be shared via the link." = "Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link."; + /* No comment provided by engineer. */ "The message will be deleted for all members." = "Wiadomość zostanie usunięta dla wszystkich członków."; @@ -4532,6 +5429,15 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Stara baza danych nie została usunięta podczas migracji, można ją usunąć."; +/* No comment provided by engineer. */ +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "Najstarsza ludzka wolność - możliwość rozmowy z inną osobą bez bycia obserwowanym - opiera się na infrastrukturze, która nie może jej zdradzić."; + +/* No comment provided by engineer. */ +"The same conditions will apply to operator **%@**." = "Te same warunki będą miały zastosowanie do operatora **%@**."; + +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "Drugi predefiniowany operator w aplikacji!"; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Drugi tik, który przegapiliśmy! ✅"; @@ -4541,6 +5447,9 @@ chat item action */ /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "Serwery dla nowych połączeń bieżącego profilu czatu **%@**."; +/* No comment provided by engineer. */ +"The servers for new files of your current chat profile **%@**." = "Serwery dla nowych plików Twojego bieżącego profilu czatu **%@**."; + /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "Tekst, który wkleiłeś nie jest linkiem SimpleX."; @@ -4550,6 +5459,15 @@ chat item action */ /* No comment provided by engineer. */ "Themes" = "Motywy"; +/* No comment provided by engineer. */ +"Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "Następnie przenieśliśmy się do sieci, a każda platforma prosiła o podanie danych osobowych - imienia i nazwiska, numeru telefonu, znajomych. Zaakceptowaliśmy fakt, że ceną za możliwość komunikowania się z innymi jest ujawnienie komuś, z kim rozmawiamy. Tak było w przypadku każdego pokolenia, ludzi i technologii - telefonu, poczty elektronicznej, komunikatorów, mediów społecznościowych. Wydawało się to jedyną możliwą opcją."; + +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "Jest jeszcze inny sposób. Sieć bez numerów telefonów. Bez nazw użytkowników. Bez kont. Bez jakichkolwiek tożsamości użytkowników. Sieć, która łączy ludzi i przesyła zaszyfrowane wiadomości, nie wiedząc, kto jest podłączony."; + +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "Warunki te będą miały również zastosowanie w przypadku: **%@**."; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "Te ustawienia dotyczą Twojego bieżącego profilu **%@**."; @@ -4562,6 +5480,9 @@ chat item action */ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Tego działania nie można cofnąć - wiadomości wysłane i odebrane wcześniej niż wybrane zostaną usunięte. Może to potrwać kilka minut."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Tego działania nie można cofnąć - Twój profil, kontakty, wiadomości i pliki zostaną nieodwracalnie utracone."; @@ -4586,12 +5507,24 @@ chat item action */ /* No comment provided by engineer. */ "This group no longer exists." = "Ta grupa już nie istnieje."; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Ta wiadomość została usunięta lub jeszcze nie otrzymana."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**."; +/* No comment provided by engineer. */ +"This setting is for your current profile **%@**." = "To ustawienie jest dla Twojego obecnego profilu **%@**."; + +/* No comment provided by engineer. */ +"Time to disappear is set only for new contacts." = "Czas zniknięcia jest ustawiony tylko dla nowych kontaktów."; + /* No comment provided by engineer. */ "Title" = "Tytuł"; @@ -4607,6 +5540,9 @@ chat item action */ /* No comment provided by engineer. */ "To make a new connection" = "Aby nawiązać nowe połączenie"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu."; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "Aby chronić strefę czasową, pliki obrazów/głosów używają UTC."; @@ -4619,6 +5555,9 @@ chat item action */ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Aby chronić prywatność, zamiast identyfikatorów użytkowników używanych przez wszystkie inne platformy, SimpleX ma identyfikatory dla kolejek wiadomości, oddzielne dla każdego z Twoich kontaktów."; +/* No comment provided by engineer. */ +"To receive" = "Żeby odebrać"; + /* No comment provided by engineer. */ "To record speech please grant permission to use Microphone." = "Aby nagrać rozmowę, proszę zezwolić na użycie Mikrofonu."; @@ -4631,18 +5570,30 @@ chat item action */ /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Aby ujawnić Twój ukryty profil, wprowadź pełne hasło w pole wyszukiwania na stronie **Twoich profili czatu**."; +/* No comment provided by engineer. */ +"To send" = "Żeby wysłać"; + +/* alert message */ +"To send commands you must be connected." = "Aby wysyłać polecenia, musisz być podłączony."; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "Aby obsługiwać natychmiastowe powiadomienia push, należy zmigrować bazę danych czatu."; +/* alert message */ +"To use another profile after connection attempt, delete the chat and use the link again." = "Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie."; + +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "Aby korzystać z serwerów **%@**, należy zaakceptować warunki użytkowania."; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach."; -/* No comment provided by engineer. */ -"Toggle chat list:" = "Przełącz listę czatów:"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Przełącz incognito przy połączeniu."; +/* token status */ +"Token status: %@." = "Stan tokena: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Nieprzezroczystość paska narzędzi"; @@ -4655,11 +5606,8 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "Sesje transportowe"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia."; /* No comment provided by engineer. */ "Turkish interface" = "Turecki interfejs"; @@ -4691,6 +5639,9 @@ chat item action */ /* rcv group event chat item */ "unblocked %@" = "odblokowano %@"; +/* No comment provided by engineer. */ +"Undelivered messages" = "Niedostarczone wiadomości"; + /* No comment provided by engineer. */ "Unexpected migration state" = "Nieoczekiwany stan migracji"; @@ -4757,6 +5708,9 @@ chat item action */ /* swipe action */ "Unread" = "Nieprzeczytane"; +/* conn error description */ +"Unsupported connection link" = "Nieobsługiwane łącze połączenia"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Do nowych członków wysyłanych jest do 100 ostatnich wiadomości."; @@ -4772,6 +5726,9 @@ chat item action */ /* No comment provided by engineer. */ "Update settings?" = "Zaktualizować ustawienia?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Zaktualizowane warunki"; + /* rcv group event chat item */ "updated group profile" = "zaktualizowano profil grupy"; @@ -4781,9 +5738,27 @@ chat item action */ /* No comment provided by engineer. */ "Updating settings will re-connect the client to all servers." = "Aktualizacja ustawień spowoduje ponowne połączenie klienta ze wszystkimi serwerami."; +/* alert button */ +"Upgrade" = "Zaktualizuj"; + +/* No comment provided by engineer. */ +"Upgrade address" = "Uaktualnij adres"; + +/* alert message */ +"Upgrade address?" = "Uaktualnić adres?"; + /* No comment provided by engineer. */ "Upgrade and open chat" = "Zaktualizuj i otwórz czat"; +/* alert message */ +"Upgrade group link?" = "Uaktualnić link do grupy?"; + +/* No comment provided by engineer. */ +"Upgrade link" = "Uaktualnij link"; + +/* No comment provided by engineer. */ +"Upgrade your address" = "Zaktualizuj swój adres"; + /* No comment provided by engineer. */ "Upload errors" = "Błędy przesłania"; @@ -4806,17 +5781,26 @@ chat item action */ "Use .onion hosts" = "Użyj hostów .onion"; /* No comment provided by engineer. */ -"Use chat" = "Użyj czatu"; +"Use %@" = "Użyj %@"; /* new chat action */ "Use current profile" = "Użyj obecnego profilu"; +/* No comment provided by engineer. */ +"Use for files" = "Użyj dla plików"; + +/* No comment provided by engineer. */ +"Use for messages" = "Użyj dla wiadomości"; + /* No comment provided by engineer. */ "Use for new connections" = "Użyj dla nowych połączeń"; /* No comment provided by engineer. */ "Use from desktop" = "Użyj z komputera"; +/* No comment provided by engineer. */ +"Use incognito profile" = "Użyj profilu incognito"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Użyj interfejsu połączeń iOS"; @@ -4835,18 +5819,30 @@ chat item action */ /* No comment provided by engineer. */ "Use server" = "Użyj serwera"; +/* No comment provided by engineer. */ +"Use servers" = "Użyj serwerów"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Użyć serwerów SimpleX Chat?"; /* No comment provided by engineer. */ "Use SOCKS proxy" = "Użyj proxy SOCKS"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Jeśli nie podano portu, należy użyć portu TCP %@."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Używaj portu TCP 443 tylko dla domyślnych serwerów."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Używaj aplikacji podczas połączenia."; /* No comment provided by engineer. */ "Use the app with one hand." = "Korzystaj z aplikacji jedną ręką."; +/* No comment provided by engineer. */ +"Use web port" = "Użyj portu internetowego"; + /* No comment provided by engineer. */ "User selection" = "Wybór użytkownika"; @@ -4916,12 +5912,21 @@ chat item action */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Film zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później!"; +/* No comment provided by engineer. */ +"Videos" = "Wideo"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Filmy i pliki do 1gb"; +/* No comment provided by engineer. */ +"View conditions" = "Zobacz warunki"; + /* No comment provided by engineer. */ "View security code" = "Pokaż kod bezpieczeństwa"; +/* No comment provided by engineer. */ +"View updated conditions" = "Zobacz zaktualizowane warunki"; + /* chat feature */ "Visible history" = "Widoczna historia"; @@ -4991,6 +5996,9 @@ chat item action */ /* No comment provided by engineer. */ "Welcome message is too long" = "Wiadomość powitalna jest zbyt długa"; +/* No comment provided by engineer. */ +"Welcome your contacts 👋" = "Powitaj swoje kontakty 👋"; + /* No comment provided by engineer. */ "What's new" = "Co nowego"; @@ -5003,6 +6011,9 @@ chat item action */ /* No comment provided by engineer. */ "when IP hidden" = "gdy IP ukryty"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje."; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Gdy udostępnisz komuś profil incognito, będzie on używany w grupach, do których Cię zaprosi."; @@ -5057,6 +6068,9 @@ chat item action */ /* No comment provided by engineer. */ "You accepted connection" = "Zaakceptowałeś połączenie"; +/* snd group event chat item */ +"you accepted this member" = "zaakceptowałeś tego członka"; + /* No comment provided by engineer. */ "You allow" = "Pozwalasz"; @@ -5066,6 +6080,9 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Jesteś już połączony z %@."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Zostałeś już połączony z %@."; + /* new chat sheet message */ "You are already connecting to %@." = "Już się łączysz z %@."; @@ -5084,12 +6101,15 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Już dołączasz do grupy!\nPowtórzyć prośbę dołączenia?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Jesteś połączony z serwerem służącym do odbierania wiadomości z tego połączenia."; /* No comment provided by engineer. */ "You are invited to group" = "Jesteś zaproszony do grupy"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Nie masz połączenia z serwerem służącym do odbierania wiadomości w ramach tego połączenia (brak subskrypcji)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości."; @@ -5105,6 +6125,9 @@ chat item action */ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Możesz to zmienić w ustawieniach wyglądu."; +/* No comment provided by engineer. */ +"You can configure servers via settings." = "Serwery można skonfigurować w ustawieniach."; + /* No comment provided by engineer. */ "You can create it later" = "Możesz go utworzyć później"; @@ -5129,6 +6152,9 @@ chat item action */ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Możesz wysyłać wiadomości do %@ ze zarchiwizowanych kontaktów."; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony."; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Podgląd powiadomień na ekranie blokady można ustawić w ustawieniach."; @@ -5153,6 +6179,9 @@ chat item action */ /* alert message */ "You can view invitation link again in connection details." = "Możesz zobaczyć link zaproszenia ponownie w szczegółach połączenia."; +/* alert message */ +"You can view your reports in Chat with admins." = "Możesz przeglądać swoje raporty w czacie z administratorami."; + /* alert title */ "You can't send messages!" = "Nie możesz wysyłać wiadomości!"; @@ -5171,9 +6200,6 @@ chat item action */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "Nie można zweryfikować użytkownika; proszę spróbować ponownie."; -/* No comment provided by engineer. */ -"You decide who can connect." = "Ty decydujesz, kto może się połączyć."; - /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Już prosiłeś o połączenie!\nPowtórzyć prośbę połączenia?"; @@ -5222,9 +6248,18 @@ chat item action */ /* chat list item description */ "you shared one-time link incognito" = "udostępniłeś jednorazowy link incognito"; +/* token info */ +"You should receive notifications." = "Powinieneś otrzymywać powiadomienia."; + /* snd group event chat item */ "you unblocked %@" = "odblokowałeś %@"; +/* No comment provided by engineer. */ +"You were born without an account" = "Urodziłeś się bez konta."; + +/* No comment provided by engineer. */ +"You will be able to send messages **only after your request is accepted**." = "Będziesz mógł wysyłać wiadomości **dopiero po zaakceptowaniu Twojej prośby**."; + /* No comment provided by engineer. */ "You will be connected to group when the group host's device is online, please wait or check later!" = "Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później!"; @@ -5243,6 +6278,9 @@ chat item action */ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Nadal będziesz otrzymywać połączenia i powiadomienia z wyciszonych profili, gdy są one aktywne."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Przestaniesz otrzymywać wiadomości od tej grupy. Historia czatu zostanie zachowana."; @@ -5258,6 +6296,9 @@ chat item action */ /* No comment provided by engineer. */ "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Używasz profilu incognito dla tej grupy - aby zapobiec udostępnianiu głównego profilu zapraszanie kontaktów jest zabronione"; +/* No comment provided by engineer. */ +"Your business contact" = "Twój kontakt biznesowy"; + /* No comment provided by engineer. */ "Your calls" = "Twoje połączenia"; @@ -5273,9 +6314,15 @@ chat item action */ /* No comment provided by engineer. */ "Your chat profiles" = "Twoje profile czatu"; +/* alert message */ +"Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile." = "Twoja rozmowa została przeniesiona do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd."; + /* No comment provided by engineer. */ "Your connection was moved to %@ but an error happened when switching profile." = "Twoje połączenie zostało przeniesione do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd."; +/* No comment provided by engineer. */ +"Your contact" = "Twój kontakt"; + /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "Twój kontakt wysłał plik, który jest większy niż obecnie obsługiwany maksymalny rozmiar (%@)."; @@ -5285,6 +6332,9 @@ chat item action */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "Twoje kontakty pozostaną połączone."; +/* No comment provided by engineer. */ +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "Twoje rozmowy należą do Ciebie, tak jak zawsze było przed pojawieniem się Internetu. Sieć nie jest miejscem, które odwiedzasz. Jest miejscem, które tworzysz i które należy do Ciebie. Nikt nie może Ci tego odebrać, niezależnie od tego, czy jest to miejsce prywatne, czy publiczne."; + /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "Twoje poświadczenia mogą zostać wysłane niezaszyfrowane."; @@ -5294,6 +6344,9 @@ chat item action */ /* No comment provided by engineer. */ "Your current profile" = "Twój obecny profil"; +/* No comment provided by engineer. */ +"Your group" = "Twoja grupa"; + /* No comment provided by engineer. */ "Your ICE servers" = "Twoje serwery ICE"; diff --git a/apps/ios/product/README.md b/apps/ios/product/README.md new file mode 100644 index 0000000000..107c0e6569 --- /dev/null +++ b/apps/ios/product/README.md @@ -0,0 +1,258 @@ +# SimpleX Chat iOS -- Product Overview + +> SimpleX Chat iOS product specification. Bidirectional code links: product docs reference source files, source files reference product docs. +> +> **Related spec:** [spec/README.md](../spec/README.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Vision](#vision) +2. [Target Users](#target-users) +3. [Capability Map](#capability-map) +4. [Navigation Map](#navigation-map) +5. [Related Specifications](#related-specifications) + +## Executive Summary + +SimpleX Chat is the first messaging platform with no user identifiers of any kind -- not even random numbers. It provides end-to-end encrypted messaging (with optional post-quantum cryptography), audio/video calls, file sharing, and group communication through a fully decentralized architecture where users control their own SMP relay servers. The iOS app is a native SwiftUI application backed by a Haskell core library. + +--- + +## Vision + +SimpleX Chat is the first messaging platform that has no user identifiers -- not even random numbers. It uses double-ratchet end-to-end encryption with optional post-quantum cryptography. The system is fully decentralized with user-controlled SMP relay servers. + +The protocol design ensures that no server or network observer can determine who communicates with whom. Each conversation uses separate unidirectional messaging queues on potentially different servers, and there is no shared identifier between the sender and receiver queues. + +--- + +## Target Users + +- **Privacy-conscious individuals** wanting secure messaging without phone-number or email-based identity +- **Groups and communities** needing encrypted group communication with role-based access control +- **Users avoiding identity linkage** who want to communicate without any persistent user identifier +- **Organizations** needing self-hosted messaging infrastructure with full control over relay servers + +--- + +## Capability Map + +### 1. Messaging + +Core message composition, delivery, and interaction features. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Text with markdown | Rich text formatting with SimpleX markdown syntax | `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | +| Images | Compressed inline images (up to 255KB) | `Shared/Views/Chat/ChatItem/CIImageView.swift` | +| Video | Video message recording and playback | `Shared/Views/Chat/ChatItem/CIVideoView.swift` | +| Voice messages | Audio recording and playback (5min / 510KB limit) | `Shared/Views/Chat/ChatItem/CIVoiceView.swift` | +| File sharing | Files up to 1GB via XFTP protocol | `Shared/Views/Chat/ChatItem/CIFileView.swift` | +| Link previews | OpenGraph metadata extraction and display | `Shared/Views/Chat/ChatItem/CILinkView.swift` | +| Message reactions | Emoji reactions on sent/received messages | `Shared/Views/Chat/ChatItem/EmojiItemView.swift` | +| Message editing | Edit previously sent messages | `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | +| Message deletion | Broadcast delete (for recipient) or internal-only delete | `Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift` | +| Timed messages | Self-destructing messages with configurable TTL | `Shared/Views/Chat/ChatItem/CIChatFeatureView.swift` | +| Quoted replies | Reply to specific messages with quote context | `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` | +| Forwarding | Forward messages between chats | `Shared/Views/Chat/ChatItemForwardingView.swift` | +| Search | Full-text search within conversations | `Shared/Views/Chat/ChatView.swift` | +| Message reports | Report messages to group moderators | `Shared/Views/Chat/ChatView.swift` | + +### 2. Contacts + +Establishing, managing, and verifying contacts. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Add via SimpleX address | Connect using a SimpleX contact address | `Shared/Views/NewChat/NewChatView.swift` | +| Add via QR code | Scan QR code to establish connection | `Shared/Views/Chat/ScanCodeView.swift` | +| Contact requests | Accept or reject incoming contact requests | `Shared/Views/ChatList/ContactRequestView.swift` | +| Local aliases | Set private display names for contacts | `Shared/Views/Chat/ChatInfoView.swift` | +| Contact verification | Compare security codes out-of-band | `Shared/Views/Chat/VerifyCodeView.swift` | +| Blocking | Block contacts from sending messages | `Shared/Views/Chat/ChatInfoView.swift` | +| Incognito mode | Per-contact random profile generation | `Shared/Views/UserSettings/IncognitoHelp.swift` | +| Bot detection | Identify automated/bot contacts | `SimpleXChat/ChatTypes.swift` | + +### 3. Groups + +Multi-party encrypted conversations with role-based management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Create groups | Create new group with initial members | `Shared/Views/NewChat/AddGroupView.swift` | +| Invite members | Invite by individual contact or link | `Shared/Views/Chat/Group/AddGroupMembersView.swift` | +| Member roles | Owner, admin, moderator, member, observer | `SimpleXChat/ChatTypes.swift` | +| Member admission | Queue-based admission with review workflow | `Shared/Views/Chat/Group/MemberAdmissionView.swift` | +| Group links | Shareable invite links for groups | `Shared/Views/Chat/Group/GroupLinkView.swift` | +| Business chat mode | Structured business communication groups | `Shared/Views/Chat/Group/GroupChatInfoView.swift` | +| Content moderation | Member reports and moderator actions | `Shared/Views/Chat/Group/MemberSupportView.swift` | +| Group preferences | Configure group-level feature settings | `Shared/Views/Chat/Group/GroupPreferencesView.swift` | +| Member direct contacts | Establish direct chats from group membership | `Shared/Views/Chat/Group/GroupMemberInfoView.swift` | + +### 4. Calling + +End-to-end encrypted audio and video communication. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| E2E encrypted calls | Audio/video calls via WebRTC with E2E encryption | `Shared/Views/Call/WebRTCClient.swift` | +| CallKit integration | Native iOS system call UI (ring, answer, decline) | `Shared/Views/Call/CallController.swift` | +| Audio device switching | Switch between speaker, earpiece, Bluetooth | `Shared/Views/Call/CallAudioDeviceManager.swift` | +| Call history | Call events displayed as chat items | `Shared/Views/Chat/ChatItem/CICallItemView.swift` | +| Incoming call view | Dedicated UI for incoming call notifications | `Shared/Views/Call/IncomingCallView.swift` | + +### 5. Privacy & Security + +Encryption, authentication, and privacy controls. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| E2E encryption | Double-ratchet encryption for all messages | `SimpleXChat/API.swift` | +| Post-quantum encryption | Optional PQ key exchange for direct chats | `SimpleXChat/ChatTypes.swift` | +| Local authentication | Face ID, Touch ID, or app passcode lock | `Shared/Views/LocalAuth/LocalAuthView.swift` | +| Hidden profiles | Password-protected profiles invisible in UI | `Shared/Views/UserSettings/HiddenProfileView.swift` | +| Database encryption | AES encryption of local SQLite database | `Shared/Views/Database/DatabaseEncryptionView.swift` | +| Screen privacy | Blur app content when in app switcher | `Shared/Views/UserSettings/PrivacySettings.swift` | +| Encrypted file storage | Local files encrypted at rest | `SimpleXChat/CryptoFile.swift` | +| Delivery receipts control | Toggle delivery/read receipts per contact/group | `Shared/Views/UserSettings/SetDeliveryReceiptsView.swift` | + +### 6. User Management + +Multiple profiles and identity management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Multiple profiles | Multiple user profiles within one app | `Shared/Views/UserSettings/UserProfilesView.swift` | +| Active user switching | Switch between profiles via user picker | `Shared/Views/ChatList/UserPicker.swift` | +| Incognito contacts | Per-contact random identities | `Shared/Views/UserSettings/IncognitoHelp.swift` | +| Profile sharing | Share profile via contact address link | `Shared/Views/UserSettings/UserAddressView.swift` | +| User muting | Mute notifications for specific profiles | `Shared/Views/ChatList/UserPicker.swift` | + +### 7. Network + +Server configuration, proxy support, and connectivity. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Custom SMP servers | Configure personal SMP relay servers | `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` | +| Custom XFTP servers | Configure personal XFTP file servers | `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` | +| Tor/onion support | Route traffic through Tor .onion addresses | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | +| SOCKS5 proxy | Route connections through SOCKS5 proxy | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | +| Custom ICE servers | Configure WebRTC ICE/TURN servers | `Shared/Views/UserSettings/RTCServers.swift` | +| Network timeouts | Configure connection timeout parameters | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | + +### 8. Customization + +Visual appearance and UI preferences. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Themes | Light, dark, SimpleX, black, and custom themes | `Shared/Theme/ThemeManager.swift` | +| Wallpapers | Preset and custom chat wallpapers | `Shared/Views/Helpers/ChatWallpaper.swift` | +| Chat bubble styling | Customize message bubble appearance | `SimpleXChat/Theme/ThemeTypes.swift` | +| One-handed UI mode | Compact layout for single-hand use | `Shared/Views/ChatList/OneHandUICard.swift` | +| Language selection | In-app language override | `Shared/Views/UserSettings/AppearanceSettings.swift` | + +### 9. Data Management + +Import, export, encryption, and storage management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Export/import profiles | Full database export and import | `Shared/Views/Database/DatabaseView.swift` | +| Database encryption | Encrypt/decrypt local database with passphrase | `Shared/Views/Database/DatabaseEncryptionView.swift` | +| Local file encryption | Encrypt stored media and attachments | `SimpleXChat/CryptoFile.swift` | +| Storage breakdown | View storage usage by category | `Shared/Views/UserSettings/StorageView.swift` | +| Device-to-device migration | Migrate full profile between iOS devices | `Shared/Views/Migration/MigrateFromDevice.swift` | + +### 10. Desktop Integration + +Remote control of the mobile app from a desktop client. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Remote control pairing | Pair with desktop app via QR code | `Shared/Views/RemoteAccess/ConnectDesktopView.swift` | +| Session management | Manage active desktop control sessions | `Shared/Views/RemoteAccess/ConnectDesktopView.swift` | + +--- + +## Navigation Map + +``` +Onboarding + OnboardingView.swift + -> SimpleXInfo -> CreateProfile -> ChooseServerOperators -> SetNotificationsMode -> CreateSimpleXAddress + -> ChatListView (home) + +ChatListView (home) + Shared/Views/ChatList/ChatListView.swift + -> ChatView .................. (tap conversation row) + -> NewChatMenuButton ......... (+ button) + -> SettingsView .............. (gear icon) + -> UserPicker ................ (avatar tap) + -> TagListView ............... (tag filter bar) + -> ServersSummaryView ........ (server status) + +ChatView + Shared/Views/Chat/ChatView.swift + -> ChatInfoView .............. (contact name tap, direct chat) + -> GroupChatInfoView ......... (group name tap, group chat) + -> ActiveCallView ............ (call button) + -> ComposeView ............... (message input area) + -> ChatItemInfoView .......... (long press -> info) + -> ChatItemForwardingView .... (long press -> forward) + -> SecondaryChatView ......... (member support thread) + +ChatInfoView + Shared/Views/Chat/ChatInfoView.swift + -> ContactPreferencesView .... (preferences) + -> VerifyCodeView ............ (verify security code) + +GroupChatInfoView + Shared/Views/Chat/Group/GroupChatInfoView.swift + -> GroupProfileView .......... (edit profile) + -> AddGroupMembersView ....... (invite members) + -> GroupLinkView ............. (manage group link) + -> MemberAdmissionView ....... (admission settings) + -> GroupPreferencesView ...... (group feature settings) + -> GroupMemberInfoView ....... (tap member) + -> GroupWelcomeView .......... (welcome message) + +NewChatMenuButton + Shared/Views/NewChat/NewChatMenuButton.swift + -> NewChatView ............... (QR scanner / paste link) + -> AddGroupView .............. (create group) + -> UserAddressView ........... (create SimpleX address) + +SettingsView + Shared/Views/UserSettings/SettingsView.swift + -> AppearanceSettings ........ (themes, wallpapers, UI) + -> NetworkAndServers ......... (SMP/XFTP/proxy config) + -> PrivacySettings ........... (privacy toggles) + -> NotificationsView ......... (push notification mode) + -> DatabaseView .............. (export/import/encrypt) + -> CallSettings .............. (call preferences) + -> StorageView ............... (storage usage) + -> VersionView ............... (about/version) + -> DeveloperView ............. (developer options) + +UserPicker + Shared/Views/ChatList/UserPicker.swift + -> UserProfilesView .......... (manage all profiles) + -> UserAddressView ........... (SimpleX address) + -> PreferencesView ........... (user preferences) + -> SettingsView .............. (app settings) + -> ConnectDesktopView ........ (pair with desktop) +``` + +--- + +## Related Specifications + +- [concepts.md](concepts.md) -- Feature concept index with bidirectional code links +- [glossary.md](glossary.md) -- Domain term glossary +- [spec/README.md](../spec/README.md) -- Technical specification overview +- [spec/architecture.md](../spec/architecture.md) -- Architecture specification +- Haskell core: `../../src/Simplex/Chat/Controller.hs`, `../../src/Simplex/Chat/Types.hs` +- Swift model: `Shared/Model/ChatModel.swift`, `SimpleXChat/ChatTypes.swift` +- Swift API bridge: `SimpleXChat/API.swift`, `Shared/Model/SimpleXAPI.swift` diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md new file mode 100644 index 0000000000..6d63ee2faf --- /dev/null +++ b/apps/ios/product/concepts.md @@ -0,0 +1,84 @@ +# SimpleX Chat iOS -- Concept Index + +> SimpleX Chat iOS concept index. Maps every product concept to its documentation and source code with bidirectional links. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/state.md](../spec/state.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Feature Concepts](#section-1-feature-concepts) +2. [Entity Index](#section-2-entity-index) + +## Executive Summary + +This document provides a structured mapping between product-level concepts, their documentation, and their implementation in both the Swift iOS layer and the Haskell core library. All source paths are relative to `apps/ios/` for Swift and use `../../src/` prefix for Haskell files (relative to `apps/ios/`). + +--- + +## Section 1: Feature Concepts + +| # | Concept | Product Docs | Spec Docs | Source Files (Swift) | Source Files (Haskell) | +|---|---------|-------------|-----------|---------------------|----------------------| +| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift` | `Controller.hs` (`APIGetChats`) | +| 2 | Direct Chat | [views/chat.md](views/chat.md), [flows/messaging.md](flows/messaging.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `ChatInfoView.swift` | `Types.hs` (`Contact`), `Messages.hs` | +| 3 | Group Chat | [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `Group/GroupChatInfoView.swift` | `Types.hs` (`GroupInfo`, `GroupMember`) | +| 4 | Message Composition | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `SendMessageView.swift` | `Controller.hs` (`APISendMessages`) | +| 5 | Message Reactions | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/EmojiItemView.swift` | `Controller.hs` (`APIChatItemReaction`) | +| 6 | Message Editing | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `ChatItemInfoView.swift` | `Controller.hs` (`APIUpdateChatItem`) | +| 7 | Message Deletion | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/MarkedDeletedItemView.swift`, `DeletedItemView.swift` | `Controller.hs` (`APIDeleteChatItem`) | +| 8 | Timed Messages | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/CIChatFeatureView.swift` | `Types/Preferences.hs` (`TimedMessagesPreference`) | +| 9 | Voice Messages | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ChatItem/CIVoiceView.swift`, `ComposeVoiceView.swift` | `Protocol.hs` (`MCVoice`) | +| 10 | File Transfer | [flows/file-transfer.md](flows/file-transfer.md) | [spec/services/files.md](../spec/services/files.md) | `ChatItem/CIFileView.swift`, `SimpleXChat/FileUtils.swift` | `Files.hs`, `Store/Files.hs` | +| 11 | Link Previews | [views/chat.md](views/chat.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `ChatItem/CILinkView.swift`, `ComposeLinkView.swift` | `Protocol.hs` (`MCLink`) | +| 12 | Contact Connection | [flows/connection.md](flows/connection.md), [views/new-chat.md](views/new-chat.md) | [spec/api.md](../spec/api.md) | `NewChat/NewChatView.swift`, `QRCode.swift` | `Controller.hs` (`APIConnect`, `APIAddContact`) | +| 13 | Contact Verification | [views/contact-info.md](views/contact-info.md) | [spec/api.md](../spec/api.md) | `Shared/Views/Chat/VerifyCodeView.swift` | `Controller.hs` (`APIVerifyContact`) | +| 14 | Group Management | [flows/group-lifecycle.md](flows/group-lifecycle.md) | [spec/api.md](../spec/api.md), [spec/database.md](../spec/database.md) | `NewChat/AddGroupView.swift`, `Group/GroupChatInfoView.swift` | `Controller.hs` (`APINewGroup`), `Store/Groups.hs` | +| 15 | Group Links | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/GroupLinkView.swift` | `Controller.hs` (`APICreateGroupLink`) | +| 16 | Member Roles | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `SimpleXChat/ChatTypes.swift`, `Group/GroupMemberInfoView.swift` | `Types/Shared.hs` (`GroupMemberRole`) | +| 17 | Audio/Video Calls | [views/call.md](views/call.md), [flows/calling.md](flows/calling.md) | [spec/services/calls.md](../spec/services/calls.md) | `Call/ActiveCallView.swift`, `CallController.swift`, `WebRTCClient.swift` | `Call.hs` (`RcvCallInvitation`, `CallType`) | +| 18 | Push Notifications | [views/settings.md](views/settings.md) | [spec/services/notifications.md](../spec/services/notifications.md) | `Model/NtfManager.swift`, `SimpleX NSE/NotificationService.swift` | `Controller.hs` | +| 19 | User Profiles | [views/user-profiles.md](views/user-profiles.md) | [spec/state.md](../spec/state.md), [spec/client/navigation.md](../spec/client/navigation.md) | `UserSettings/UserProfilesView.swift`, `ChatList/UserPicker.swift` | `Types.hs` (`User`), `Store/Profiles.hs` | +| 20 | Incognito Mode | [views/contact-info.md](views/contact-info.md) | [spec/api.md](../spec/api.md) | `UserSettings/IncognitoHelp.swift` | `ProfileGenerator.hs`, `Types.hs` | +| 21 | Hidden Profiles | [views/user-profiles.md](views/user-profiles.md) | [spec/api.md](../spec/api.md) | `UserSettings/HiddenProfileView.swift` | `Controller.hs` (`APIHideUser`, `APIUnhideUser`) | +| 22 | Local Authentication | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `LocalAuth/LocalAuthView.swift`, `PasscodeView.swift` | N/A (iOS-only) | +| 23 | Database Encryption | [views/settings.md](views/settings.md) | [spec/database.md](../spec/database.md) | `Database/DatabaseEncryptionView.swift`, `DatabaseView.swift` | `Controller.hs` (`APIExportArchive`) | +| 24 | Theme System | [views/settings.md](views/settings.md) | [spec/services/theme.md](../spec/services/theme.md) | `Theme/ThemeManager.swift`, `SimpleXChat/Theme/ThemeTypes.swift` | `Types/UITheme.hs` | +| 25 | Network Configuration | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `NetworkAndServers/NetworkAndServers.swift`, `ProtocolServersView.swift` | `Controller.hs` (`APISetNetworkConfig`) | +| 26 | Device Migration | [flows/onboarding.md](flows/onboarding.md) | [spec/database.md](../spec/database.md) | `Migration/MigrateFromDevice.swift`, `MigrateToDevice.swift` | `Archive.hs` | +| 27 | Remote Desktop | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `RemoteAccess/ConnectDesktopView.swift` | `Remote.hs`, `Remote/Types.hs` | +| 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` | +| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) | +| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | +| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus` incl. `.rsRejected`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `GroupMemberStatus.memRejected`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/GroupMemberInfoView.swift` (rejected-status row), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Views/NewChat/AddChannelView.swift` (`relayStatusIndicator` rejected branch), `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | + +--- + +## Section 2: Entity Index + +Core data entities, their storage, and the operations that manage their lifecycle. + +| Entity | DB Table (Haskell) | Created By | Read By | Mutated By | Deleted By | +|--------|-------------------|------------|---------|------------|------------| +| **User** | `users` | `CreateActiveUser` in `Controller.hs` | `ListUsers`, `APISetActiveUser` in `Controller.hs` | `APISetActiveUser`, `APIHideUser`, `APIUnhideUser`, `APIMuteUser`, `APIUpdateProfile` in `Controller.hs` | `APIDeleteUser` in `Controller.hs`; `Store/Profiles.hs` | +| **Contact** | `contacts`, `contact_profiles` | `APIAddContact`, `APIConnect` in `Controller.hs` | `APIGetChat` in `Controller.hs`; `Store/Direct.hs` (getContact) | `APISetContactAlias`, `APISetConnectionAlias` in `Controller.hs`; `Store/Direct.hs` | `APIDeleteChat` in `Controller.hs`; `Store/Direct.hs` (deleteContact) | +| **GroupInfo** | `groups`, `group_profiles` | `APINewGroup` in `Controller.hs`; `Store/Groups.hs` (createNewGroup) | `APIGetChat`, `APIGroupInfo` in `Controller.hs`; `Store/Groups.hs` | `APIUpdateGroupProfile` in `Controller.hs`; `Store/Groups.hs` (updateGroupProfile) | `APIDeleteChat` in `Controller.hs`; `Store/Groups.hs` (deleteGroup) | +| **GroupMember** | `group_members`, `contact_profiles` | `APIAddMember`, `APIJoinGroup` in `Controller.hs`; `Store/Groups.hs` (createNewGroupMember) | `APIListMembers` in `Controller.hs`; `Store/Groups.hs` (getGroupMembers) | `APIMembersRole` in `Controller.hs`; `Store/Groups.hs` (updateGroupMemberRole) | `APIRemoveMembers` in `Controller.hs`; `Store/Groups.hs` (deleteGroupMember) | +| **ChatItem** | `chat_items`, `chat_item_versions` | `APISendMessages` in `Controller.hs`; `Store/Messages.hs` (createNewChatItem) | `APIGetChat`, `APIGetChatItems` in `Controller.hs`; `Store/Messages.hs` (getChatItems) | `APIUpdateChatItem`, `APIChatItemReaction` in `Controller.hs`; `Store/Messages.hs` (updateChatItem) | `APIDeleteChatItem` in `Controller.hs`; `Store/Messages.hs` (deleteChatItem) | +| **Connection** | `connections` | `createConnection` via SMP agent; `Store/Connections.hs` | `Store/Connections.hs` (getConnectionEntity) | `Store/Connections.hs` (updateConnectionStatus) | `Store/Connections.hs` (deleteConnection) | +| **FileTransfer** | `files`, `snd_files`, `rcv_files`, `xftp_file_descriptions` | `APISendMessages` (with file), `ReceiveFile` in `Controller.hs`; `Store/Files.hs` | `Store/Files.hs` (getFileTransfer) | `Store/Files.hs` (updateFileStatus, updateFileProgress) | `Store/Files.hs` (deleteFileTransfer) | +| **GroupLink** | `user_contact_links` | `APICreateGroupLink` in `Controller.hs`; `Store/Groups.hs` | `APIGetGroupLink` in `Controller.hs`; `Store/Groups.hs` | N/A (recreated on change) | `APIDeleteGroupLink` in `Controller.hs`; `Store/Groups.hs` | +| **ChatTag** | `chat_tags`, `chat_tags_chats` | `APICreateChatTag` in `Controller.hs` | `APIGetChats` in `Controller.hs` | `APIUpdateChatTag`, `APISetChatTags` in `Controller.hs` | `APIDeleteChatTag` in `Controller.hs` | +| **RcvCallInvitation** | In-memory (not persisted) | Received via `XCallInv` message in `Library/Subscriber.hs`; stored in `ChatModel.callInvitations` | `CallController.swift`, `IncomingCallView.swift` | Updated on call accept/reject in `CallManager.swift` | Removed on call end/reject; `Controller.hs` | + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Glossary: [glossary.md](glossary.md) +- Haskell core controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell store layer: `../../src/Simplex/Chat/Store/` (Direct.hs, Groups.hs, Messages.hs, Files.hs, Profiles.hs, Connections.hs) +- Swift model: `Shared/Model/ChatModel.swift` +- Swift API types: `SimpleXChat/APITypes.swift`, `SimpleXChat/ChatTypes.swift` +- Swift API bridge: `SimpleXChat/API.swift`, `Shared/Model/SimpleXAPI.swift` diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md new file mode 100644 index 0000000000..86cb026625 --- /dev/null +++ b/apps/ios/product/flows/calling.md @@ -0,0 +1,179 @@ +# Audio/Video Call Flow + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Overview + +WebRTC-based audio and video calling in SimpleX Chat iOS. Calls are end-to-end encrypted with an additional shared key negotiated over the E2E encrypted SMP channel. The iOS app integrates with CallKit for native call UI (incoming call screen, lock screen integration) with a fallback mode for regions where CallKit is restricted (China). Call signaling (offer/answer/ICE candidates) is exchanged via SMP messages, not through a central signaling server. + +## Prerequisites + +- Established direct contact connection (calls are 1:1 only, not available in groups) +- Microphone permission granted (audio calls) +- Camera permission granted (video calls) +- Network connectivity for WebRTC peer-to-peer or relay + +## Step-by-Step Processes + +### 1. Initiate Call + +1. User opens a direct chat in `ChatView`. +2. Taps the audio or video call button in the navigation bar. +3. `CallController` determines call type: `CallType(media: .audio/.video, capabilities: CallCapabilities(encryption: true))`. +4. If CallKit is enabled (`CallController.useCallKit()`): + - `CXStartCallAction` is requested via `CXCallController`. + - CallKit reports the outgoing call. + - `provider(perform: CXStartCallAction)` fulfills and reports `reportOutgoingCall(startedConnectingAt:)`. +5. Calls `apiSendCallInvitation(contact:callType:)`: + ```swift + func apiSendCallInvitation(_ contact: Contact, _ callType: CallType) async throws + ``` +6. Sends `ChatCommand.apiSendCallInvitation(contact:callType:)`. +7. Core sends the call invitation to the contact via SMP. +8. `ChatModel.shared.activeCall` is set with the call state. + +### 2. Receive Call + +1. `ChatReceiver` receives `ChatEvent.callInvitation(callInvitation: RcvCallInvitation)`. +2. `RcvCallInvitation` contains: `user`, `contact`, `callType`, `sharedKey`, `callUUID`, `callTs`. +3. Processing in `processReceivedMsg`: + - Call invitation is stored in `chatModel.callInvitations`. +4. If CallKit is enabled: + - `CXProvider.reportNewIncomingCall` presents the native iOS incoming call UI. + - Works even on lock screen and in background. +5. If CallKit is disabled (China / user preference): + - `IncomingCallView` is shown as an in-app overlay. + - `SoundPlayer` plays the ringtone. +6. User chooses to accept or reject. + +### 3. Accept Call + +1. **Via CallKit**: User swipes to accept on the native incoming call screen. + - `provider(perform: CXAnswerCallAction)` is triggered. + - Waits for chat to be started if needed (`waitUntilChatStarted(timeoutMs: 30_000)`). + - `callManager.answerIncomingCall(callUUID:)` begins WebRTC setup. + - `fulfillOnConnect` is set -- the action is fulfilled only when WebRTC reaches connected state (required for audio/mic to work on lock screen). +2. **Via in-app UI**: User taps "Accept" in `IncomingCallView`. + - Directly starts WebRTC setup. + +### 4. Reject Call + +1. **Via CallKit**: User taps "Decline" on native UI. + - `provider(perform: CXEndCallAction)` is triggered. + - `callManager.endCall(callUUID:)` cleans up. +2. **Via API**: `apiRejectCall(contact:)` sends rejection to peer. +3. Call invitation is removed from `chatModel.callInvitations`. + +### 5. WebRTC Setup (Signaling) + +All signaling messages are exchanged via E2E encrypted SMP messages (no central signaling server). + +**Caller side:** +1. `WebRTCClient` creates a `RTCPeerConnection`. +2. Creates SDP offer. +3. Calls `apiSendCallOffer(contact:rtcSession:rtcIceCandidates:media:capabilities:)`: + ```swift + func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String, + media: CallMediaType, capabilities: CallCapabilities) async throws + ``` +4. Constructs `WebRTCCallOffer(callType:rtcSession:)` and sends via `ChatCommand.apiSendCallOffer`. +5. Gathers ICE candidates and sends via `apiSendCallExtraInfo(contact:rtcIceCandidates:)`. + +**Callee side:** +1. Receives the offer via SMP. +2. `WebRTCClient` sets remote description from the offer. +3. Creates SDP answer. +4. Calls `apiSendCallAnswer(contact:rtcSession:rtcIceCandidates:)`: + ```swift + func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String) async throws + ``` +5. Constructs `WebRTCSession(rtcSession:rtcIceCandidates:)` and sends. +6. Gathers and sends additional ICE candidates via `apiSendCallExtraInfo`. + +### 6. Media Streaming + +1. WebRTC peer connection transitions to connected state. +2. If CallKit is used, `fulfillOnConnect` action is fulfilled (enables audio hardware). +3. Audio/video streams are active. +4. `ActiveCallView` displays: + - Remote video (full screen) + - Local video preview (picture-in-picture corner) + - Call controls: mute, speaker, camera toggle, end call + - Call duration timer +5. `CallViewRenderers` manages WebRTC video rendering surfaces. +6. Call status updates are sent via `apiCallStatus(contact:status:)`. + +### 7. Audio Routing + +1. `CallAudioDeviceManager` handles audio device selection. +2. Options: earpiece (receiver), speaker, Bluetooth devices. +3. `AudioDevicePicker` provides UI for device selection during call. +4. Uses `AVAudioSession` for routing configuration. + +### 8. End Call + +1. Either party taps "End" button. +2. Calls `apiEndCall(contact:)`: + ```swift + func apiEndCall(_ contact: Contact) async throws + ``` +3. Sends `ChatCommand.apiEndCall(contact:)` via SMP to notify peer. +4. `WebRTCClient` closes peer connection, releases media resources. +5. If CallKit: `CXEndCallAction` is requested, `provider(perform: CXEndCallAction)` fulfills. +6. `ChatModel.shared.activeCall` is cleared. +7. A `CICallItemView` event item is added to the chat history (call duration, type). + +### 9. CallKit-Free Mode + +1. `CallController.isInChina` checks `SKStorefront().countryCode == "CHN"`. +2. If in China or user disabled CallKit (`callKitEnabledGroupDefault`): `useCallKit()` returns `false`. +3. Incoming calls use `IncomingCallView` overlay instead of native CallKit UI. +4. `SoundPlayer` handles ringtone playback. +5. No lock-screen call answering; app must be in foreground or notified via push. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CallType` | `SimpleXChat/CallTypes.swift` | `media: CallMediaType` (.audio/.video), `capabilities: CallCapabilities` | +| `CallMediaType` | `SimpleXChat/CallTypes.swift` | `.audio` or `.video` | +| `CallCapabilities` | `SimpleXChat/CallTypes.swift` | `encryption: Bool` for E2E call encryption support | +| `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs | +| `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data | +| `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) | +| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer | +| `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting | +| `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` | +| `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout | +| Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry | +| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status | +| CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up | +| No camera/mic permission | User denied permissions | Permission request dialog shown | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/Call/CallController.swift` | CallKit integration, CXProvider delegate, PKPushRegistry, call lifecycle management | +| `Shared/Views/Call/CallManager.swift` | Call state management, starting/answering/ending calls | +| `Shared/Views/Call/WebRTCClient.swift` | WebRTC peer connection, SDP offer/answer, ICE candidate handling | +| `Shared/Views/Call/ActiveCallView.swift` | Active call UI: video renderers, controls, duration | +| `Shared/Views/Call/CallViewRenderers.swift` | WebRTC video rendering surfaces | +| `Shared/Views/Call/IncomingCallView.swift` | Non-CallKit incoming call overlay | +| `Shared/Views/Call/CallAudioDeviceManager.swift` | Audio routing: speaker, earpiece, Bluetooth | +| `Shared/Views/Call/AudioDevicePicker.swift` | Audio device selection UI | +| `Shared/Views/Call/SoundPlayer.swift` | Ringtone and call sound playback | +| `Shared/Views/Call/WebRTC.swift` | WebRTC configuration and utilities | +| `SimpleXChat/CallTypes.swift` | All call-related type definitions | +| `Shared/Model/SimpleXAPI.swift` | Call API functions: `apiSendCallInvitation`, `apiSendCallOffer`, `apiSendCallAnswer`, `apiSendCallExtraInfo`, `apiEndCall`, `apiRejectCall`, `apiCallStatus` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Calls capability +- `apps/ios/product/flows/connection.md` -- Calls require an established direct connection diff --git a/apps/ios/product/flows/connection.md b/apps/ios/product/flows/connection.md new file mode 100644 index 0000000000..c621dc5124 --- /dev/null +++ b/apps/ios/product/flows/connection.md @@ -0,0 +1,178 @@ +# Connection Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/architecture.md](../../spec/architecture.md) + +## Overview + +Establishing contact between two SimpleX Chat users. SimpleX uses no user identifiers; connections are formed through one-time invitation links or permanent SimpleX addresses. Each connection creates unique unidirectional SMP queues, ensuring no server can correlate sender and receiver. Supports incognito mode for per-contact random profile generation. + +## Prerequisites + +- User profile created and chat engine running +- Network connectivity to SMP relay servers +- For QR code scanning: camera permission granted + +## Step-by-Step Processes + +### 1. Create Invitation Link + +1. User taps "+" button in `ChatListView` -> `NewChatMenuButton` -> "Add contact". +2. `NewChatView` is presented. +3. Calls `apiAddContact(incognito:)`: + ```swift + func apiAddContact(incognito: Bool) async + -> ((CreatedConnLink, PendingContactConnection)?, Alert?) + ``` +4. Internally sends `ChatCommand.apiAddContact(userId:incognito:)` to core. +5. Core creates SMP queues and returns `ChatResponse1.invitation(user, connLinkInv, connection)`. +6. Returns `(CreatedConnLink, PendingContactConnection)`. +7. `CreatedConnLink` contains the invitation URI (both full and short link forms). +8. UI displays: + - QR code rendered by `QRCode` view (scannable by peer) + - Share button to send link via system share sheet + - Copy button for clipboard +9. A `PendingContactConnection` appears in the chat list while awaiting peer. + +### 2. Connect via Link + +1. User receives a SimpleX link (pasted, scanned, or opened via URL scheme). +2. If opened via deep link: `SimpleXApp.onOpenURL` sets `chatModel.appOpenUrl`. +3. For manual entry: User pastes link in `NewChatView`. +4. First, `apiConnectPlan(connLink:inProgress:)` is called to validate: + ```swift + func apiConnectPlan(connLink: String, inProgress: BoxedValue) async + -> ((CreatedConnLink, ConnectionPlan)?, Alert?) + ``` +5. Returns `ConnectionPlan` indicating whether it is an invitation, contact address, or group link, and whether connection is already established. +6. If valid, calls `apiConnect(incognito:connLink:)`: + ```swift + func apiConnect(incognito: Bool, connLink: CreatedConnLink) async + -> (ConnReqType, PendingContactConnection)? + ``` +7. Core creates the connection and returns one of: + - `ChatResponse1.sentConfirmation(user, connection)` -- for invitation links (type: `.invitation`) + - `ChatResponse1.sentInvitation(user, connection)` -- for contact address links (type: `.contact`) + - `ChatResponse1.contactAlreadyExists(user, contact)` -- duplicate +8. `PendingContactConnection` appears in chat list while awaiting peer confirmation. + +### 3. Prepared Contact/Group Flow (Short Links) + +1. For short links with embedded profile data, the app uses a two-phase flow. +2. `apiPrepareContact(connLink:contactShortLinkData:)` or `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` creates a local prepared chat. `directLink` is `true` for standard group links, `false` for channel relay links. +3. Returns `ChatData` with the prepared contact/group shown in UI before connecting. +4. User can switch profiles or set incognito before committing. +5. `apiConnectPreparedContact(contactId:incognito:msg:)` finalizes the connection. +6. Returns `ChatResponse1.startedConnectionToContact(user, contact)`. + +### 4. Accept Contact Request + +1. When a peer connects via the user's SimpleX address, core generates a `ChatEvent.receivedContactRequest`. +2. `processReceivedMsg` handles the event, adding a `UserContactRequest` to `ChatModel`. +3. Contact request appears in `ChatListView` as a special `ContactRequestView` row. +4. User taps "Accept": + ```swift + func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? + ``` +5. Sends `ChatCommand.apiAcceptContact(incognito:contactReqId:)`. +6. Core returns `ChatResponse1.acceptingContactRequest(user, contact)`. +7. Connection handshake proceeds asynchronously. +8. User can also reject: `apiRejectContactRequest(contactReqId:)` -> `ChatResponse1.contactRequestRejected`. + +### 5. Connection Established + +1. Both sides complete the SMP handshake asynchronously. +2. Core sends `ChatEvent.contactConnected(user, contact, userCustomProfile)`. +3. `processReceivedMsg` updates `ChatModel`: + - Contact status transitions from pending to active. + - Chat becomes available for messaging. +4. `NtfManager` may post a notification: "Contact connected". +5. The `PendingContactConnection` in the chat list is replaced by the full contact chat. + +### 6. Create SimpleX Address + +1. User navigates to Settings or taps "Create SimpleX address" during onboarding. +2. Calls `apiCreateUserAddress()`: + ```swift + func apiCreateUserAddress() async throws -> CreatedConnLink? + ``` +3. Core creates a permanent address (unlike one-time invitations). +4. Address is stored in `ChatModel.shared.userAddress`. +5. Can be shared publicly; multiple contacts can connect via the same address. +6. User must accept each incoming contact request individually. +7. To delete: `apiDeleteUserAddress()` removes the address and associated SMP queues. + +### 7a. Relay Link Rejection + +1. User scans, pastes, or opens a relay address link (URL path `/r` or `SimplexLinkType.relay`). +2. In `ContentView.connectViaUrl_()`: early return with alert "Relay address" / "This is a chat relay address, it cannot be used to connect." +3. In `NewChatView.planAndConnect()`: `.simplexLink(_, .relay, _, _)` pattern triggers the same alert. +4. The link is NOT processed further. No connection is attempted. + +### 7b. Channel Prepared Group Flow + +1. When connecting to a channel link (`GroupShortLinkInfo.direct == false`): +2. `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` is called with `directLink: false`, preparing the channel locally. +3. `groupShortLinkInfo.groupRelays` (hostnames) stored in `ChatModel.shared.channelRelayHostnames[groupId]`. +4. Pre-join UI shows channel icon and "Open new channel" (not "Open new group"). +5. `apiConnectPreparedGroup(groupId:incognito:msg:)` returns `(GroupInfo, [RelayConnectionResult])`. +6. `RelayConnectionResult` contains `relayMember: GroupMember` and optional `relayError: ChatError?` per relay. +7. Relay members are upserted to `chatModel.groupMembers`; `channelRelayHostnames` entry is cleared. + +### 7. Incognito Connection + +1. Before connecting, user toggles "Incognito" in the connection UI. +2. `incognito: true` is passed to `apiAddContact`, `apiConnect`, or `apiAcceptContactRequest`. +3. Core generates a random display name for this connection only. +4. The random profile is stored per-connection; the user's real profile is never shared. +5. Incognito status is shown with a mask icon in the chat. +6. Can also be toggled for pending connections via `apiSetConnectionIncognito(connId:incognito:)`. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CreatedConnLink` | `SimpleXChat/APITypes.swift` | Contains `connFullLink` (URI) and optional `connShortLink` | +| `PendingContactConnection` | `SimpleXChat/ChatTypes.swift` | Represents an in-progress connection before contact is established | +| `ConnectionPlan` | `Shared/Model/AppAPITypes.swift` | Enum describing what a link will do: connect contact, join group, already connected, etc. | +| `ConnReqType` | `Shared/Views/NewChat/NewChatView.swift` | `.invitation`, `.contact`, or `.groupLink` -- type of connection request | +| `Contact` | `SimpleXChat/ChatTypes.swift` | Full contact model with profile, connection status, preferences | +| `UserContactRequest` | `SimpleXChat/ChatTypes.swift` | Incoming contact request awaiting acceptance | +| `ChatType` | `SimpleXChat/ChatTypes.swift` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` | +| `GroupShortLinkInfo` | `Shared/Model/AppAPITypes.swift` | Contains `direct: Bool`, `groupRelays: [String]`, `publicGroupId: String?`; transient data returned by prepare | +| `RelayConnectionResult` | `Shared/Model/AppAPITypes.swift` | Contains `relayMember: GroupMember`, `relayError: ChatError?`; per-relay join outcome | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `ChatError.invalidConnReq` | Malformed or expired link | Alert: "Invalid connection link" | +| `ChatError.unsupportedConnReq` | Link requires newer app version | Alert: "Unsupported connection link" | +| `ChatError.errorAgent(.SMP(_, .AUTH))` | Link already used or deleted | Alert: "Connection error (AUTH)" | +| `ChatError.errorAgent(.SMP(_, .BLOCKED(info)))` | Server operator blocked connection | Alert: "Connection blocked" with reason | +| `ChatError.errorAgent(.SMP(_, .QUOTA))` | Too many undelivered messages | Alert: "Undelivered messages" | +| `ChatError.errorAgent(.INTERNAL("SEUniqueID"))` | Duplicate connection attempt | Alert: "Already connected?" | +| `ChatError.errorAgent(.BROKER(_, .TIMEOUT))` | Server timeout | Retryable via `chatApiSendCmdWithRetry` | +| `ChatError.errorAgent(.BROKER(_, .NETWORK))` | Network failure | Retryable via `chatApiSendCmdWithRetry` | +| `contactAlreadyExists` | Connecting to existing contact | Alert: "Contact already exists" with contact name | +| `errorAgent(.SMP(_, .AUTH))` on accept | Sender deleted request | Alert: "Sender may have deleted the connection request" | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/NewChat/NewChatView.swift` | Main connection UI: create link, paste link, QR scan | +| `Shared/Views/NewChat/NewChatMenuButton.swift` | "+" button menu in chat list | +| `Shared/Views/NewChat/QRCode.swift` | QR code rendering for invitation links | +| `Shared/Views/NewChat/AddContactLearnMore.swift` | Help text explaining connection process | +| `Shared/Views/ChatList/ContactRequestView.swift` | Incoming contact request display | +| `Shared/Views/ChatList/ContactConnectionView.swift` | Pending connection display | +| `Shared/Views/ChatList/ContactConnectionInfo.swift` | Connection details sheet | +| `Shared/Model/SimpleXAPI.swift` | API functions: `apiAddContact`, `apiConnect`, `apiConnectPlan`, `apiAcceptContactRequest`, `apiCreateUserAddress` | +| `Shared/Model/AppAPITypes.swift` | `ConnectionPlan` enum, `GroupLink` struct | +| `SimpleXChat/APITypes.swift` | `CreatedConnLink`, `ComposedMessage`, command/response types | +| `SimpleXChat/ChatTypes.swift` | `Contact`, `PendingContactConnection`, `UserContactRequest` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Contacts capability map +- `apps/ios/product/flows/messaging.md` -- Messaging after connection is established diff --git a/apps/ios/product/flows/file-transfer.md b/apps/ios/product/flows/file-transfer.md new file mode 100644 index 0000000000..0b4b0538cc --- /dev/null +++ b/apps/ios/product/flows/file-transfer.md @@ -0,0 +1,209 @@ +# File Transfer Flow + +> **Related spec:** [spec/services/files.md](../../spec/services/files.md) + +## Overview + +File and media sharing in SimpleX Chat iOS. Small files are sent inline within SMP messages; large files use the XFTP (eXtended File Transfer Protocol) for chunked, encrypted uploads up to 1GB. All files are encrypted end-to-end. Optional local encryption protects downloaded files at rest using AES via `CryptoFile`. + +## Prerequisites + +- Established contact or group conversation +- For sending: photo library or file picker access permission +- For receiving: sufficient device storage +- XFTP relay servers configured (default servers or custom) + +## Size Limits + +| Category | Limit | Constant | +|----------|-------|----------| +| Inline image (compressed) | 255 KB | `MAX_IMAGE_SIZE` = 261,120 bytes | +| Auto-receive image | 510 KB | `MAX_IMAGE_SIZE_AUTO_RCV` = MAX_IMAGE_SIZE * 2 | +| Auto-receive voice | 510 KB | `MAX_VOICE_SIZE_AUTO_RCV` = MAX_IMAGE_SIZE * 2 | +| Auto-receive video | 1,023 KB | `MAX_VIDEO_SIZE_AUTO_RCV` = 1,047,552 bytes | +| Max file via XFTP | 1 GB | `MAX_FILE_SIZE_XFTP` = 1,073,741,824 bytes | +| Max file via SMP | ~8 MB | `MAX_FILE_SIZE_SMP` = 8,000,000 bytes | +| Max voice message length | 5 min | `MAX_VOICE_MESSAGE_LENGTH` = 300s | + +## Step-by-Step Processes + +### 1. Send Image + +1. User taps the attachment button in `ComposeView` and selects an image. +2. `ComposeImageView` displays the selected image preview. +3. Image is compressed to fit within `MAX_IMAGE_SIZE` (255KB). +4. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: compressedImagePath), + msgContent: .image(text: captionText, image: base64Thumbnail) + ) + ``` +5. `apiSendMessages(type:id:scope:composedMessages:)` is called. +6. For images <=255KB: sent inline within the SMP message. +7. For larger images: XFTP upload is used (see XFTP transfer below). +8. Recipient auto-receives images up to 510KB (`MAX_IMAGE_SIZE_AUTO_RCV`). + +### 2. Send Video + +1. User picks a video from the library. +2. Thumbnail is generated from the first frame. +3. Video duration is calculated. +4. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: videoFilePath), + msgContent: .video(text: captionText, image: base64Thumbnail, duration: durationSeconds) + ) + ``` +5. `apiSendMessages(...)` is called. +6. Video files are typically >255KB, so XFTP upload is used. +7. Recipient auto-receives videos up to 1,023KB (`MAX_VIDEO_SIZE_AUTO_RCV`). +8. `CIVideoView` displays thumbnail with play button; video downloads on tap if not auto-received. + +### 3. Send File + +1. User taps the attachment button and selects a document via the system file picker. +2. `ComposeFileView` shows the file name and size. +3. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: filePath), + msgContent: .file(fileName) + ) + ``` +4. `apiSendMessages(...)` is called. +5. If file <=255KB: sent inline via SMP. +6. If file >255KB and <=1GB: uploaded via XFTP. +7. Files >1GB: rejected (prevented in UI). +8. `CIFileView` displays file icon, name, and size for the recipient. + +### 4. Send Voice Message + +1. User taps and holds the microphone button in `ComposeView`. +2. `AudioRecPlay` records audio to a temporary file. +3. `ComposeVoiceView` shows recording waveform and duration. +4. On release (or tapping stop), recording ends. +5. Duration is checked against `MAX_VOICE_MESSAGE_LENGTH` (5 minutes / 300 seconds). +6. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: voiceFilePath), + msgContent: .voice(text: "", duration: durationSeconds) + ) + ``` +7. `apiSendMessages(...)` is called. +8. Voice messages <=510KB are sent inline. +9. Recipient auto-receives voice up to 510KB (`MAX_VOICE_SIZE_AUTO_RCV`). +10. `CIVoiceView` renders waveform with playback controls. + +### 5. Receive File + +1. Core receives a message with a file reference via SMP. +2. `ChatEvent.newChatItems` delivers the chat item with file metadata. +3. Auto-receive logic checks: + - File type and size against auto-receive thresholds. + - User's auto-receive preferences. +4. If auto-received or user taps "Download": + ```swift + func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async + ``` +5. Internally calls `receiveFiles(user:fileIds:userApprovedRelays:auto:)`. +6. Sends `ChatCommand.receiveFile(fileId:userApprovedRelays:encrypted:inline:)`. +7. `encrypted` is determined by `privacyEncryptLocalFilesGroupDefault`. +8. `userApprovedRelays` controls whether unknown XFTP relay servers are trusted. +9. On success: `ChatResponse2.rcvFileAccepted(user, chatItem)` -- file download begins. +10. On sender cancelled: `ChatResponse2.rcvFileAcceptedSndCancelled(user, rcvFileTransfer)`. +11. Download progress is tracked and shown in the UI. +12. Completed files are stored in the app's `Documents/files/` directory. + +### 6. XFTP Transfer (Large Files) + +**Upload (sender side):** +1. File is encrypted locally with a random symmetric key. +2. Encrypted file is split into chunks. +3. Chunks are uploaded to one or more XFTP relay servers. +4. A file description (URI with encryption key and chunk locations) is created. +5. The file description is sent to the recipient via the SMP message. + +**Download (recipient side):** +1. Recipient receives the file description via SMP. +2. Chunks are downloaded from XFTP relay servers. +3. Chunks are reassembled and decrypted locally. +4. File is available at the local path. + +**Standalone file operations** (used for database migration): +- `uploadStandaloneFile(user:file:ctrl:)` -- upload without a chat message +- `downloadStandaloneFile(user:url:file:ctrl:)` -- download from a standalone URL +- `standaloneFileInfo(url:ctrl:)` -- get metadata for a standalone file URL + +### 7. Local File Encryption + +1. If `privacyEncryptLocalFilesGroupDefault` is enabled in privacy settings: + - Downloaded files are encrypted at rest using AES via `CryptoFile`. + - `CryptoFile` wraps a file path with encryption metadata. +2. Encryption key is derived and stored securely. +3. Files are decrypted on-the-fly when accessed for viewing/playback. +4. This protects files even if the device storage is accessed externally. + +### 8. Unknown Relay Server Approval + +1. When receiving a file, XFTP relay servers are checked against known/approved servers. +2. If unknown servers are detected: `ChatError.error(.fileNotApproved(fileId, unknownServers))`. +3. If not auto-receiving, user is shown an alert: + - "Unknown servers! Without Tor or VPN, your IP address will be visible to these XFTP relays: [server list]." + - Option to "Download" (approve) or cancel. +4. On approval: `receiveFiles(user:fileIds:userApprovedRelays: true)` retries with approval. +5. If `privacyAskToApproveRelaysGroupDefault` is disabled, relays are auto-approved. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CryptoFile` | `SimpleXChat/CryptoFile.swift` | File path with optional encryption key and nonce for local AES encryption | +| `MsgContent.image` | `SimpleXChat/ChatTypes.swift` | `.image(text: String, image: String)` -- text caption + base64 thumbnail | +| `MsgContent.video` | `SimpleXChat/ChatTypes.swift` | `.video(text: String, image: String, duration: Int)` -- caption + thumbnail + duration | +| `MsgContent.voice` | `SimpleXChat/ChatTypes.swift` | `.voice(text: String, duration: Int)` -- empty text + duration in seconds | +| `MsgContent.file` | `SimpleXChat/ChatTypes.swift` | `.file(String)` -- file name | +| `ComposedMessage` | `SimpleXChat/APITypes.swift` | Outgoing message with fileSource, quotedItemId, msgContent, mentions | +| `FileTransferMeta` | `SimpleXChat/ChatTypes.swift` | Metadata for an ongoing file transfer | +| `RcvFileTransfer` | `SimpleXChat/ChatTypes.swift` | State of a file being received | +| `MigrationFileLinkData` | Used for standalone file transfers during database migration | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `fileNotApproved(fileId, unknownServers)` | Unknown XFTP relay servers | Alert with option to approve and retry | +| `fileCancelled` | File transfer was cancelled | Silently ignored in `receiveFiles` | +| `fileAlreadyReceiving` | Duplicate receive request | Silently ignored | +| `rcvFileAcceptedSndCancelled` | Sender cancelled after acceptance | Alert: "Sender cancelled file transfer" | +| File too large | Exceeds 1GB XFTP limit | Prevented in UI picker | +| Network errors | XFTP server unreachable | Standard retry mechanism | +| Storage full | Insufficient device storage | System-level error | + +## Key Files + +| File | Purpose | +|------|---------| +| `SimpleXChat/FileUtils.swift` | File size constants, path utilities, database file management | +| `SimpleXChat/CryptoFile.swift` | Local file encryption/decryption with AES | +| `SimpleXChat/ImageUtils.swift` | Image compression and thumbnail generation | +| `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | File/media attachment selection and composition | +| `Shared/Views/Chat/ComposeMessage/ComposeImageView.swift` | Image preview in compose area | +| `Shared/Views/Chat/ComposeMessage/ComposeFileView.swift` | File preview in compose area | +| `Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift` | Voice recording UI with waveform | +| `Shared/Views/Chat/ChatItem/CIFileView.swift` | File message display: icon, name, size, download action | +| `Shared/Views/Chat/ChatItem/CIImageView.swift` | Image message display: thumbnail, full-screen tap | +| `Shared/Views/Chat/ChatItem/CIVideoView.swift` | Video message display: thumbnail, play button, inline playback | +| `Shared/Views/Chat/ChatItem/CIVoiceView.swift` | Voice message display: waveform, playback controls | +| `Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift` | Voice message inside a framed (quoted/forwarded) context | +| `Shared/Views/Chat/ChatItem/FullScreenMediaView.swift` | Full-screen image/video viewer | +| `Shared/Model/SimpleXAPI.swift` | `apiSendMessages`, `receiveFile`, `receiveFiles`, `uploadStandaloneFile`, `downloadStandaloneFile` | +| `Shared/Model/AudioRecPlay.swift` | Audio recording and playback engine for voice messages | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Messaging capability (file sharing) +- `apps/ios/product/flows/messaging.md` -- File transfer is part of the message send flow +- `apps/ios/product/views/chat.md` -- Chat view file/media display diff --git a/apps/ios/product/flows/group-lifecycle.md b/apps/ios/product/flows/group-lifecycle.md new file mode 100644 index 0000000000..e102fa982a --- /dev/null +++ b/apps/ios/product/flows/group-lifecycle.md @@ -0,0 +1,228 @@ +# Group Lifecycle Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/database.md](../../spec/database.md) + +## Overview + +Complete group management in SimpleX Chat iOS: creating groups, inviting members, joining via links, managing roles and admission, and group deletion. Groups use the same E2E encryption as direct messages -- each member pair has independent encrypted channels. Group metadata (name, image, preferences) is distributed via the group protocol. + +## Prerequisites + +- User profile created and chat engine running +- At least one established contact (to invite to a group) +- For joining via link: a valid group link or invitation + +## Step-by-Step Processes + +### 1. Create Group + +1. User taps "+" in `ChatListView` -> `NewChatMenuButton` -> "Create group". +2. `AddGroupView` is presented for entering group name, optional image, and description. +3. User fills in `GroupProfile(displayName:fullName:image:description:)` and taps "Create". +4. Calls `apiNewGroup(incognito:groupProfile:)`: + ```swift + func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo + ``` +5. Sends `ChatCommand.apiNewGroup(userId:incognito:groupProfile:)` to core (synchronous). +6. Core returns `ChatResponse2.groupCreated(user, groupInfo)`. +7. `GroupInfo` contains the new group's ID, profile, and the creator as owner. +8. User is navigated to `AddGroupMembersView` to optionally invite contacts. +9. User can also create a group link at this stage. + +### 1a. Create Public Group (Channel) + +1. Alternative to standard group creation for relay-backed channels. +2. Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)`: + ```swift + func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay]) + ``` +3. Sends `ChatCommand.apiNewPublicGroup(userId:incognito:relayIds:groupProfile:)` to core. +4. Core returns `ChatResponse2.publicGroupCreated(user, groupInfo, groupLink, groupRelays)`. +5. The resulting `GroupInfo` has `useRelays == true` and includes a group link. +6. Channel relay members (with role `.relay`) are managed by the core. + +### 2. Invite Members + +1. From `GroupChatInfoView`, user taps "Add members" -> `AddGroupMembersView`. +2. `filterMembersToAdd` filters contacts already in the group. +3. User selects contacts and assigns roles (default: `.member`). +4. For each selected contact, calls `apiAddMember(groupId:contactId:memberRole:)`: + ```swift + func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember + ``` +5. Core sends group invitation to the contact and returns `ChatResponse2.sentGroupInvitation(user, _, _, member)`. +6. The invited contact receives a `CIGroupInvitationView` in their chat. +7. Invited member's status is `.invited` until they accept. + +### 3. Join via Link + +1. User receives a group link (scanned or pasted). +2. `apiConnectPlan` validates the link and identifies it as a group link. +3. For prepared groups (short links): `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` shows group info before joining. `directLink` is `true` for standard group links, `false` for channel relay links. +4. `apiConnectPreparedGroup(groupId:incognito:msg:)` or `apiConnect(incognito:connLink:)` initiates joining. +5. Core processes the join request. Depending on group admission settings: + - **Auto-join**: Member is added immediately. + - **Approval required**: Member enters pending admission queue. +6. `apiJoinGroup(groupId:)` is called for invitation-based joins: + ```swift + func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult? + ``` +7. Returns one of: + - `.joined(groupInfo:)` -- successfully joined + - `.invitationRemoved` -- invitation was revoked (SMP AUTH error) + - `.groupNotFound` -- group no longer exists + +### 4. Member Admission + +1. Group has admission settings configured via `MemberAdmissionView`. +2. When a new member joins a group requiring approval, admins see pending members. +3. Admin reviews pending member in the member list. +4. To accept: `apiAcceptMember(groupId:groupMemberId:memberRole:)`: + ```swift + func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) + ``` +5. Core returns `ChatResponse2.memberAccepted(user, groupInfo, member)`. +6. To reject: remove the pending member (same as member removal). +7. Member support chat (`MemberSupportView`, `MemberSupportChatToolbar`) allows admins to communicate with pending members. + +### 5. Change Member Roles + +1. Admin/owner navigates to member info in `GroupChatInfoView`. +2. Selects new role for the member. +3. Calls `apiMembersRole(groupId:memberIds:memberRole:)`: + ```swift + func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] + ``` +4. Core returns `ChatResponse2.membersRoleUser(user, _, members, _)`. +5. Available roles (in hierarchy order): + - `.owner` -- full control, can delete group + - `.admin` -- can manage members, change roles (below admin) + - `.moderator` -- can delete messages, moderate content + - `.member` -- standard participant, can send messages + - `.observer` -- read-only access +6. Role changes are broadcast to all group members as group events. + +### 6. Remove Member + +1. Admin/owner navigates to member info -> taps "Remove". +2. Calls `apiRemoveMembers(groupId:memberIds:withMessages:)`: + ```swift + func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) + ``` +3. `withMessages: true` also deletes all messages from that member. +4. Core returns `ChatResponse2.userDeletedMembers(user, updatedGroupInfo, members, withMessages)`. +5. Removed member receives notification and loses access. + +### 7. Block Member for All + +1. Admin can block a member's messages from being visible to all group members. +2. Calls `apiBlockMembersForAll(groupId:memberIds:blocked:)`: + ```swift + func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] + ``` +3. Core returns `ChatResponse2.membersBlockedForAllUser(user, _, members, _)`. + +### 8. Leave Group + +1. User navigates to `GroupChatInfoView` -> taps "Leave group". +2. Confirmation dialog is presented. +3. Calls `leaveGroup(groupId:)` which wraps `apiLeaveGroup(groupId:)`: + ```swift + func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo + ``` +4. Core returns `ChatResponse2.leftMemberUser(user, groupInfo)`. +5. `ChatModel.shared.updateGroup(groupInfo)` updates the UI. +6. User retains local chat history but can no longer send/receive. + +### 9. Delete Group + +1. Owner navigates to `GroupChatInfoView` -> taps "Delete group". +2. Calls `apiDeleteChat(type: .group, id: groupId)`: + ```swift + func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws + ``` +3. Core notifies all members and removes the group. +4. Chat is removed from `ChatModel.shared.chats`. + +### 10. Group Link Management + +**Create group link:** +1. From `GroupLinkView` (accessible via `GroupChatInfoView`). +2. Calls `apiCreateGroupLink(groupId:memberRole:)`: + ```swift + func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? + ``` +3. Returns `GroupLink` containing the link URI and member role. +4. Optional: `apiAddGroupShortLink(groupId:)` generates an additional short link. + +**Update link role:** +- `apiGroupLinkMemberRole(groupId:memberRole:)` changes the default role for new joiners. + +**Delete group link:** +- `apiDeleteGroupLink(groupId:)` invalidates the link. + +**Get existing link:** +- `apiGetGroupLink(groupId:)` retrieves the current link (returns `nil` if none exists). + +### 11. Group Preferences + +1. `GroupPreferencesView` allows configuring per-feature preferences. +2. Features controlled include: + - Timed/disappearing messages + - Message reactions + - Voice messages + - File sharing + - Direct messages between members + - Full message deletion + - Message history visibility for new members +3. Changes are saved via `apiUpdateGroup(groupId:groupProfile:)` with updated preferences. +4. `GroupWelcomeView` manages the welcome message shown to new joiners. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `GroupInfo` | `SimpleXChat/ChatTypes.swift` | Full group model: ID, profile, membership, preferences, business chat info | +| `GroupProfile` | `SimpleXChat/ChatTypes.swift` | Name, full name, image, description, preferences | +| `GroupMember` | `SimpleXChat/ChatTypes.swift` | Member model: role, status, profile, connection info | +| `GroupMemberRole` | `SimpleXChat/ChatTypes.swift` | `.owner`, `.admin`, `.moderator`, `.member`, `.observer`, `.relay` | +| `GroupMemberStatus` | `SimpleXChat/ChatTypes.swift` | Member lifecycle: `.invited`, `.accepted`, `.connected`, `.complete`, etc. | +| `GroupLink` | `Shared/Model/AppAPITypes.swift` | Group link with URI, member role, and short link data | +| `BusinessChatInfo` | `SimpleXChat/ChatTypes.swift` | Business chat metadata for commercial group chats | +| `JoinGroupResult` | `Shared/Model/SimpleXAPI.swift` | `.joined(groupInfo)`, `.invitationRemoved`, `.groupNotFound` | +| `GMember` | `Shared/Views/Chat/Group/` | View-layer wrapper around `GroupMember` for list display | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `errorStore(.groupNotFound)` | Group deleted or not accessible | `JoinGroupResult.groupNotFound` | +| `errorAgent(.SMP(_, .AUTH))` | Invitation revoked | `JoinGroupResult.invitationRemoved` | +| `errorStore(.groupLinkNotFound)` | No group link exists | `apiGetGroupLink` returns `nil` | +| `duplicateGroupLink` | Link already exists for group | Show alert | +| `errorAgent(.NOTICE(server, preset, expires))` | Server notice during link creation | `showClientNotice` alert | +| Network errors | SMP/XFTP server unreachable | Retryable via `chatApiSendCmdWithRetry` | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/NewChat/AddGroupView.swift` | Group creation UI | +| `Shared/Views/Chat/Group/AddGroupMembersView.swift` | Member invitation UI | +| `Shared/Views/Chat/Group/GroupLinkView.swift` | Group link management UI | +| `Shared/Views/Chat/Group/GroupProfileView.swift` | Group profile editing | +| `Shared/Views/Chat/Group/GroupPreferencesView.swift` | Feature preferences UI | +| `Shared/Views/Chat/Group/GroupWelcomeView.swift` | Welcome message editing | +| `Shared/Views/Chat/Group/MemberAdmissionView.swift` | Admission settings UI | +| `Shared/Views/Chat/Group/MemberSupportView.swift` | Admin-to-pending-member chat | +| `Shared/Views/Chat/Group/MemberSupportChatToolbar.swift` | Support chat accept/reject toolbar | +| `Shared/Views/Chat/Group/SecondaryChatView.swift` | Secondary chat view for member support | +| `Shared/Model/SimpleXAPI.swift` | All group API functions | +| `Shared/Model/AppAPITypes.swift` | `GroupLink`, `ConnectionPlan` | +| `SimpleXChat/ChatTypes.swift` | `GroupInfo`, `GroupProfile`, `GroupMember`, `GroupMemberRole` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Groups capability map +- `apps/ios/product/flows/connection.md` -- Connection flow (group links use the same connect mechanism) +- `apps/ios/product/flows/messaging.md` -- Messaging within groups diff --git a/apps/ios/product/flows/messaging.md b/apps/ios/product/flows/messaging.md new file mode 100644 index 0000000000..d37fefdd7d --- /dev/null +++ b/apps/ios/product/flows/messaging.md @@ -0,0 +1,178 @@ +# Messaging Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/client/chat-view.md](../../spec/client/chat-view.md) | [spec/client/compose.md](../../spec/client/compose.md) + +## Overview + +Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, editing, deleting, reacting to, replying to, and forwarding messages. All messages are end-to-end encrypted via the SMP protocol. The Haskell core handles encryption, routing, and persistence; the Swift UI layer drives composition and display. + +## Prerequisites + +- User profile created and chat engine running (`startChat()` completed) +- At least one established contact or group conversation +- `ChatModel.shared` populated with chat list data + +## Step-by-Step Processes + +### 1. Send Text Message + +1. User navigates to a conversation (direct or group) via `ChatListView` -> `ChatView`. +2. User types text into `ComposeView`'s `SendMessageView` text editor. +3. Link previews are detected and fetched asynchronously (`ComposeLinkView`). +4. User taps the send button. +5. `ComposeView` builds a `ComposedMessage`: + ```swift + ComposedMessage( + fileSource: nil, + quotedItemId: nil, + msgContent: .text("Hello"), + mentions: [:] + ) + ``` +6. Calls `apiSendMessages(type:id:scope:live:ttl:composedMessages:sendAsGroup:)` (where `sendAsGroup` defaults to `false`; set to `true` when a channel owner sends as the channel identity). +7. Internally dispatches `ChatCommand.apiSendMessages(...)` to the Haskell core. +8. Core encrypts, queues via SMP, and returns `ChatResponse1.newChatItems(user, aChatItems)`. +9. `processSendMessageCmd` extracts `[ChatItem]` from response. +10. For direct chats, a background task tracks delivery via `chatModel.messageDelivery`. +11. `ChatModel` updates, UI refreshes to show the new message. + +### 2. Send Media (Image/Video/File) + +1. User taps the attachment button in `ComposeView`. +2. **Image**: Picked via `PhotosPicker` or camera. Compressed to <=255KB. Sent inline with `.image(text, base64Image)` content type. +3. **Video**: Picked from library. Thumbnail generated. Video file sent via XFTP for files >255KB. Content type: `.video(text, thumbnail, duration)`. +4. **File**: Picked via document picker. If <=255KB, sent inline. If >255KB, uploaded via XFTP (up to 1GB). Content type: `.file(text)`. +5. `ComposedMessage` includes `fileSource: CryptoFile(filePath:)`. +6. `apiSendMessages(...)` called with the composed message array. +7. Core handles XFTP upload for large files (chunked, encrypted upload to XFTP servers). +8. Recipient receives file reference and can download. + +### 3. Receive Message + +1. `ChatReceiver.shared` runs `receiveMsgLoop()` continuously calling `chatRecvMsg()`. +2. Core delivers events via `APIResult`. +3. On `ChatEvent.newChatItems(user, chatItems)`: + - `processReceivedMsg` is called. + - For the active user, `ChatModel` is updated with new items. + - If the chat is currently open, `ItemsModel` appends to `reversedChatItems`. + - `NtfManager` posts a local notification if the app is in the background. +4. Small files/images attached to incoming messages are auto-received if within size thresholds. + +### 4. Edit Message + +1. User long-presses a sent message -> selects "Edit" from context menu. +2. `ComposeView` enters edit mode with the original text pre-filled. +3. User modifies text and taps send. +4. Calls `apiUpdateChatItem(type:id:scope:itemId:updatedMessage:live:)`. +5. Dispatches `ChatCommand.apiUpdateChatItem(...)`. +6. Core returns `ChatResponse1.chatItemUpdated(user, aChatItem)` or `.chatItemNotChanged(user, aChatItem)`. +7. `ChatModel` updates the item in place. Edit timestamp is shown in the UI. + +### 5. Delete Message + +1. User long-presses a message -> selects "Delete". +2. Presented with options: + - **Delete for me** (`CIDeleteMode.cidmInternal`) -- removes locally only. + - **Delete for everyone** (`CIDeleteMode.cidmBroadcast`) -- sends deletion to recipient(s). +3. Calls `apiDeleteChatItems(type:id:scope:itemIds:mode:)`. +4. Dispatches `ChatCommand.apiDeleteChatItem(type:id:scope:itemIds:mode:)`. +5. Core returns `ChatResponse1.chatItemsDeleted(user, items, _)` containing `[ChatItemDeletion]`. +6. For group messages from other members, admin/owner can call `apiDeleteMemberChatItems(groupId:itemIds:)`. +7. `ChatModel` removes or replaces items with "deleted" placeholders. + +### 6. React to Message + +1. User long-presses a message -> selects "React" -> picks an emoji. +2. Calls `apiChatItemReaction(type:id:scope:itemId:add:reaction:)`. +3. `reaction` is `MsgReaction` (e.g., `.emoji(.heart)`). +4. `add: true` to add, `add: false` to remove. +5. Core returns `ChatResponse1.chatItemReaction(user, _, reaction)`. +6. The reaction is displayed below the message bubble. + +### 7. Reply to Message + +1. User long-presses a message -> selects "Reply". +2. `ComposeView` enters reply mode, showing quoted message in `ContextItemView`. +3. User types reply text and taps send. +4. `ComposedMessage` is created with `quotedItemId: originalItem.id`. +5. `apiSendMessages(...)` sends with the quote reference. +6. Recipient sees the reply with the quoted context rendered above. + +### 8. Forward Message + +1. User long-presses a message -> selects "Forward". +2. `ChatItemForwardingView` is presented for destination chat selection. +3. `apiPlanForwardChatItems(type:id:scope:itemIds:)` validates what can be forwarded, returns `([Int64], ForwardConfirmation?)`. +4. User confirms and selects destination chat. +5. Calls `apiForwardChatItems(toChatType:toChatId:toScope:fromChatType:fromChatId:fromScope:itemIds:ttl:sendAsGroup:)` (where `sendAsGroup` defaults to `false`). +6. Core returns `ChatResponse1.newChatItems(...)` with the forwarded items in the destination chat. + +### 9. Voice Message + +1. User taps and holds the microphone button in `ComposeView`. +2. `AudioRecPlay` starts recording to a temporary file. +3. On release, recording stops. Duration is calculated (max 5 minutes / 300 seconds). +4. `ComposedMessage` created with: + - `fileSource: CryptoFile` pointing to the audio file + - `msgContent: .voice(text: "", duration: seconds)` +5. `apiSendMessages(...)` sends the voice message. +6. Voice messages <=510KB sent inline; larger via XFTP. +7. Recipient sees `CIVoiceView` with waveform and playback controls. + +### 10. Delivery Tracking + +1. On send, message status starts as `CIStatus.sndNew`. +2. After SMP delivery: `CIStatus.sndSent(sndProgress)`. +3. When delivered to recipient's agent: status updates to delivered. +4. If delivery receipts are enabled by both parties, read status is reported. +5. Failed delivery results in `CIStatus.sndError*` or `CIStatus.sndWarning*`. +6. Status is displayed via `CIMetaView` (checkmarks/indicators). + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `ComposedMessage` | `SimpleXChat/APITypes.swift` | Outgoing message: fileSource, quotedItemId, msgContent, mentions | +| `MsgContent` | `SimpleXChat/ChatTypes.swift` | Enum: `.text`, `.link`, `.image`, `.video`, `.voice`, `.file` | +| `CIContent` | `SimpleXChat/ChatTypes.swift` | Chat item content wrapper with sent/received variants | +| `CIStatus` | `SimpleXChat/ChatTypes.swift` | Delivery status: sndNew, sndSent, sndError, rcvNew, rcvRead | +| `CIDirection` | `SimpleXChat/ChatTypes.swift` | `.directSnd`, `.directRcv`, `.groupSnd`, `.groupRcv(groupMember)`, `.channelRcv` | +| `ChatItem` | `SimpleXChat/ChatTypes.swift` | Full message model: content, meta, status, direction, quotedItem | +| `ChatItemDeletion` | `SimpleXChat/ChatTypes.swift` | Deleted item info with old/new item pairs | +| `CIDeleteMode` | `SimpleXChat/ChatTypes.swift` | `.cidmInternal` (local) or `.cidmBroadcast` (for everyone) | +| `MsgReaction` | `SimpleXChat/ChatTypes.swift` | Reaction type (emoji-based) | +| `UpdatedMessage` | `SimpleXChat/APITypes.swift` | Edited message content for update API | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `ChatError.errorAgent(.SMP(_, .AUTH))` | Recipient queue issue | Show "Connection error (AUTH)" alert | +| `ChatError.errorAgent(.BROKER(_, .TIMEOUT))` | Server timeout | Retryable: show retry dialog via `chatApiSendCmdWithRetry` | +| `ChatError.errorAgent(.BROKER(_, .NETWORK))` | Network failure | Retryable: show retry dialog | +| Send message error | Core processing failure | `sendMessageErrorAlert` shown to user | +| `chatItemNotChanged` | Edit with identical content | No error, item returned unchanged | +| File too large (>1GB) | XFTP limit exceeded | Prevented in UI file picker | +| `fileNotApproved` | Unknown XFTP relay servers | Show "Unknown servers!" alert with approve option | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | Message composition UI and send logic | +| `Shared/Views/Chat/ComposeMessage/SendMessageView.swift` | Text input and send button | +| `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` | Reply/edit context display | +| `Shared/Views/Chat/ChatItemView.swift` | Per-message rendering dispatcher | +| `Shared/Views/Chat/ChatItem/MsgContentView.swift` | Text message content with markdown | +| `Shared/Views/Chat/ChatItem/CIMetaView.swift` | Delivery status indicators | +| `Shared/Views/Chat/ChatItemForwardingView.swift` | Forward destination picker | +| `Shared/Views/Chat/ChatItemInfoView.swift` | Message info (delivery details, timestamps) | +| `Shared/Model/SimpleXAPI.swift` | API functions: `apiSendMessages`, `apiUpdateChatItem`, `apiDeleteChatItems`, `apiChatItemReaction`, `apiForwardChatItems` | +| `SimpleXChat/APITypes.swift` | `ComposedMessage`, `ChatCommand` enum, response types | +| `SimpleXChat/ChatTypes.swift` | `MsgContent`, `CIContent`, `CIStatus`, `CIDirection`, `ChatItem` | +| `Shared/Model/AudioRecPlay.swift` | Voice message recording/playback engine | + +## Related Specifications + +- `apps/ios/product/views/chat.md` -- Chat view UI specification +- `apps/ios/product/README.md` -- Product overview and capability map diff --git a/apps/ios/product/flows/onboarding.md b/apps/ios/product/flows/onboarding.md new file mode 100644 index 0000000000..5e2e04d42a --- /dev/null +++ b/apps/ios/product/flows/onboarding.md @@ -0,0 +1,239 @@ +# Onboarding Flow + +> **Related spec:** [spec/architecture.md](../../spec/architecture.md) | [spec/database.md](../../spec/database.md) + +## Overview + +First-time setup and migration flows for SimpleX Chat iOS. Covers app initialization, profile creation, server operator selection, notification configuration, and database import/export for device migration. The app uses a Haskell runtime for its core chat engine, with SQLite databases shared between the main app and the Notification Service Extension (NSE). + +## Prerequisites + +- Fresh install of SimpleX Chat from the App Store, or +- Existing install with database archive for import/migration +- iOS 15+ with App Group entitlement configured + +## Step-by-Step Processes + +### 1. App Initialization Sequence + +On every app launch, `SimpleXApp.init()` executes the following in order: + +``` +1. haskell_init() -- Start Haskell runtime system (GHC RTS) +2. UserDefaults.standard.register(defaults:) -- Set default preferences (appDefaults) +3. setGroupDefaults() -- Configure app group shared defaults +4. registerGroupDefaults() -- Register group container defaults +5. setDbContainer() -- Configure database paths in app group container +6. BGManager.shared.register() -- Register background task handlers +7. NtfManager.shared.registerCategories() -- Register notification action categories +``` + +Then in `ContentView.onAppear`: +- If no migration is in progress and authentication is set up, `initChatAndMigrate()` is called. +- This triggers `chatMigrateInit()` to initialize/migrate databases. +- Then `startChat()` is called to start the chat engine. + +### 2. Fresh Install -- Onboarding Steps + +Onboarding is managed by `OnboardingStage` enum and `OnboardingView`: + +**Step 1: SimpleX Info** (`step1_SimpleXInfo`) +1. `SimpleXInfo` view is presented. +2. Explains SimpleX's architecture: no user identifiers, E2E encryption, decentralized servers. +3. User taps "Create your profile" to proceed. + +**Step 2: Create Profile** (`step2_CreateProfile` -- now inline in step 1) +1. `CreateFirstProfile` view (embedded in the onboarding flow). +2. User enters display name (required). Full name is set to empty string. +3. Display name is validated via `mkValidName()` and `canCreateProfile()`. +4. On "Create": + ```swift + AppChatState.shared.set(.active) + m.currentUser = try apiCreateActiveUser(profile) + try startChat() + ``` +5. `apiCreateActiveUser(Profile(displayName:fullName:shortDescr:))` creates the user in the Haskell core. +6. `startChat()` initializes the chat engine. +7. Onboarding advances to `step3_ChooseServerOperators`. + +**Step 3: Choose Server Operators** (`step3_ChooseServerOperators`) +1. `OnboardingConditionsView` is presented (simplified conditions acceptance). +2. User reviews and accepts server operator conditions. +3. This configures which SMP/XFTP server operators to use. +4. Advances to `step4_SetNotificationsMode`. + +**Step 4: Set Notifications** (`step4_SetNotificationsMode`) +1. `SetNotificationsMode` view is presented. +2. Three options: + - **Instant**: Requires Apple Push Notification service. Registers device token via `apiRegisterToken(token:notificationMode:)`. + - **Periodic**: Uses iOS background app refresh. No push token needed. + - **Off**: No notifications. +3. For instant mode: `apiRegisterToken` sends `ChatCommand.apiRegisterToken(token:notificationMode:)` and receives `ChatResponse2.ntfTokenStatus(status)`. +4. On completion: `onboardingStageDefault.set(.onboardingComplete)`. + +**Onboarding Complete** (`onboardingComplete`) +1. `ChatListView` is shown. +2. Empty state displays "Add contact" prompt via `ChatHelp`. +3. If delivery receipts haven't been configured: `chatModel.setDeliveryReceipts = true` triggers a prompt. + +### 3. startChat() -- Chat Engine Startup + +Called after profile creation or on subsequent app launches: + +```swift +func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { + 1. setNetworkConfig(getNetCfg()) -- Apply network configuration + 2. apiCheckChatRunning() -- Check if already running + 3. listUsers() -- Load all user profiles + 4. getUserChatData() -- Load chats, tags, address, TTL + 5. NtfManager.shared.setNtfBadgeCount(...) -- Set badge count + 6. refreshCallInvitations() -- Check pending call invitations + 7. apiGetNtfToken() -- Get notification token status + 8. apiStartChat() -- Start the Haskell chat engine + 9. registerToken(token:) -- Register push token if available + 10. ChatReceiver.shared.start() -- Start message receive loop +} +``` + +### 4. Database Setup + +**Location:** +- App group container (shared with NSE): determined by `dbContainerGroupDefault` +- Path prefix: `simplex_v1` (`DB_FILE_PREFIX`) +- Chat database: `simplex_v1_chat.db` (messages, contacts, groups, settings) +- Agent database: `simplex_v1_agent.db` (SMP connections, encryption keys, queues) + +**Initialization:** +- `chatMigrateInit(useKey:confirmMigrations:backgroundMode:)` in `SimpleXChat/API.swift`. +- Creates databases if they do not exist. +- Runs pending migrations with confirmation mode. +- Handles database encryption: + - If keychain storage enabled: generates random DB key on first run (`randomDatabasePassword()`). + - Stores key in keychain via `kcDatabasePassword`. + - `initialRandomDBPassphraseGroupDefault` tracks whether using auto-generated key. + +**Encryption:** +- Optional database encryption passphrase via `DatabaseEncryptionView`. +- `apiStorageEncryption(currentKey:newKey:)` changes encryption key. +- `testStorageEncryption(key:)` validates a key against the database. + +### 5. Database Export (Source Device) + +1. User navigates to Settings -> Database -> "Export database". +2. Chat must be stopped first for data consistency. +3. Calls `apiExportArchive(config: ArchiveConfig)`: + ```swift + func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] + ``` +4. Core creates a ZIP archive containing both databases and file attachments. +5. Returns any non-fatal `[ArchiveError]` (e.g., file access issues). +6. User transfers the archive to the new device via AirDrop, file share, etc. + +### 6. Database Import (Destination Device) + +1. On new device: during onboarding or Settings -> Database -> "Import database". +2. User selects the archive file. +3. Calls `apiImportArchive(config: ArchiveConfig)`: + ```swift + func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] + ``` +4. Core extracts the archive, replacing local databases. +5. Returns any non-fatal `[ArchiveError]`. +6. Chat engine is restarted with the imported data. +7. All contacts, groups, messages, and settings are restored. + +### 7. In-App Device Migration + +An alternative to manual export/import using direct device-to-device transfer. + +**Source device** (`MigrateFromDevice` view): +1. User navigates to Settings -> Database -> "Migrate to another device". +2. App creates a temporary database and uploads archive via XFTP standalone file. +3. Generates a migration link containing the file URL and encryption key. +4. Displays QR code / share link for the destination device. + +**Destination device** (`MigrateToDevice` view): +1. On new device: onboarding detects migration state or user selects "Migrate". +2. Scans/pastes the migration link. +3. `downloadStandaloneFile(user:url:file:ctrl:)` downloads the archive from XFTP. +4. `standaloneFileInfo(url:ctrl:)` validates the file metadata. +5. Archive is imported, databases are restored. +6. `chatInitTemporaryDatabase(url:key:confirmation:)` may be used for temporary DB operations during migration. +7. Chat engine starts with the migrated data. + +If migration is interrupted: +- `chatModel.migrationState` preserves state across app restarts. +- On next launch, `ContentView.onAppear` detects pending migration and resumes. + +### 8. Additional Profile Creation (Multi-Account) + +1. From `UserPicker` (profile switcher) -> "Add profile". +2. `CreateProfile` view is presented (distinct from `CreateFirstProfile`). +3. User enters display name and optional bio (max 160 bytes JSON-encoded, `MAX_BIO_LENGTH_BYTES`). +4. `apiCreateActiveUser(profile)` creates additional user. +5. `listUsers()` and `getUserChatData()` refresh the model. +6. No onboarding steps -- goes directly to chat list. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `OnboardingStage` | `Shared/Views/Onboarding/OnboardingView.swift` | Enum: `step1_SimpleXInfo`, `step2_CreateProfile`, `step3_ChooseServerOperators`, `step4_SetNotificationsMode`, `onboardingComplete` | +| `Profile` | `SimpleXChat/ChatTypes.swift` | `displayName`, `fullName`, `image`, `shortDescr` | +| `User` | `SimpleXChat/ChatTypes.swift` | Full user model with profile, userId, and settings | +| `ArchiveConfig` | `SimpleXChat/APITypes.swift` | Configuration for database export/import | +| `DBMigrationResult` | `SimpleXChat/API.swift` | Result of database migration: `.ok`, `.errorNotADatabase`, `.errorKeychain`, etc. | +| `MigrationConfirmation` | `SimpleXChat/API.swift` | Migration confirmation mode: `.error`, `.yesUp`, `.yesUpDown` | +| `DeviceToken` | `SimpleXChat/ChatTypes.swift` | Apple push notification device token | +| `NtfTknStatus` | `SimpleXChat/ChatTypes.swift` | Notification token status: registered, active, expired, etc. | +| `NotificationsMode` | `SimpleXChat/ChatTypes.swift` | `.off`, `.periodic`, `.instant` | +| `MigrationFileLinkData` | Used in standalone file transfers for device migration | +| `AppChatState` | `SimpleXChat/` | Shared state: `.active`, `.stopped`, `.suspended` | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `DBMigrationResult.errorNotADatabase` | Wrong encryption key or corrupt DB | Show `DatabaseErrorView` with options | +| `DBMigrationResult.errorKeychain` | Keychain access failed | Show error, offer to re-enter passphrase | +| `DBMigrationResult.errorMigration` | Schema migration failure | Show error with migration details | +| `duplicateUserError` | Display name already in use | `UserProfileAlert.duplicateUserError` | +| `invalidDisplayNameError` | Invalid characters in display name | `UserProfileAlert.invalidDisplayNameError` | +| `createUserError` | Core failed to create user | Alert with error details | +| `invalidNameError(validName)` | Name needs normalization | Alert suggesting the valid name | +| Archive import errors | Missing files, version mismatch | Non-fatal `[ArchiveError]` displayed | +| Migration interrupted | Network failure, app killed | State preserved in `chatModel.migrationState`, resumed on next launch | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/SimpleXApp.swift` | App entry point: `haskell_init`, defaults registration, DB container setup, BG tasks | +| `Shared/AppDelegate.swift` | Push notification registration, URL handling | +| `Shared/ContentView.swift` | Root view: authentication, onboarding routing, chat initialization | +| `Shared/Views/Onboarding/OnboardingView.swift` | Onboarding step router, `OnboardingStage` enum | +| `Shared/Views/Onboarding/SimpleXInfo.swift` | Step 1: Privacy architecture explanation | +| `Shared/Views/Onboarding/CreateProfile.swift` | Profile creation: `CreateProfile` (additional) and `CreateFirstProfile` (onboarding) | +| `Shared/Views/Onboarding/ChooseServerOperators.swift` | Step 3: Server operator conditions | +| `Shared/Views/Onboarding/SetNotificationsMode.swift` | Step 4: Notification mode selection | +| `Shared/Views/Onboarding/CreateSimpleXAddress.swift` | Optional address creation during onboarding | +| `Shared/Views/Onboarding/HowItWorks.swift` | Educational content about SimpleX protocol | +| `Shared/Views/Migration/MigrateFromDevice.swift` | Source device migration UI | +| `Shared/Views/Migration/MigrateToDevice.swift` | Destination device migration UI | +| `Shared/Views/Database/DatabaseView.swift` | Database management: export, import, encryption | +| `Shared/Views/Database/DatabaseEncryptionView.swift` | Database passphrase management | +| `Shared/Views/Database/DatabaseErrorView.swift` | Database error recovery UI | +| `Shared/Views/Database/MigrateToAppGroupView.swift` | Legacy migration from Documents to App Group container | +| `Shared/Model/SimpleXAPI.swift` | `startChat`, `apiCreateActiveUser`, `apiExportArchive`, `apiImportArchive`, `apiRegisterToken` | +| `SimpleXChat/API.swift` | `chatMigrateInit`, `chatInitTemporaryDatabase`, low-level DB initialization | +| `SimpleXChat/FileUtils.swift` | DB file paths, constants (`DB_FILE_PREFIX`, `CHAT_DB`, `AGENT_DB`) | +| `SimpleXChat/AppGroup.swift` | App group container configuration | +| `SimpleXChat/KeyChain.swift` | Keychain access for DB passphrase and app passwords | +| `Shared/Model/BGManager.swift` | Background task registration and scheduling | +| `Shared/Model/NtfManager.swift` | Notification management and badge counts | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: architecture and capabilities +- `apps/ios/product/flows/connection.md` -- After onboarding, user establishes first connections +- `apps/ios/product/flows/messaging.md` -- Messaging starts after profile creation diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md new file mode 100644 index 0000000000..50d6bf2938 --- /dev/null +++ b/apps/ios/product/gaps.md @@ -0,0 +1,64 @@ +# SimpleX Chat iOS -- Known Gaps & Recommendations + +> Aggregation of `[GAP]` and `[REC]` annotations discovered during specification analysis. Organized by product area. +> +> **Related spec:** [spec/README.md](../spec/README.md) + +--- + +## UI: Error Feedback + +### GAP: No user-visible error on FFI command failure +**Source:** [spec/architecture.md](../spec/architecture.md) +API calls via `chatApiSendCmd` return `APIResult` which can be `.error(ChatError)`. Not all error cases surface user-visible feedback in the UI. + +**REC:** Audit all `chatApiSendCmd` call sites and ensure `.error` cases show appropriate alerts or banners. + +--- + +## UI: Loading States + +### GAP: No loading indicator during initial chat list population +**Source:** [spec/client/chat-list.md](../spec/client/chat-list.md) +When `ChatModel.chatInitialized` transitions to `true`, the chat list appears fully formed. There is no intermediate loading state for users with large numbers of chats. + +**REC:** Add a progress indicator during `apiGetChats` for users with 100+ conversations. + +--- + +## Flows: Group Lifecycle + +### GAP: Bulk member role change — API supports batch but UI uses single-member calls +**Source:** [spec/api.md](../spec/api.md) +`APIMembersRole` accepts `NonEmpty GroupMemberId`, supporting batch role changes at the API level. However, the iOS UI (`GroupMemberInfoView.swift`) currently invokes it with a single member at a time. + +**REC:** Expose batch role change in the UI for group admins managing large groups. + +--- + +## Security + +### GAP: Database passphrase not enforced by default +**Source:** [spec/database.md](../spec/database.md) +Database encryption is optional and requires the user to manually set a passphrase. New installations start with an unencrypted database. + +**REC:** Consider prompting users to set a database passphrase during onboarding, especially on devices without hardware encryption. + +### GAP: No forward secrecy indicator in UI +**Source:** [product/glossary.md](glossary.md) +While the double-ratchet protocol provides forward secrecy, there is no UI indicator showing whether a specific conversation has achieved forward secrecy (i.e., completed initial key exchange ratcheting). + +**REC:** Add a security indicator in contact/group info showing ratchet state. + +--- + +## Documentation + +### GAP: Haskell Store layer not fully specified +**Source:** [spec/database.md](../spec/database.md) +The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages.hs`, etc.) are referenced by function name but not fully specified with parameter types and return types. + +**REC:** Expand database spec with key Store function signatures as the specification matures. + +--- + diff --git a/apps/ios/product/glossary.md b/apps/ios/product/glossary.md new file mode 100644 index 0000000000..7b2e227ffa --- /dev/null +++ b/apps/ios/product/glossary.md @@ -0,0 +1,253 @@ +# SimpleX Chat iOS -- Glossary + +> SimpleX Chat iOS domain glossary. Defines all domain terms used in SimpleX Chat with links to relevant specifications and source code. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Protocols & Cryptography](#protocols--cryptography) +2. [Core Data Types](#core-data-types) +3. [Commands & Events](#commands--events) +4. [Connection & Identity](#connection--identity) +5. [Messaging Features](#messaging-features) +6. [Calling & Media](#calling--media) +7. [Notifications & Background](#notifications--background) +8. [Application Architecture](#application-architecture) +9. [Configuration & Preferences](#configuration--preferences) + +--- + +## Protocols & Cryptography + +### SMP (Simplex Messaging Protocol) +The core messaging protocol used for asynchronous message delivery through relay servers. Each conversation uses separate unidirectional queues, and sender and receiver queues have no shared identifier. Defined in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/simplex-messaging.md`, implementation `simplexmq/src/Simplex/Messaging/Protocol.hs`* + +### SMP Server +A relay server that stores and forwards encrypted messages between parties. Users can configure custom SMP servers or use defaults. Servers cannot see message contents or correlate senders with receivers. *See: `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift`* + +### XFTP (eXtended File Transfer Protocol) +A protocol for transferring large files (up to 1GB) through relay servers. Files are encrypted, split into chunks, and uploaded to XFTP servers. Recipients download and reassemble chunks independently. Defined in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/xftp.md`, implementation `simplexmq/src/Simplex/FileTransfer/Protocol.hs`; chat-level integration `../../src/Simplex/Chat/Files.hs`* + +### XFTP Server +A relay server that stores encrypted file chunks for asynchronous file transfer. Like SMP servers, users can configure custom XFTP servers. *See: `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift`* + +### SMP Agent +The lower-level agent library (in [simplexmq](https://github.com/simplex-chat/simplexmq)) that manages SMP connections, queue creation/rotation, duplex connection establishment, message delivery, and the double-ratchet encryption protocol. The chat application layer communicates with the agent via its functional API. *See: protocol spec `simplexmq/protocol/agent-protocol.md`, implementation `simplexmq/src/Simplex/Messaging/Agent.hs`; chat-level integration `../../src/Simplex/Chat/Controller.hs`* + +### Double Ratchet +The key agreement protocol used for E2E encryption. Provides forward secrecy and break-in recovery by deriving new encryption keys for each message. Based on the Signal protocol's double-ratchet algorithm, augmented with post-quantum KEM (PQDR). Implemented in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/pqdr.md`, implementation `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs`* + +### Post-Quantum Encryption +Optional quantum-resistant key exchange (PQ) available for direct chats. Uses a hybrid scheme combining classical X25519 with Streamlined NTRU-Prime 761 (sntrup761) KEM. The hybrid secret is SHA3-256(DH_secret || KEM_shared_secret). Implemented in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/pqdr.md`, implementation `simplexmq/src/Simplex/Messaging/Crypto/SNTRUP761.hs`; Swift types `SimpleXChat/ChatTypes.swift` (PQEncryption, PQSupport)* + +### E2E Encryption +End-to-end encryption ensuring that only the communicating parties can read message contents. Neither SMP relay servers nor any network observer can decrypt messages. All SimpleX Chat messages are E2E encrypted by default using the double-ratchet protocol. *See: `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs` (ratchet implementation), `simplexmq/src/Simplex/Messaging/Agent/Protocol.hs` (E2E message envelopes)* + +### Forward Secrecy +A property of the double-ratchet protocol ensuring that compromise of current encryption keys does not compromise past session keys. Each message uses a derived key that is deleted after use. *See: `simplexmq/protocol/pqdr.md`, `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs`* + +### Chat Protocol (x-events) +The chat-level protocol defining message envelopes and content types exchanged between chat participants. Includes x-events (XMsgNew, XMsgUpdate, XMsgDel, XCallInv, XFileCancel, XGrpMemNew, etc.), MsgContent (text, image, video, voice, file, link), and message encoding (Binary/JSON). This is distinct from the lower-level SMP transport protocol. *See: `../../src/Simplex/Chat/Protocol.hs`* + +### Security Code +A hash of the shared encryption session displayed as a numeric code and QR code. Contacts can compare security codes out-of-band to verify they have an uncompromised E2E session. *See: `Shared/Views/Chat/VerifyCodeView.swift`, `../../src/Simplex/Chat/Controller.hs` (APIVerifyContact)* + +--- + +## Core Data Types + +### ChatItem +The fundamental unit of content in a conversation. Represents a single message, event, call record, or system notification within a chat. Each ChatItem has direction (sent/received), content, metadata, and optional quoted context. *See: `../../src/Simplex/Chat/Messages.hs` (data ChatItem), `SimpleXChat/ChatTypes.swift`* + +### ChatInfo +A type-safe wrapper identifying a conversation and its metadata. Variants: DirectChat (1:1 with Contact), GroupChat (with GroupInfo), LocalChat (note folder), ContactRequest, ContactConnection. *See: `../../src/Simplex/Chat/Messages.hs` (data ChatInfo), `SimpleXChat/ChatTypes.swift`* + +### CIContent +The content payload of a ChatItem. Differentiates sent vs. received content types: message content (text/image/file/voice/link), deletion markers, call records, group events, and feature preference changes. *See: `../../src/Simplex/Chat/Messages/CIContent.hs` (data CIContent)* + +### User +A local user profile within the app. Each user has an independent set of contacts, groups, and connections. Multiple users can exist in one app installation. Fields include userId, profile, display name, and optional view password hash for hidden profiles. *See: `../../src/Simplex/Chat/Types.hs` (data User), `Shared/Model/ChatModel.swift`* + +### Contact +A remote party with whom the user has an established E2E encrypted connection. Stores the contact's profile, local alias, connection status, feature preferences, and UI settings. *See: `../../src/Simplex/Chat/Types.hs` (data Contact), `SimpleXChat/ChatTypes.swift`* + +### GroupInfo +Metadata for a group conversation including group profile, member count, preferences, and membership status. Contains the user's own membership record as a GroupMember. *See: `../../src/Simplex/Chat/Types.hs` (data GroupInfo)* + +### GroupMember +A participant in a group conversation. Each member has a role, status, profile, and optionally a direct connection. The user's own membership is also represented as a GroupMember within GroupInfo. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMember)* + +### Connection +A low-level SMP agent connection between two parties. Each connection has a status (new, joined, ready, deleted), an agent connection ID, and is associated with a specific contact or group member. *See: `../../src/Simplex/Chat/Types.hs` (data Connection)* + +### ConnStatus +The lifecycle state of a Connection: ConnNew (created, awaiting join), ConnJoined (joined, handshake in progress), ConnReady (fully established), ConnDeleted (terminated). *See: `../../src/Simplex/Chat/Types.hs` (data ConnStatus)* + +### ContactStatus +The status of a contact record: CSActive (normal), CSDeleted (deleted by contact), CSDeletedByUser (deleted by user). *See: `../../src/Simplex/Chat/Types.hs` (data ContactStatus)* + +### GroupMemberRole +Hierarchical role assigned to a group member. From most to least privileged: GROwner, GRAdmin, GRModerator, GRMember, GRObserver, GRRelay. Roles determine permissions for sending messages, managing members, and moderating content. The `.relay` role is below `.observer` and is used for relay members in channels. *See: `../../src/Simplex/Chat/Types/Shared.hs` (data GroupMemberRole), `SimpleXChat/ChatTypes.swift` L2806* + +### GroupMemberStatus +The lifecycle state of a group member: GSMemRejected, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown, GSMemInvited, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete, GSMemCreator, GSMemPendingReview, GSMemPendingApproval. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMemberStatus)* + +### FileTransfer +Represents an in-progress or completed file transfer. Variants: FTSnd (sending, with metadata and per-recipient transfer records) and FTRcv (receiving). Tracks protocol (SMP inline or XFTP), progress, and encryption parameters. *See: `../../src/Simplex/Chat/Types.hs` (data FileTransfer)* + +### ChatTag +A user-defined label for organizing conversations in the chat list. Each tag has a text label and optional emoji. Chats can have multiple tags, and the chat list can be filtered by tag. *See: `../../src/Simplex/Chat/Types.hs` (data ChatTag), `Shared/Views/ChatList/TagListView.swift`* + +### Channel +A group that uses relay infrastructure for message delivery (`groupInfo.useRelays == true`). Channels decouple the message sender from direct group membership connections, routing messages through relay members instead. Channels display the `antenna.radiowaves.left.and.right` SF Symbol as their icon and render received messages with the group avatar and "channel" role label. *See: [spec/state.md](../spec/state.md) (Relay-Related Data Model), [spec/client/chat-view.md](../spec/client/chat-view.md) (Channel Message Rendering), `SimpleXChat/ChatTypes.swift` (GroupInfo.useRelays, GroupInfo.chatIconName)* + +### RelayStatus +The lifecycle state of a relay member in a channel: `.rsNew` (created), `.rsInvited` (invitation sent), `.rsAccepted` (accepted by relay), `.rsActive` (fully operational). *See: `SimpleXChat/ChatTypes.swift` L2506* + +### GroupRelay +A struct representing a relay instance for a group. Contains the relay's database ID (`groupRelayId`), associated group member ID, user chat relay ID, relay status, and optional relay link (per-group link for subscribers). *See: `SimpleXChat/ChatTypes.swift` L2555* + +### UserChatRelay +A struct representing a user's chat relay configuration. Contains the relay's database ID (`chatRelayId`), SMP server address, name, domains, and flags for preset/tested/enabled/deleted status. *See: `SimpleXChat/ChatTypes.swift` L2513* + +### GroupShortLinkInfo +Information about a group's short link including whether it's a direct link, associated relay hostnames, and shared group identifier. Transient data returned by `APIConnectPreparedGroup` — not persisted on GroupInfo. *See: `Shared/Model/AppAPITypes.swift` L1352* + +### CIDirection.channelRcv +A chat item direction case for messages received via a channel relay, as opposed to `.groupRcv` for standard group messages. *See: `SimpleXChat/ChatTypes.swift` L3529* + +--- + +## Commands & Events + +### ChatCommand +A sum type representing all commands the UI can send to the chat controller. Examples: APISendMessages, APIGetChat, APIConnect, APINewGroup, APIDeleteChatItem. Commands are serialized and dispatched through the FFI bridge. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatCommand)* + +### ChatResponse +A sum type representing synchronous responses from the chat controller to the UI after processing a ChatCommand. Examples: CRActiveUser, CRNewChatItems, CRChatItemUpdated. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatResponse)* + +### ChatEvent +A sum type representing asynchronous events pushed from the chat controller to the UI. These are unsolicited notifications about state changes: incoming messages, connection status changes, call invitations, etc. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatEvent)* + +### ChatError +Error types returned by the chat controller. Variants: ChatError (application-level), ChatErrorAgent (SMP agent errors), ChatErrorStore (database errors), ChatErrorRemoteHost (remote desktop errors). *See: `../../src/Simplex/Chat/Controller.hs` (data ChatError)* + +--- + +## Connection & Identity + +### SimpleX Address +A long-lived contact address that others can use to send connection requests. Unlike one-time invitation links, an address can be reused by multiple contacts. The user can accept or reject each incoming request. *See: `Shared/Views/UserSettings/UserAddressView.swift`, `../../src/Simplex/Chat/Controller.hs` (APICreateMyAddress)* + +### Contact Link +A one-time or reusable URI that initiates a contact connection. When scanned or opened, it triggers the SMP handshake to establish an E2E encrypted channel between two parties. *See: `Shared/Views/NewChat/NewChatView.swift`* + +### Group Link +A shareable URI that allows new members to join a group. The link connects to the group host, who then introduces the new member to existing members. Configurable with a default member role. *See: `Shared/Views/Chat/Group/GroupLinkView.swift`, `../../src/Simplex/Chat/Types.hs` (data GroupLink)* + +### Short Link +A compact version of SimpleX contact or group links, using a shorter URI format for easier sharing. Contains encoded connection parameters with reduced character length. *See: `../../src/Simplex/Chat/Controller.hs`* + +### Incognito Mode +A privacy feature that generates a random profile (display name and avatar) for each new contact connection. The real user profile is never shared with incognito contacts. Can be toggled per-connection at invitation time. *See: `Shared/Views/UserSettings/IncognitoHelp.swift`, `../../src/Simplex/Chat/ProfileGenerator.hs`* + +### Hidden Profile +A user profile protected by a separate password. Hidden profiles do not appear in the user picker or profile list. To access a hidden profile, the user enters its password in the search field of the user picker. *See: `Shared/Views/UserSettings/HiddenProfileView.swift`, `../../src/Simplex/Chat/Controller.hs` (APIHideUser)* + +--- + +## Messaging Features + +### Delivery Receipt +A confirmation that a message was successfully delivered to the recipient's device. Displayed as a double-check indicator on sent messages. Can be enabled or disabled per contact or globally. *See: `Shared/Views/UserSettings/SetDeliveryReceiptsView.swift`, `../../src/Simplex/Chat/Controller.hs`* + +### Read Receipt +An indicator that a recipient has viewed a received message. Currently not implemented as a separate feature; delivery receipts serve as the primary delivery confirmation. *See: `Shared/Views/UserSettings/PrivacySettings.swift`* + +### Timed Message +A message with a configurable time-to-live (TTL). After the TTL expires, the message is automatically deleted from both sender and recipient devices. The TTL is set as a chat feature preference. Also referred to as a disappearing message. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (TimedMessagesPreference)* + +### Disappearing Message +Synonym for Timed Message. A message that self-destructs after a configured duration. The timer starts when the message is read by the recipient. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (TimedMessagesPreference)* + +### Message Integrity +Verification that messages are received in order and without gaps. The system detects skipped messages and decryption failures, displaying integrity error indicators in the chat. *See: `Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`, `../../src/Simplex/Chat/Messages/CIContent.hs`* + +### Decryption Error +An error occurring when a received message cannot be decrypted, typically due to ratchet synchronization issues. The UI displays a specific error view with recovery options. *See: `Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`, `../../src/Simplex/Chat/Messages/CIContent.hs`* + +--- + +## Calling & Media + +### CallKit +Apple's framework for integrating VoIP calls with the native iOS call UI. SimpleX Chat uses CallKit to display incoming calls on the lock screen, support call answering from the system UI, and manage audio sessions. *See: `Shared/Views/Call/CallController.swift`, `Shared/Views/Call/CallManager.swift`* + +### WebRTC +The real-time communication framework used for audio/video calls. SimpleX Chat wraps WebRTC in an E2E encrypted layer, with signaling performed through the existing SMP message channel rather than a central server. *See: `Shared/Views/Call/WebRTC.swift`, `Shared/Views/Call/WebRTCClient.swift`* + +### ICE Server +An Interactive Connectivity Establishment server used by WebRTC to discover network paths between call participants. SimpleX Chat supports configuring custom ICE servers. *See: `Shared/Views/UserSettings/RTCServers.swift`, `SimpleXChat/CallTypes.swift`* + +### TURN Server +A Traversal Using Relays around NAT server that relays WebRTC media when direct peer-to-peer connection is not possible. A specific type of ICE server. SimpleX Chat allows configuring custom TURN servers for call relay. *See: `Shared/Views/UserSettings/RTCServers.swift`* + +### RcvCallInvitation +An in-memory data structure representing an incoming call invitation. Contains the calling contact, call type (audio/video), encryption keys, and shared key for the WebRTC session. Not persisted to database. *See: `../../src/Simplex/Chat/Call.hs` (data RcvCallInvitation)* + +--- + +## Notifications & Background + +### Notification Service Extension (NSE) +An iOS app extension that processes incoming push notifications while the main app is not running. The NSE starts a temporary chat controller, decrypts the incoming message, and displays a notification with the message preview. *See: `SimpleX NSE/NotificationService.swift`, `SimpleX NSE/NSEAPITypes.swift`* + +### Background Task +An iOS background execution context used for periodic message fetching when instant notifications are not enabled. Managed by BGManager to check for new messages at system-determined intervals. *See: `Shared/Model/BGManager.swift`* + +--- + +## Application Architecture + +### chat_ctrl +The opaque C pointer to the Haskell chat controller, obtained via FFI initialization. All chat operations are dispatched through this controller handle. The main app and NSE maintain separate chat_ctrl instances. *See: `SimpleXChat/API.swift` (chatController, getChatCtrl)* + +### ComposeState +A Swift struct holding the current state of the message composition area. Tracks the message text, parsed markdown, preview, attached media, editing context, quote context, and voice recording state. *See: `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (struct ComposeState)* + +### ChatModel +The central observable model object for the iOS app. Holds all reactive state: current user, chat list, active chat, call state, app preferences, and navigation state. Published properties drive SwiftUI view updates. *See: `Shared/Model/ChatModel.swift` (class ChatModel)* + +### ItemsModel +An observable model managing the list of ChatItems displayed in a conversation view. Handles item loading, pagination, merging of new items, and secondary chat filtering. *See: `Shared/Model/ChatModel.swift` (class ItemsModel)* + +### AppTheme +An observable object encapsulating the current visual theme: name, base theme, color overrides, app-specific colors, and wallpaper configuration. Shared as an environment object across the SwiftUI view hierarchy. *See: `Shared/Theme/Theme.swift` (class AppTheme)* + +--- + +## Configuration & Preferences + +### FeaturePreference +A type class (Haskell) / protocol pattern representing a user's preference for a specific chat feature (e.g., timed messages, voice messages, calls). Each preference has an allow/enable setting and optional parameters. Feature preferences are negotiated between contacts. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (class FeatureI, type FeaturePreference)* + +### ChatSettings +Per-chat configuration including notification mode (all/mentions/off), send receipts toggle, favorite flag, and tag assignments. Stored per contact and per group. *See: `../../src/Simplex/Chat/Types.hs` (data ChatSettings)* + +### UserDefaults / GroupDefaults +iOS persistent key-value storage for app preferences. GroupDefaults (UserDefaults with the app group suite name) is shared between the main app and the NSE extension. Stores settings like notification mode, appearance preferences, and runtime flags. *See: `SimpleXChat/AppGroup.swift` (groupDefaults)* + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Concept index: [concepts.md](concepts.md) +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell chat protocol (x-events): `../../src/Simplex/Chat/Protocol.hs` +- Haskell messages: `../../src/Simplex/Chat/Messages.hs` +- Swift model: `Shared/Model/ChatModel.swift` +- Swift API types: `SimpleXChat/APITypes.swift`, `SimpleXChat/ChatTypes.swift` +- simplexmq library (SMP, XFTP, Agent, encryption): [github.com/simplex-chat/simplexmq](https://github.com/simplex-chat/simplexmq) diff --git a/apps/ios/product/rules.md b/apps/ios/product/rules.md new file mode 100644 index 0000000000..0cb3f8e96a --- /dev/null +++ b/apps/ios/product/rules.md @@ -0,0 +1,148 @@ +# SimpleX Chat iOS -- Business Rules + +> Business invariants enforced by the SimpleX Chat iOS app and Haskell core. Each rule states the invariant, where it is enforced, and links to the relevant spec. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/architecture.md](../spec/architecture.md) | [spec/state.md](../spec/state.md) + +--- + +## Security & Privacy + +### RULE-01: No user identifiers +**Rule:** The system MUST NOT assign, generate, or expose any persistent user identifier (phone number, email, username, UUID) that could be used to correlate a user across conversations. +**Enforced by:** SMP protocol design in simplexmq library; each connection uses independent unidirectional queues with no shared identifier. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-02: End-to-end encryption on all messages +**Rule:** All message content MUST be encrypted end-to-end using double-ratchet (with optional post-quantum KEM). The SMP server MUST NOT have access to plaintext. +**Enforced by:** simplexmq library (`Simplex.Messaging.Crypto.Ratchet`); encryption happens before `chat_send_cmd_retry` FFI call. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-03: Database encryption at rest +**Rule:** Both SQLite databases (chat and agent) MUST be encrypted with SQLCipher when the user sets a database passphrase. +**Enforced by:** `chat_migrate_init_key` in Haskell core via SQLCipher; `DatabaseEncryptionView.swift` in UI. +**Spec:** [spec/database.md](../spec/database.md) + +### RULE-04: Local authentication before content access +**Rule:** When app lock is enabled, the app MUST authenticate the user (Face ID, Touch ID, or passcode) before displaying any chat content. +**Enforced by:** `LocalAuthView.swift`, `ContentView.swift` (`contentViewAccessAuthenticated` guard on `ChatModel`). +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-05: Incognito profiles are per-connection +**Rule:** When incognito mode is used for a connection, the generated random profile MUST be unique to that connection and MUST NOT be reused across connections. +**Enforced by:** `ProfileGenerator.hs` generates fresh profile per connection; stored on the connection entity. +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Message Integrity + +### RULE-06: Message order preservation +**Rule:** Messages within a single connection MUST be displayed in the order determined by the SMP agent's sequence numbers, not by local timestamps. +**Enforced by:** `Store/Messages.hs` (`createNewChatItem` uses agent-assigned ordering); `ItemsModel` in `ChatModel.swift` preserves this order. +**Spec:** [spec/state.md](../spec/state.md) + +### RULE-07: Edited messages retain history +**Rule:** When a message is edited, the previous version MUST be preserved in `chat_item_versions` and accessible via the item info view. +**Enforced by:** `Controller.hs` (`APIUpdateChatItem`); `Store/Messages.hs` (`updateChatItem` creates version record); `ChatItemInfoView.swift` displays history. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-08: Deleted messages respect deletion mode +**Rule:** `CIDeleteMode.cidmBroadcast` sends deletion to recipient; `cidmInternal` only deletes locally. Moderation deletion (`cidmInternalMark`) marks the item but retains a placeholder. +**Enforced by:** `Controller.hs` (`APIDeleteChatItem` checks `CIDeleteMode`); `MarkedDeletedItemView.swift` renders moderation placeholders. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-09: Timed messages auto-delete after TTL +**Rule:** Messages with a TTL MUST be automatically deleted from local storage after the configured time-to-live expires. +**Enforced by:** `Controller.hs` (background task scheduling); `Store/Messages.hs` (TTL-based cleanup). +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Group Integrity + +### RULE-10: Role hierarchy enforcement +**Rule:** A member can only modify members with strictly lower roles. Owner > Admin > Moderator > Member > Observer. +**Enforced by:** `Controller.hs` (`APIMembersRole` validates role hierarchy); `GroupMemberInfoView.swift` restricts available actions in UI. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-11: Group creator is always owner +**Rule:** The user who creates a group MUST be assigned the `GROwner` role and cannot be demoted. +**Enforced by:** `Controller.hs` (`APINewGroup`); `Store/Groups.hs` (`createNewGroup` assigns owner role). +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-12: Group link role assignment +**Rule:** Members joining via group link MUST receive the role configured on the link (default: `GRMember`). Only admins and owners can create group links. +**Enforced by:** `Controller.hs` (`APICreateGroupLink` takes `memberRole` parameter); `GroupLinkView.swift` UI restricts to admin+. +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## File Transfer + +### RULE-13: File size limits +**Rule:** Files up to 1GB are transferred via XFTP. The system MUST reject files exceeding the configured maximum. +**Enforced by:** Haskell core (`Files.hs` checks file size); XFTP protocol enforces chunk limits. +**Spec:** [spec/services/files.md](../spec/services/files.md) + +### RULE-14: File encryption at rest +**Rule:** When `privacyEncryptLocalFiles` is enabled, downloaded files MUST be encrypted locally using AES with per-file random key/nonce stored in `CryptoFile`. +**Enforced by:** `CryptoFile.swift` (`encryptCryptoFile`, `decryptCryptoFile`); `Library/Commands.hs` uses `CryptoFileArgs` for file encryption. +**Spec:** [spec/services/files.md](../spec/services/files.md) + +--- + +## Notification Delivery + +### RULE-15: Notification preview respects privacy setting +**Rule:** Notification content MUST respect `NotificationPreviewMode`: `.message` shows full content, `.contact` shows sender only, `.hidden` shows generic alert. +**Enforced by:** `Notifications.swift` (notification content creation checks `ntfPreviewModeGroupDefault`); `NotificationService.swift` (NSE content generation). +**Spec:** [spec/services/notifications.md](../spec/services/notifications.md) + +### RULE-16: NSE database coordination +**Rule:** The NSE and main app MUST NOT write to the database simultaneously. File locks coordinate access. +**Enforced by:** `chat_close_store` / `chat_reopen_store` FFI calls; NSE uses short-lived database sessions. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +--- + +## Channel Integrity + +### RULE-19: Channel owner cannot leave own channel +**Rule:** A channel owner (`groupInfo.useRelays && groupInfo.isOwner`) who is the sole owner MUST NOT be able to leave the channel. The leave button is hidden in both swipe actions and context menu. +**Enforced by:** `ChatListNavLink.swift` (swipe/context menu guards), `GroupChatInfoView.swift` (leave button conditional). +**Spec:** [spec/client/chat-view.md](../spec/client/chat-view.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) + +### RULE-20: Relay members cannot be removed +**Rule:** Members with role `.relay` MUST NOT be removable through the member info UI. The remove button is hidden for relay members. +**Enforced by:** `GroupMemberInfoView.swift` (`mem.memberRole != .relay` guard on remove button). +**Spec:** [spec/client/chat-view.md](../spec/client/chat-view.md) + +### RULE-21: Relay links cannot be used to connect +**Rule:** SimpleX links with path `/r` (relay addresses) MUST be rejected when users attempt to connect. An explanatory alert is shown instead. +**Enforced by:** `ContentView.swift` (`connectViaUrl_` early return for `/r` path), `NewChatView.swift` (`planAndConnect` guard for `.simplexLink(_, .relay, _, _)`). +**Spec:** [spec/client/navigation.md](../spec/client/navigation.md) + +### RULE-22: Channel subscribers default to observer role +**Rule:** Members joining a channel via its link MUST receive the `.observer` role. The initial role picker is hidden for channels. +**Enforced by:** `AddChannelView.swift` (`groupLinkMemberRole: .observer` hardcoded), `GroupLinkView.swift` (role picker hidden when `isChannel`). +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-23: Channels default to history enabled +**Rule:** Newly created channels MUST have message history enabled by default (`GroupPreference(enable: .on)`). +**Enforced by:** `AddChannelView.swift` (`createChannel()` sets history preference). +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Call Integrity + +### RULE-17: Call encryption key exchange +**Rule:** WebRTC call encryption keys MUST be negotiated over the existing E2E encrypted SMP channel, not through any external signaling server. +**Enforced by:** `ActiveCallView.swift` sends call signaling via `apiSendCallInvitation`/`apiSendCallAnswer` which use SMP; `Call.hs` defines call protocol. +**Spec:** [spec/services/calls.md](../spec/services/calls.md) + +### RULE-18: CallKit region restriction +**Rule:** CallKit MUST be disabled in regions where it is restricted (China). The app uses in-app call UI as fallback. +**Enforced by:** `CallController.swift` checks `useCallKit()` based on region; `ActiveCallView.swift` provides fallback UI. +**Spec:** [spec/services/calls.md](../spec/services/calls.md) diff --git a/apps/ios/product/views/call.md b/apps/ios/product/views/call.md new file mode 100644 index 0000000000..f32f7ec243 --- /dev/null +++ b/apps/ios/product/views/call.md @@ -0,0 +1,122 @@ +# Audio / Video Call + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Purpose + +Make and receive end-to-end encrypted audio and video calls over WebRTC. Supports CallKit integration for native iOS call UI, picture-in-picture for video calls, audio device selection, and collapsible call overlay. + +## Route / Navigation + +- **Entry point (outgoing)**: Tap audio or video call button in `ChatInfoView` action buttons or `ChatView` toolbar +- **Entry point (incoming)**: `IncomingCallView` banner appears at top of screen; or native CallKit UI if enabled +- **Presented by**: `ActiveCallView` is overlaid on the main app view when `chatModel.activeCall` is set +- **Collapsible**: Call view can be collapsed via `chatModel.activeCallViewIsCollapsed` to return to chat while call continues +- **Dismiss**: Call ends when user taps end button or remote party disconnects + +## Page Sections + +### Incoming Call Banner (`IncomingCallView`) + +Displayed as an overlay banner when `CallController.activeCallInvitation` is set: + +| Element | Description | +|---|---| +| Profile avatar | User profile image (shown when multiple profiles exist) | +| Call type icon | `video.fill` (green) for video calls, `phone.fill` (green) for audio | +| Call type text | "Audio call" or "Video call" with caller info | +| Caller profile | `ProfilePreview` showing caller name and image | +| Reject button | Red `phone.down.fill` icon -- ends the invitation | +| Ignore button | Neutral `multiply` icon -- dismisses the banner without rejecting | +| Accept button | Green `checkmark` icon -- accepts the call; if another call is active, ends it first | + +Sound: Ringtone plays via `SoundPlayer.startRingtone()` while banner is visible (unless call view is already showing). + +### Active Call View (`ActiveCallView`) + +Full-screen overlay with black background: + +| Element | Description | +|---|---| +| Remote video | Full-screen `CallViewRemote` showing remote party's camera feed; tap toggles between `scaleAspectFill` and `scaleAspectFit` | +| Local video preview | Small floating `CallViewLocal` in top-right corner (30% width); shows local camera with rounded corners | +| Call overlay | `ActiveCallOverlay` with call controls (hidden when PiP is active for video calls) | +| Screen keep-on | `AppDelegate.keepScreenOn(true)` prevents screen dimming during calls | + +### Call Controls (`ActiveCallOverlay`) + +Bottom bar of the active call: + +| Control | Description | +|---|---| +| Mute toggle | Microphone on/off | +| Speaker toggle | Speaker/receiver switch | +| Camera switch | Front/back camera toggle (video calls) | +| Video toggle | Enable/disable video during call | +| End call | Red phone-down button to terminate | +| Audio device picker | `AudioDevicePicker` / `CallAudioDeviceManager` for selecting output (receiver, speaker, Bluetooth, AirPods) | + +### Picture-in-Picture (PiP) + +- When `pipShown == true` and call has video, the call overlay is hidden +- PiP window shows the remote video feed +- User can interact with the app normally while call continues + +### CallKit Integration + +Managed by `CallController`: + +| Feature | Description | +|---|---| +| Native incoming call UI | iOS system call screen for incoming calls (when CallKit is enabled) | +| Call history | Optionally shown in Phone app recents (`DEFAULT_CALL_KIT_CALLS_IN_RECENTS`) | +| System audio routing | CallKit manages audio session configuration | +| Lock screen answering | Call can be answered from lock screen via system UI | + +When CallKit is not used, the app falls back to `IncomingCallView` banner. + +### WebRTC Client + +| Component | Description | +|---|---| +| `WebRTCClient` | Manages peer connection, ICE candidates, media tracks | +| `WebRTC.swift` | Bridge between native code and WebRTC JavaScript via `WKWebView` | +| `CallViewRenderers` | `CallViewLocal` and `CallViewRemote` SwiftUI wrappers for video renderers | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Permissions required | Prompts for microphone (and camera for video) permissions on first call | +| Connecting | Call overlay shows connecting state; `SoundPlayer` plays connecting tone | +| WebRTC client creation | `createWebRTCClient()` called on appear and when `canConnectCall` changes | +| Call ended | `CallSoundsPlayer.vibrate(long: true)` on disconnect if was connected; audio session reset to `.soloAmbient` | +| Call failed | Call dismissed; WebRTC client cleaned up | +| No call invitation | `IncomingCallView` body is empty when no active invitation | + +## Audio Session Management + +- During call: Audio session configured for voice chat +- Camera permissions: `AVFoundation.AVCaptureDevice` authorization checked +- Audio device management: `CallAudioDeviceManager` handles routing changes and device enumeration +- Post-call cleanup: Audio session reverted to `.soloAmbient` + +## Related Specs + +- `spec/services/calls.md` -- Call service specification +- [Chat](chat.md) -- Call buttons in chat navigation bar +- [Contact Info](contact-info.md) -- Call buttons in contact info action row +- [Settings](settings.md) -- Call settings (CallKit, ICE servers, relay policy) + +## Source Files + +- `Shared/Views/Call/ActiveCallView.swift` -- Main active call view with video renderers and overlay +- `Shared/Views/Call/IncomingCallView.swift` -- Incoming call notification banner +- `Shared/Views/Call/CallController.swift` -- CallKit integration and call lifecycle management +- `Shared/Views/Call/CallManager.swift` -- Call state management and CXProvider delegate +- `Shared/Views/Call/CallAudioDeviceManager.swift` -- Audio device enumeration and routing +- `Shared/Views/Call/AudioDevicePicker.swift` -- Audio output device picker UI +- `Shared/Views/Call/WebRTC.swift` -- WebRTC signaling bridge via WKWebView +- `Shared/Views/Call/WebRTCClient.swift` -- WebRTC peer connection management +- `Shared/Views/Call/CallViewRenderers.swift` -- SwiftUI wrappers for local and remote video views +- `Shared/Views/Call/SoundPlayer.swift` -- Ringtone and call sound playback diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md new file mode 100644 index 0000000000..04d19bef9e --- /dev/null +++ b/apps/ios/product/views/chat-list.md @@ -0,0 +1,130 @@ +# Chat List (Home Screen) + +> **Related spec:** [spec/client/chat-list.md](../../spec/client/chat-list.md) + +## Purpose + +Main screen of the SimpleX Chat app. Displays all conversations sorted by last activity, serves as the navigation root, and provides access to user profiles, settings, and new chat creation. + +## Route / Navigation + +- **Entry point**: App launch (root view), or back-navigation from any chat +- **Presented by**: `ContentView` as the default view when `chatModel.chatId == nil` +- **Navigation stack**: `NavStackCompat` wrapping `chatListView` with destination `chatView` +- **UserPicker sheet**: Triggered by tapping the user avatar in the toolbar; presents `UserPicker` as a custom sheet, which links to `UserPickerSheetView` sub-sheets (address, preferences, profiles, current profile, use from desktop, settings) + +## Page Sections + +### Toolbar + +| Element | Location | Behavior | +|---|---|---| +| User avatar button | Leading | Opens `UserPicker` sheet (profile switcher, address, settings, preferences, connect to desktop) | +| Connection status indicator | Center (`SubsStatusIndicator`) | Shows server subscription status; taps navigate to `ServersSummaryView` | +| New chat button (pencil icon) | Trailing | Opens `NewChatSheet` modal | + +The toolbar supports two layout modes: +- **Standard (top)**: Navigation bar with `.topBarLeading`, `.principal`, `.topBarTrailing` placements +- **One-hand UI (bottom)**: Toolbar items placed in `.bottomBar` with the list vertically flipped via `scaleEffect(y: -1)` + +### Search Bar + +- Text field with magnifying glass icon +- When active, `searchMode = true` hides the navigation bar and shows inline search +- Filters chat list in real-time by contact/group name and message content +- Detects pasted SimpleX links (`searchShowingSimplexLink`) and offers to connect + +### Chat Filter Tabs (Tags) + +Managed by `ChatTagsModel` and `TagListView`: + +| Filter | PresetTag | Description | +|---|---|---| +| All | (none) | No filter, shows all chats | +| Unread | `.unread` | Chats with unread messages | +| Favorites | `.favorites` | User-favorited chats | +| Groups | `.groups` | Group conversations only | +| Contacts | `.contacts` | Direct contacts only | +| Business | `.business` | Business chat conversations | +| Notes | `.notes` | Notes to self | +| Group Reports | `.groupReports` | Moderation reports (non-collapsible) | +| Custom tags | `.userTag(ChatTag)` | User-created tags with custom names | + +### Chat Preview Rows + +Each row rendered by `ChatPreviewView` inside `ChatListNavLink`: + +| Element | Description | +|---|---| +| Avatar | Profile image or colored initials circle; online status indicator for contacts | +| Chat name | Display name (contact, group, or note-to-self) | +| Last message preview | Truncated text of most recent message; supports markdown rendering | +| Timestamp | Relative time of last activity (e.g., "2m", "1h", "Yesterday") | +| Unread badge | Numeric count badge for unread messages; distinct styling for mentions | +| Muted indicator | Bell-slash icon when notifications are muted | +| Pinned indicator | Pin icon for pinned chats | +| Incognito indicator | Shows when connected via incognito profile | +| Connection status | Shows connecting/pending state for incomplete connections | + +### Channel Adaptations + +When a group has `groupInfo.useRelays == true` (channel): + +| Element | Channel behavior | +|---|---| +| Chat icon | Antenna icon (`antenna.radiowaves.left.and.right.circle.fill`) instead of group icon | +| Swipe "Leave" | Hidden for channel owners (`useRelays && isOwner`) | +| Context menu "Leave" | Hidden for channel owners | +| Delete alert | "Delete channel?" (not "Delete group?") | +| Leave alert title | "Leave channel?" (not "Leave group?") | +| Leave alert message | "You will stop receiving messages from this channel. Chat history will be preserved." | + +### Relay URL Handling + +When a relay address link (`/r` path) is opened via URL deep link, `ContentView.connectViaUrl_()` intercepts it and shows an alert: "Relay address" / "This is a chat relay address, it cannot be used to connect." The link is not processed further. + +### Swipe Actions + +- **Trailing swipe**: Mute/unmute, pin/unpin, tag management +- **Leading swipe**: Mark as read/unread +- **Context menu** (long press): Full set of actions including delete, clear chat, toggle favorite + +### Floating Elements + +- **One-hand UI card** (`OneHandUICard`): Dismissible card shown to introduce bottom toolbar mode +- **Address creation card** (`AddressCreationCard`): Prompts user to create a SimpleX address + +### Pull-to-Refresh + +Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect servers?"). Uses additional traffic to force message delivery. + +## Loading / Error States + +| State | Behavior | +|---|---| +| Chat database not started | Settings row shows exclamation icon; chat running == false disables interactions | +| No chats | `ChatHelp` view displayed with onboarding guidance | +| Connection in progress | `ConnectProgressManager` overlay with connecting text | +| Search with no results | Empty list with no special empty-state view | + +## Related Specs + +- `spec/client/chat-list.md` -- Chat list feature specification +- `spec/state.md` -- Application state management +- [User Profiles](user-profiles.md) -- Profile switching from UserPicker +- [Settings](settings.md) -- Settings accessed via UserPicker +- [New Chat](new-chat.md) -- New chat sheet triggered from toolbar +- [Chat](chat.md) -- Navigated to when tapping a chat row + +## Source Files + +- `Shared/Views/ChatList/ChatListView.swift` -- Main view, toolbar, search, filter logic +- `Shared/Views/ChatList/ChatPreviewView.swift` -- Individual chat row rendering +- `Shared/Views/ChatList/ChatListNavLink.swift` -- Navigation link wrapper with swipe actions +- `Shared/Views/ChatList/TagListView.swift` -- Filter tab bar (preset + custom tags) +- `Shared/Views/ChatList/UserPicker.swift` -- User profile picker sheet +- `Shared/Views/ChatList/ChatHelp.swift` -- Empty-state help view +- `Shared/Views/ChatList/ContactRequestView.swift` -- Contact request row rendering +- `Shared/Views/ChatList/ContactConnectionView.swift` -- Pending connection row rendering +- `Shared/Views/ChatList/OneHandUICard.swift` -- One-hand UI introduction card +- `Shared/Views/ChatList/ServersSummaryView.swift` -- Server subscription summary diff --git a/apps/ios/product/views/chat.md b/apps/ios/product/views/chat.md new file mode 100644 index 0000000000..bf84bf4feb --- /dev/null +++ b/apps/ios/product/views/chat.md @@ -0,0 +1,174 @@ +# Chat View (Conversation) + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) | [spec/client/compose.md](../../spec/client/compose.md) + +## Purpose + +Full conversation view for displaying and interacting with messages in a direct contact chat, group chat, or note-to-self. Supports text messaging with markdown, media attachments, voice messages, E2E encrypted calls, message reactions, replies, forwarding, and content search/filtering. + +## Route / Navigation + +- **Entry point**: Tap a chat row in `ChatListView` +- **Presented by**: `NavStackCompat` destination from `ChatListView`, bound to `chatModel.chatId` +- **Back navigation**: Dismiss sets `chatModel.chatId = nil`, returning to chat list +- **Sub-navigation**: Info button navigates to `ChatInfoView` (contact) or `GroupChatInfoView` (group); member avatars navigate to `GroupMemberInfoView` + +## Page Sections + +### Navigation Bar + +Custom toolbar overlaying the chat with themed material background: + +| Element | Description | +|---|---| +| Back button | Returns to chat list | +| Contact/Group avatar | Small profile image | +| Chat name | Display name; tappable to open info sheet | +| Encryption badge | Shows PQ (post-quantum) or standard E2E status | +| Call buttons | Audio and video call icons (direct chats only) | +| Search button | Toggles in-chat message search | +| Info button | Opens `ChatInfoView` or `GroupChatInfoView` | + +### Message List + +Rendered by `EndlessScrollView` with lazy loading and pagination: + +| Feature | Description | +|---|---| +| Scroll direction | Bottom-to-top (newest messages at bottom) | +| Pagination | Loads more items on scroll to top (`loadingTopItems`) and bottom (`loadingBottomItems`) | +| Merged items | Adjacent messages from the same sender are visually merged via `MergedItems` | +| Floating buttons | Scroll-to-bottom button with unread count; scroll-to-first-unread button | +| Date separators | Sticky date headers between messages from different days | +| Wallpaper | Themed background image with tint and opacity from `theme.wallpaper` | +| Content filter | Filter messages by type: `.images`, `.files`, `.links` | + +### Message Types + +Each type has a dedicated view in `Shared/Views/Chat/ChatItem/`: + +| Type | View | Description | +|---|---|---| +| Text | `MsgContentView` | Rendered with markdown (bold, italic, code, links, mentions) | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `FullScreenMediaView` | +| Video | `CIVideoView` | Video thumbnail with play button; inline playback | +| Voice | `CIVoiceView` / `FramedCIVoiceView` | Waveform visualization with playback controls and duration | +| File | `CIFileView` | File icon, name, size; download/open actions | +| Link preview | `CILinkView` | URL preview card with title, description, image | +| Emoji-only | `EmojiItemView` | Large emoji rendering without message bubble | +| Call event | `CICallItemView` | Call status (missed, ended, duration) | +| Group event | `CIEventView` | Member joined/left, role changes, group updates | +| E2EE info | `CIChatFeatureView` | Encryption status and feature change notifications | +| Group invitation | `CIGroupInvitationView` | Inline group join invitation card | +| Deleted | `DeletedItemView` / `MarkedDeletedItemView` | Placeholder for deleted messages | +| Decryption error | `CIRcvDecryptionError` | Error with ratchet sync suggestion | +| Invalid JSON | `CIInvalidJSONView` | Developer fallback for malformed items | +| Integrity error | `IntegrityErrorItemView` | Message integrity/gap warnings | + +### Message Interactions + +Long-press context menu on any message: + +| Action | Description | +|---|---| +| Reply | Sets compose bar to reply mode with quoted message | +| Forward | Opens `forwardedChatItems` sheet to pick destination chat | +| Copy | Copies message text to clipboard | +| Edit | Enters edit mode in compose bar (own messages, within edit window) | +| Delete | Delete for self or delete for everyone (with confirmation) | +| React | Opens emoji reaction picker | +| Select multiple | Enters multi-select mode (`selectedChatItems`) with bulk delete/forward | +| Info | Shows delivery status and timestamps | + +Emoji reactions bar displayed below messages with reaction counts. + +### Compose Bar (`ComposeView`) + +| Element | Description | +|---|---| +| Text input | `NativeTextEditor` with markdown support and auto-growing height | +| Attachment button | Opens picker for images, videos, files, camera | +| Send button | Sends composed message; changes to voice record button when empty | +| Voice record | Hold-to-record with waveform preview; swipe-to-cancel | +| Reply quote | Shows quoted message above input when replying | +| Edit indicator | Shows "editing" label when editing a previous message | +| Link preview | Auto-generated preview card for detected URLs (`ComposeLinkView`) | +| Image/Video preview | Thumbnail strip for selected media (`ComposeImageView`) | +| File preview | File name and size for attached file (`ComposeFileView`) | +| Voice preview | Waveform of recorded voice message (`ComposeVoiceView`) | +| Live message | Real-time typing broadcast (optional, with alert on first use) | +| Context actions | `ContextContactRequestActionsView` for accepting/rejecting contact requests; `ContextPendingMemberActionsView` for pending group member actions | +| Commands menu | `CommandsMenuView` for bot/menu commands in chats with `menuCommands` | +| Group mentions | `GroupMentionsView` autocomplete popup when typing `@` in groups | +| Profile picker | `ContextProfilePickerView` for choosing incognito/main profile | + +### Channel Messages + +In channel conversations (`groupInfo.useRelays == true`), received messages (`.channelRcv` direction) display with: +- The **channel icon** (`antenna.radiowaves.left.and.right`) instead of the standard group icon +- The **channel name** as sender, with "channel" as the role label +- The **group profile image** as the avatar (tapping opens group info, not member info) +- Consecutive channel messages are grouped without repeating the avatar +- Channel messages cannot be moderated per-member (no member identity) + +### Member Support Chat (Groups) + +For groups with member support enabled: +- `MemberSupportView` and `MemberSupportChatToolbar` shown as secondary chat within group +- `SecondaryChatView` for scoped group chat views (reports, member support) +- User knocking state: `userMemberKnockingTitleBar()` shown when user is pending admission + +## Loading / Error States + +| State | Behavior | +|---|---| +| Initial load | Messages load from `ItemsModel` with merged items; `allowLoadMoreItems` throttles pagination | +| Loading more (top) | `loadingTopItems` spinner at top of scroll view | +| Loading more (bottom) | `loadingBottomItems` spinner at bottom | +| Connection in progress | `ConnectProgressManager` shows connecting text below compose bar | +| Connecting text | "connecting..." label shown below message list when chat not yet ready | +| Send disabled | Compose bar shows `disabledText` reason when `userCantSendReason` is set | +| Empty chat | No messages placeholder (implicit -- empty scroll view) | + +## Related Specs + +- `spec/client/chat-view.md` -- Chat view feature specification +- `spec/client/compose.md` -- Compose bar specification +- [Chat List](chat-list.md) -- Parent navigation +- [Contact Info](contact-info.md) -- Info sheet for direct chats +- [Group Info](group-info.md) -- Info sheet for group chats +- [Call](call.md) -- Audio/video calls initiated from toolbar + +## Source Files + +- `Shared/Views/Chat/ChatView.swift` -- Main chat view, message list, navigation, state management +- `Shared/Views/Chat/ChatItemView.swift` -- Individual message item rendering dispatcher +- `Shared/Views/Chat/ComposeMessage/ComposeView.swift` -- Compose bar container +- `Shared/Views/Chat/ComposeMessage/SendMessageView.swift` -- Send button and voice record +- `Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift` -- Text input with markdown +- `Shared/Views/Chat/ComposeMessage/ComposeImageView.swift` -- Image attachment preview +- `Shared/Views/Chat/ComposeMessage/ComposeFileView.swift` -- File attachment preview +- `Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift` -- Voice recording preview +- `Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift` -- Link preview generation +- `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` -- Reply/edit context display +- `Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift` -- Contact request accept/reject +- `Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift` -- Pending member actions +- `Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift` -- Profile picker for incognito +- `Shared/Views/Chat/ChatItem/FramedItemView.swift` -- Framed message bubble rendering +- `Shared/Views/Chat/ChatItem/MsgContentView.swift` -- Text message content with markdown +- `Shared/Views/Chat/ChatItem/CIImageView.swift` -- Image message view +- `Shared/Views/Chat/ChatItem/CIVideoView.swift` -- Video message view +- `Shared/Views/Chat/ChatItem/CIVoiceView.swift` -- Voice message view +- `Shared/Views/Chat/ChatItem/CIFileView.swift` -- File message view +- `Shared/Views/Chat/ChatItem/CILinkView.swift` -- Link preview view +- `Shared/Views/Chat/ChatItem/EmojiItemView.swift` -- Large emoji view +- `Shared/Views/Chat/ChatItem/CICallItemView.swift` -- Call event view +- `Shared/Views/Chat/ChatItem/CIEventView.swift` -- Group/system event view +- `Shared/Views/Chat/ChatItem/CIChatFeatureView.swift` -- Feature change notification +- `Shared/Views/Chat/ChatItem/CIMetaView.swift` -- Timestamp and delivery status +- `Shared/Views/Chat/ChatItem/FullScreenMediaView.swift` -- Fullscreen image/video viewer +- `Shared/Views/Chat/ChatItem/AnimatedImageView.swift` -- Animated GIF rendering +- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention autocomplete +- `Shared/Views/Chat/Group/MemberSupportView.swift` -- Member support scoped chat +- `Shared/Views/Chat/Group/MemberSupportChatToolbar.swift` -- Support chat toolbar +- `Shared/Views/Chat/Group/SecondaryChatView.swift` -- Secondary scoped chat view diff --git a/apps/ios/product/views/contact-info.md b/apps/ios/product/views/contact-info.md new file mode 100644 index 0000000000..5223bfcae4 --- /dev/null +++ b/apps/ios/product/views/contact-info.md @@ -0,0 +1,154 @@ +# Contact Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View contact details, manage per-contact preferences, verify security codes for E2E encryption, manage connection settings, and perform destructive actions like blocking or deleting a contact. + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a direct contact chat) +- **Presented by**: `NavigationView` sheet from `ChatView` via `showChatInfoSheet` +- **Sub-navigation**: + - Contact preferences -> `ContactPreferencesView` + - Security code verification -> `VerifyCodeView` + - Chat wallpaper -> `ChatWallpaperEditorSheet` + +## Page Sections + +### Contact Info Header + +| Element | Description | +|---|---| +| Profile image | Large circular avatar; tappable | +| Display name | Contact's display name | +| Full name | Optional full name below display name | +| Connection status | Shows if contact is ready, connecting, or has issues | + +### Local Alias + +Editable text field (`aliasTextFieldFocused`) for setting a local-only name visible only on this device. Not shared with the contact. + +### Action Buttons + +Horizontal row of quick-action buttons (width divided by 4): + +| Button | Description | +|---|---| +| Search | Triggers `onSearch` to search messages in chat | +| Audio call | Initiate audio call (`AudioCallButton`) | +| Video call | Initiate video call (`VideoButton`) | +| Mute/Unmute | Toggle notification mode (`nextNtfMode`) | + +Call buttons check `connectionStats` and show alerts if connection state prevents calling. + +### Incognito Section + +Shown only when `customUserProfile` is set (connected via incognito): + +| Element | Description | +|---|---| +| "Your random profile" label | Shows the incognito display name used for this contact | + +### Connection Settings Section + +| Element | Condition | Description | +|---|---|---| +| Verify security code | `connectionCode` available | Navigate to `VerifyCodeView` for QR-based code verification | +| Contact preferences | Always | Navigate to `ContactPreferencesView` | +| Send receipts | Always | Toggle: yes / no / default(yes) / default(no) | +| Synchronize connection | `ratchetSyncAllowed` | Fix encryption ratchet desynchronization | +| Chat theme | Always | Navigate to `ChatWallpaperEditorSheet` | + +All items disabled when `!contact.ready || !contact.active`. + +### Chat TTL Section + +| Element | Description | +|---|---| +| Chat TTL option | `ChatTTLOption` -- auto-delete timer for messages on this device | + +Footer: "Delete chat messages from your device." + +### Encryption Info Section + +Shown when `contact.activeConn` exists: + +| Element | Description | +|---|---| +| E2E encryption | "Quantum resistant" (PQ enabled) or "Standard" | + +### Contact Address Section + +Shown when `contact.contactLink` exists: + +| Element | Description | +|---|---| +| QR code | `SimpleXLinkQRCode` displaying the contact's address | +| Share address | Share button for the contact's SimpleX address link | + +Footer: "You can share this address with your contacts to let them connect with **[name]**." + +### Servers Section + +Shown when `contact.ready && contact.active`: + +| Element | Description | +|---|---| +| Subscription status | `SubStatusRow` showing connection health; tappable for details | +| Change receiving address | Button to switch SMP receiving queue (disabled during switch) | +| Abort changing address | Button to cancel in-progress address switch | +| Receiving via | SMP server hostnames for receiving queues | +| Sending via | SMP server hostnames for sending queues | + +### Danger Zone Section + +| Action | Description | +|---|---| +| Clear chat | Delete all messages locally (confirmation alert) | +| Delete contact | Remove contact entirely (confirmation alert) | + +### Developer Section + +Shown when `developerTools` is enabled: + +| Element | Description | +|---|---| +| Local name | Internal local display name | +| Database ID | API entity ID | +| Debug delivery | Button to fetch queue info via `apiContactQueueInfo` | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Loading connection info | `apiContactInfo` and `apiGetContactCode` called on appear; stats and code populated asynchronously | +| Progress indicator | `ProgressView` overlay during TTL changes | +| Contact not ready | Settings section disabled with reduced opacity | +| Contact inactive | Settings section disabled | +| Errors | Alert with localized error title and message | + +## Alerts + +| Alert | Trigger | +|---|---| +| `clearChatAlert` | Tap clear chat | +| `subStatusAlert` | Tap subscription status row | +| `switchAddressAlert` | Tap change receiving address | +| `abortSwitchAddressAlert` | Tap abort address change | +| `syncConnectionForceAlert` | Force ratchet sync | +| `queueInfo` | Debug delivery results | +| `someAlert` | Various sub-component alerts | + +## Related Specs + +- `spec/api.md` -- Contact API commands (info, code verification, preferences, delete) +- [Chat](chat.md) -- Parent chat view +- [Group Info](group-info.md) -- Similar pattern for group info + +## Source Files + +- `Shared/Views/Chat/ChatInfoView.swift` -- Main contact info view with all sections +- `Shared/Views/Chat/ContactPreferencesView.swift` -- Per-contact feature preferences (timed messages, reactions, voice, calls, file transfer, full delete) +- `Shared/Views/Chat/VerifyCodeView.swift` -- Security code verification via QR scan or visual comparison diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md new file mode 100644 index 0000000000..bfc9acfa71 --- /dev/null +++ b/apps/ios/product/views/group-info.md @@ -0,0 +1,246 @@ +# Group Chat Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View and manage group settings, member list, group preferences, group links, member admission, welcome messages, and moderation features. The scope of available actions depends on the user's role within the group (member, moderator, admin, owner). + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a group chat) +- **Presented by**: `NavigationView` sheet from `ChatView` via `showChatInfoSheet` +- **Sub-navigation**: + - Edit group profile -> `GroupProfileView` + - Add members -> `AddGroupMembersView` + - Group link -> `GroupLinkView` + - Group preferences -> `GroupPreferencesView` (via `GroupPreferencesButton`) + - Welcome message -> `GroupWelcomeView` + - Member info -> `GroupMemberInfoView` + - Chat wallpaper -> `ChatWallpaperEditorSheet` + - Member support -> `MemberSupportView` + - Group reports -> `GroupReportsChatNavLink` + +## Page Sections + +### Group Info Header + +| Element | Description | +|---|---| +| Group image | Large circular profile image | +| Group name | Display name (editable by owners) | +| Member count | "N members" label | +| Full name | Optional secondary name | +| Description | Group description text (if set) | + +### Local Alias + +Editable text field for a local-only alias (not shared with other members). Focused via `aliasTextFieldFocused`. + +### Action Buttons + +Horizontal row of action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearch` callback to search messages in chat | +| Mute/Unmute | Toggle notification mode (`nextNtfMode`) | + +### Group Management Section + +| Element | Condition | Description | +|---|---|---| +| Group link | `canAddMembers` and not business chat | Navigate to `GroupLinkView` to create/manage invitation link | +| Member support | Not business chat, role >= moderator | Navigate to member support chat view | +| Group reports | `canModerate` | Navigate to group reports chat | +| User support chat | Member active, role < moderator or has support chat | Navigate to own support chat with moderators | + +### Group Profile Section + +| Element | Condition | Description | +|---|---|---| +| Edit group | Owner, not business chat | Navigate to `GroupProfileView` for editing name, image, description | +| Welcome message | Has description or is owner (not business) | Navigate to `GroupWelcomeView` for add/edit | +| Group preferences | Always | Navigate to `GroupPreferencesView` -- timed messages, reactions, voice, files, direct messages, history visibility | + +Footer: "Only group owners can change group preferences." (or "Only chat owners can change preferences." for business chats) + +### Chat Settings Section + +| Element | Description | +|---|---| +| Send receipts | Toggle delivery receipts; disabled for groups > 20 current members with explanation | +| Chat theme | Navigate to `ChatWallpaperEditorSheet` | +| Chat TTL | `ChatTTLOption` -- set auto-deletion timer for messages on device | + +Footer: "Delete chat messages from your device." + +### Member List Section + +Header shows total member count (e.g., "25 members"). + +| Element | Description | +|---|---| +| Invite members button | Shown if `canAddMembers`; disabled with tap alert if incognito | +| Search field | Filter members by name (`searchText`) | +| Member rows | Each shows: avatar, display name, role badge (owner/admin/moderator/observer), online status indicator, connection status | +| Member tap | Navigates to `GroupMemberInfoView` | +| Member swipe actions | Block/unblock member, block/unblock for all (moderators) | + +Member list is sorted by role (owners first) and filtered to exclude `memLeft` and `memRemoved` statuses. + +### Danger Zone Section + +| Action | Description | +|---|---| +| Clear chat | Deletes all messages locally (with confirmation alert) | +| Leave group | Leave the group (with confirmation alert) | +| Delete group | Delete entire group -- only for owners (with confirmation alert) | + +### Developer Section + +Shown when `developerTools` is enabled: + +| Element | Description | +|---|---| +| Local name | Internal chat local display name | +| Database ID | API entity ID | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Loading members | Member list populated from `chatModel.groupMembers` | +| Progress indicator | `ProgressView` overlay when `progressIndicator` is true (during TTL changes) | +| Large group receipts | Receipts option disabled with "Disabled for large groups" label and info alert | +| Incognito invite blocked | Alert: "Can't invite contacts when incognito" | +| Errors | Alert with localized title and error description | + +## Alerts + +| Alert | Trigger | +|---|---| +| `deleteGroupAlert` | Tap delete group | +| `clearChatAlert` | Tap clear chat | +| `leaveGroupAlert` | Tap leave group | +| `cantInviteIncognitoAlert` | Tap invite members while incognito | +| `largeGroupReceiptsDisabled` | Tap receipts info on large group | +| `blockMemberAlert` / `unblockMemberAlert` | Block/unblock member actions | +| `blockForAllAlert` / `unblockForAllAlert` | Moderator block/unblock for all members | + +## Channel Adaptations + +When `groupInfo.useRelays == true`, the group info view adapts to channel semantics. All sections below describe differences from the standard group behavior above. + +### Channel Info Layout + +The top section splits into a channel-specific branch: + +| Element | Owner | Non-owner | +|---|---|---| +| Channel link | NavigationLink "Channel link" to `GroupLinkView` | Inline QR code (`SimpleXLinkQRCode`) + "Share link" button (if `groupProfile.publicGroup?.groupLink` exists) | +| Members | NavigationLink "Owners & subscribers" to `ChannelMembersView` | NavigationLink "Owners" to `ChannelMembersView` | +| Relays | NavigationLink "Chat relays" to `ChannelRelaysView` | NavigationLink "Chat relays" to `ChannelRelaysView` | + +### Channel Action Bar + +| Button | Channel behavior | +|---|---| +| Link button | Replaces "Add members" for channel owners; navigates to `GroupLinkView` | +| Add members | Hidden for channels | + +### Hidden Sections for Channels + +The following are hidden when `groupInfo.useRelays == true`: + +- Group preferences button and footer +- Send receipts toggle +- Member list section (replaced by ChannelMembersView navigation) +- Non-admin block section (in GroupMemberInfoView) + +### Channel Leave/Delete Rules + +- Sole channel owner cannot leave (button hidden when `isOwner && no other owners`) +- "Leave group" -> "Leave channel"; "Delete group" -> "Delete channel"; "Edit group profile" -> "Edit channel profile" +- `deleteGroupAlert`: "Delete channel?" / "Channel will be deleted for all subscribers - this cannot be undone!" (current member) or "Channel will be deleted for you - this cannot be undone!" (non-current member) +- `leaveGroupAlert`: "Leave channel?" / "You will stop receiving messages from this channel. Chat history will be preserved." +- `showRemoveMemberAlert`: "Remove subscriber?" / "Subscriber will be removed from channel - this cannot be undone!" + +### Channel Members View (`ChannelMembersView`) + +New view accessible from channel info, showing: + +| Section | Content | Visibility | +|---|---|---| +| Owners | Members with role >= `.owner`, plus current user if owner | Always | +| Subscribers | Members with role < `.owner` and != `.relay` | Owner only | + +- Excludes `memLeft`, `memRemoved`, and current user from member list +- Each row: profile image, verified badge, name; taps navigate to `GroupMemberInfoView` +- Empty state: "No subscribers" when subscriber list is empty + +### Channel Relays View (`ChannelRelaysView`) + +New view accessible from channel info, showing relay members (role == `.relay`): + +| Element | Description | +|---|---| +| Relay list | Filtered from `chatModel.groupMembers` by `.relay` role | +| Relay row | Profile image, relay display name, status text (`RelayStatus` or connection status) | +| Relay tap | NavigationLink to `GroupMemberInfoView` with `groupRelay:` parameter | +| Add relay sheet | Owner-only "Add relay" button opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's swipe action | +| Empty state | "No chat relays" | +| Footer | "Chat relays forward messages to channel subscribers." | + +Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection status only. + +### Channel Link View (`GroupLinkView` with `isChannel: true`) + +| Change | Channel behavior | +|---|---| +| Title | "Channel link" (not "Group link") | +| Description | "Anybody will be able to join the channel" (omits "You won't lose members...") | +| Initial role picker | Hidden | +| Upgrade link button | Hidden | +| Delete link button | Hidden (channel link deletion only via channel deletion) | +| Short/full link toggle | Hidden | +| Share button | Shares directly (no upgrade-and-share alert) | + +### Channel Member Info (`GroupMemberInfoView` adaptations) + +| Change | Channel behavior | +|---|---| +| Section header | "Relay" / "Owner" / "Subscriber" (based on member role) instead of "Member" | +| Group label | "Channel" instead of "Group" / "Chat" | +| Action buttons | Hidden (message/audio/video/search) | +| Role change picker | Hidden | +| Verify code button | Hidden for relay members | +| Block section | Hidden for non-moderator users | +| Remove button | Hidden for relay members | +| "Remove member" label | "Remove subscriber" | +| "Block for all?" alert | "Block subscriber for all?" | +| "Unblock for all?" alert | "Unblock subscriber for all?" | +| Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` | +| Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button | +| Status row (rejected) | Shown when `groupRelay?.relayStatus == .rsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `.memLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/group allow #`. | +| Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." | + +## Related Specs + +- `spec/api.md` -- Group API commands (create, update, add/remove members, roles, links) +- [Chat](chat.md) -- Parent chat view +- [Contact Info](contact-info.md) -- Similar pattern for direct contact info + +## Source Files + +- `Shared/Views/Chat/Group/GroupChatInfoView.swift` -- Main group info view with all sections +- `Shared/Views/Chat/Group/GroupProfileView.swift` -- Edit group name, image, description +- `Shared/Views/Chat/Group/AddGroupMembersView.swift` -- Member invitation view +- `Shared/Views/Chat/Group/GroupLinkView.swift` -- Group link creation and management +- `Shared/Views/Chat/Group/GroupPreferencesView.swift` -- Group feature preferences +- `Shared/Views/Chat/Group/GroupWelcomeView.swift` -- Welcome message editor +- `Shared/Views/Chat/Group/MemberAdmissionView.swift` -- Member admission policy settings +- `Shared/Views/Chat/Group/GroupMemberInfoView.swift` -- Individual member info and actions +- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention support in groups +- `Shared/Views/Chat/Group/ChannelMembersView.swift` -- Channel owners/subscribers list +- `Shared/Views/Chat/Group/ChannelRelaysView.swift` -- Channel relay status list diff --git a/apps/ios/product/views/new-chat.md b/apps/ios/product/views/new-chat.md new file mode 100644 index 0000000000..2ab5f9ba8f --- /dev/null +++ b/apps/ios/product/views/new-chat.md @@ -0,0 +1,144 @@ +# New Chat / Connection + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Create new contacts, groups, or connect with others via one-time invitation links or by scanning/pasting SimpleX links. This is the primary onramp for establishing new E2E encrypted connections. + +## Route / Navigation + +- **Entry point**: Tap the new chat button (pencil icon) in `ChatListView` toolbar +- **Presented by**: `NewChatSheet` modal from `ChatListView` +- **Internal navigation**: `NewChatMenuButton` provides a dropdown with options: + - "New chat" -- opens `NewChatView` + - "Create group" -- opens `AddGroupView` +- **Tabs within NewChatView**: Segmented picker toggles between `.invite` (1-time link) and `.connect` (connect via link) +- **Swipe gesture**: Left/right swipe switches between invite and connect tabs +- **Dismiss behavior**: On dismiss, `showKeepInvitationAlert()` asks whether to keep an unused invitation link or delete it + +## Page Sections + +### Segmented Picker + +| Tab | Icon | Description | +|---|---|---| +| 1-time link | `link` | Generate and share a one-time invitation link | +| Connect via link | `qrcode` | Scan QR code or paste a received link | + +### Invite Tab (1-time Link) + +Displayed when `selection == .invite`: + +| Element | Description | +|---|---| +| QR code display | Generated QR code for the invitation link (`SimpleXLinkQRCode`) | +| Short/full link toggle | Switch between short and full link display | +| Share button | System share sheet for the invitation link | +| Copy button | Copy link to clipboard | +| Incognito toggle | Option to connect with a random profile | +| Loading state | `creatingLinkProgressView` spinner while `creatingConnReq` is true | +| Retry button | Shown if link creation fails | + +Link creation calls `apiAddContact` which returns a `CreatedConnLink` with both `connFullLink` and optional `connShortLink`. + +### Connect Tab (Connect via Link) + +Displayed when `selection == .connect`: + +| Element | Description | +|---|---| +| QR code scanner | Camera-based `CodeScanner` view for scanning SimpleX QR codes | +| Paste link field | Text input for pasting a SimpleX link manually | +| Connect button | Initiates connection via the pasted/scanned link | + +Handled by `ConnectView` sub-view with `showQRCodeScanner` state. + +### Info Sheet + +Toolbar trailing button opens `AddContactLearnMore` info sheet explaining how SimpleX connections work. + +### Add Group + +Accessed via `NewChatMenuButton` dropdown: + +| Element | Description | +|---|---| +| Group name | Required text field | +| Group image | Optional profile image picker | +| Incognito option | Create group with random profile | +| Create button | Creates group via API and navigates to group chat | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Creating invitation | `ProgressView` spinner shown; buttons disabled | +| Link creation failure | Retry button displayed | +| Invalid link pasted | Alert shown via `NewChatViewAlert.newChatSomeAlert` | +| Connection in progress | Chat list shows pending connection entry | +| Unused invitation on dismiss | Alert: "Keep unused invitation?" with Keep/Delete options | + +## Create Channel (`AddChannelView`) + +Accessed via `NewChatMenuButton` dropdown: "Create channel (BETA)" with antenna icon (`antenna.radiowaves.left.and.right.circle.fill`). + +### Three-Step Channel Creation Wizard + +| Step | View | Description | +|---|---|---| +| 1. Profile | `profileStepView()` | Channel name input with validation, optional profile image. "Configure relays" link navigates to `NetworkAndServers`. Warning footer if no relays enabled. | +| 2. Progress | `progressStepView(_:)` | Relay connection progress: circular indicator (active/total), expandable relay list with status indicators (green=active, orange=invited/accepted, red=new). Cancel button deletes channel. | +| 3. Link | `linkStepView(_:)` | Wraps `GroupLinkView(isChannel: true)` showing the channel link for sharing. | + +### Channel Creation Defaults + +- History preference auto-enabled (`GroupPreference(enable: .on)`) +- Group link member role hardcoded to `.observer` +- Up to 3 random enabled relays selected from user's configured relays + +### Channel Creation API + +Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)` which returns `publicGroupCreated` response with group info, link, and relay list. On cancel, `apiDeleteChat` deletes the channel. + +### Relay Validation + +- `checkHasRelays()`: validates at least one enabled, non-deleted relay exists +- Warning footer: "Enable at least one chat relay in Network & Servers." +- `getEnabledRelays()`: filters enabled/non-deleted relays from user's server config + +## Channel-Specific Connection Behavior + +### Relay Link Blocking + +When `planAndConnect` encounters a `.simplexLink(_, .relay, _, _)`, it shows a "Relay address" alert: "This is a chat relay address, it cannot be used to connect." Connection is blocked. + +### Channel Prepare/Join Alerts + +| Context | Channel behavior | Group behavior | +|---|---|---| +| Prepare alert icon | `antenna.radiowaves.left.and.right.circle.fill` | `person.2.circle.fill` | +| Prepare alert title | "Open new channel" | "Open new group" | +| Error text | "Error opening channel" | "Error opening group" | +| Own-link confirm | "This is your link for channel" with only "Open channel" + "Cancel" (no incognito/profile options) | Full incognito/profile selection | +| Known group alert | "Open channel" / "Open new channel" | "Open group" / "Open new group" | + +### Pre-Join Relay Info + +When preparing a channel link, `groupShortLinkInfo.groupRelays` (hostnames) are stored in `ChatModel.shared.channelRelayHostnames` for display in the subscriber relay bar before joining. + +## Related Specs + +- `spec/api.md` -- API commands: `APIAddContact`, `APIConnect`, `APICreateUserAddress` +- `spec/client/navigation.md` -- Navigation architecture for channel creation flow +- [Chat List](chat-list.md) -- Parent view that presents this sheet +- [Chat](chat.md) -- Navigated to after successful connection + +## Source Files + +- `Shared/Views/NewChat/NewChatView.swift` -- Main view with invite/connect tabs, link generation +- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group, create channel) +- `Shared/Views/NewChat/QRCode.swift` -- QR code generation and display +- `Shared/Views/NewChat/AddGroupView.swift` -- Group creation form +- `Shared/Views/NewChat/AddChannelView.swift` -- Channel creation wizard (3 steps) +- `Shared/Views/NewChat/AddContactLearnMore.swift` -- Info sheet explaining connection process diff --git a/apps/ios/product/views/onboarding.md b/apps/ios/product/views/onboarding.md new file mode 100644 index 0000000000..a283c25a19 --- /dev/null +++ b/apps/ios/product/views/onboarding.md @@ -0,0 +1,147 @@ +# Onboarding + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/architecture.md](../../spec/architecture.md) + +## Purpose + +First-time setup flow for new users. Guides through app introduction, profile creation, server operator conditions acceptance, and notification configuration. Also provides an entry point for device migration. + +## Route / Navigation + +- **Entry point**: App launch when `onboardingStageDefault` is not `.onboardingComplete` +- **Presented by**: `OnboardingView` renders the appropriate step based on `OnboardingStage` enum +- **Flow direction**: Linear progression; back navigation hidden on later steps (`.navigationBarBackButtonHidden(true)`) +- **Completion**: Sets `onboardingStageDefault` to `.onboardingComplete` and updates `chatModel.onboardingStage` + +## Onboarding Steps + +### Step 1: Welcome / SimpleX Info (`SimpleXInfo`) + +**Stage**: `step1_SimpleXInfo` + +| Element | Description | +|---|---| +| Logo | SimpleX Chat logo (light/dark variant based on color scheme) | +| "The future of messaging" | Info button opening `HowItWorks` sheet | +| Privacy redefined | "No user identifiers." with privacy icon | +| Immune to spam | "You decide who can connect." with shield icon | +| Decentralized | "Anybody can host servers." with decentralized icon | +| **Create your profile** button | Primary action; navigates to `CreateFirstProfile` | +| **Migrate from another device** button | Secondary action; opens `MigrateToDevice` sheet | + +The "How it works" sheet (`HowItWorks`) explains SimpleX's privacy model with an option to proceed to profile creation. + +### Step 2: Create Profile (`CreateFirstProfile`) + +**Stage**: `step2_CreateProfile` (deprecated -- now part of step 1 flow) + +| Element | Description | +|---|---| +| Display name field | Required; auto-focused after 1 second delay | +| Validation | `mkValidName` check; alerts for invalid/duplicate names | +| Create button | Calls profile creation API; advances to next step | + +Profile is stored locally and only shared with contacts. Footer explains this privacy property. + +### Step 3: Server Operator Conditions (`OnboardingConditionsView`) + +**Stage**: `step3_ChooseServerOperators` (changed to simplified conditions view) + +| Element | Description | +|---|---| +| "Conditions of use" title | Large title header | +| Privacy explanation | "Private chats, groups and your contacts are not accessible to server operators." | +| Operator selection | Toggle operators (with `selectedOperatorIds`) | +| Show conditions | Sheet to view full conditions (`ConditionsWebView`) | +| Configure operators | Sheet to customize operator settings | +| **Accept** button | Accepts conditions and advances to notifications step | + +Previous deprecated step `step3_CreateSimpleXAddress` (`CreateSimpleXAddress`) is no longer in the active flow. + +### Step 4: Set Notification Mode (`SetNotificationsMode`) + +**Stage**: `step4_SetNotificationsMode` + +| Element | Description | +|---|---| +| "Push notifications" title | Large title header | +| Info text | Explanation of notification modes | +| Mode selector | `NtfModeSelector` for each `NotificationsMode.values` | +| **Enable notifications** / **Use chat** button | Sets notification mode and completes onboarding | +| Info sheet | `NotificationsInfoView` accessible for detailed explanation | + +Notification modes: + +| Mode | Description | +|---|---| +| Instant | Background connection maintained; real-time notifications | +| Periodic | Checks every 10 minutes; battery-friendly | +| Off | No push notifications; messages received only when app is open | + +On completion, `onboardingStageDefault.set(.onboardingComplete)` is called. + +### Completion + +**Stage**: `onboardingComplete` + +`OnboardingView` renders `EmptyView()` and the app proceeds to `ChatListView`. + +## Optional Paths + +### Migrate from Another Device + +- Triggered from Step 1 via "Migrate from another device" button +- Sets `chatModel.migrationState = .pasteOrScanLink` +- Opens `MigrateToDevice` in a sheet within `NavigationView` +- User pastes or scans a migration link from the source device +- Imports database and settings from the linked device + +### What's New (`WhatsNewView`) + +- Not part of the linear onboarding flow +- Shown when `DEFAULT_WHATS_NEW_VERSION` differs from current version +- Accessible later from Settings > Help > What's new +- Displays changelog with feature descriptions + +## Onboarding Stage Enum + +``` +enum OnboardingStage: String { + case step1_SimpleXInfo + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators // conditions acceptance + case step4_SetNotificationsMode + case onboardingComplete +} +``` + +Persisted via `DEFAULT_ONBOARDING_STAGE` in `UserDefaults`. + +## Loading / Error States + +| State | Behavior | +|---|---| +| No device token | Alert "No device token!" if trying to set notification mode without token | +| Profile creation error | Alert with error description | +| Migration failure | Error handling within `MigrateToDevice` flow | +| Conditions loading | Async fetch of operator conditions | + +## Related Specs + +- `spec/architecture.md` -- App architecture and initialization flow +- [Chat List](chat-list.md) -- Destination after onboarding completes +- [User Profiles](user-profiles.md) -- Profile created during onboarding; additional profiles later +- [Settings](settings.md) -- Notification and server settings revisitable after onboarding + +## Source Files + +- `Shared/Views/Onboarding/OnboardingView.swift` -- Step router and `OnboardingStage` enum definition +- `Shared/Views/Onboarding/SimpleXInfo.swift` -- Step 1: Welcome screen with privacy highlights and migration entry +- `Shared/Views/Onboarding/CreateProfile.swift` -- Profile creation form (shared between onboarding and user profiles) +- `Shared/Views/Onboarding/CreateSimpleXAddress.swift` -- Deprecated step 3: SimpleX address creation +- `Shared/Views/Onboarding/ChooseServerOperators.swift` -- Step 3: Server operator conditions and selection +- `Shared/Views/Onboarding/SetNotificationsMode.swift` -- Step 4: Push notification mode selection +- `Shared/Views/Onboarding/HowItWorks.swift` -- "How it works" info sheet from step 1 +- `Shared/Views/Onboarding/WhatsNewView.swift` -- Changelog / what's new display +- `Shared/Views/Onboarding/AddressCreationCard.swift` -- Address creation prompt card diff --git a/apps/ios/product/views/settings.md b/apps/ios/product/views/settings.md new file mode 100644 index 0000000000..3cc4da5d2b --- /dev/null +++ b/apps/ios/product/views/settings.md @@ -0,0 +1,201 @@ +# Settings + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/services/theme.md](../../spec/services/theme.md) | [spec/services/notifications.md](../../spec/services/notifications.md) + +## Purpose + +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker sheet on the chat list. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> Settings option +- **Presented by**: `UserPickerSheetView(sheet: .settings)` wrapping `SettingsView` in a `NavigationView` +- **Navigation title**: "Your settings" +- **Sub-navigation**: Each settings row is a `NavigationLink` to a dedicated settings view + +## Page Sections + +### Settings Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Notifications | `bolt` (color varies by token status) | `NotificationsView` | Push notification mode and preview settings | +| Network & servers | `externaldrive.connected.to.line.below` | `NetworkAndServers` | SMP/XFTP servers, proxy, .onion hosts, advanced network | +| Audio & video calls | `video` | `CallSettings` | WebRTC relay policy, ICE servers, CallKit options | +| Privacy & security | `lock` | `PrivacySettings` | SimpleX Lock, screen protection, delivery receipts, auto-accept | +| Appearance | `sun.max` | `AppearanceSettings` | Theme, language, wallpapers, chat bubbles, toolbar opacity | + +All rows disabled when `chatModel.chatRunning != true`. Appearance row only shown when `UIApplication.shared.supportsAlternateIcons`. + +#### Notifications (`NotificationsView`) + +| Setting | Options | +|---|---| +| Notification mode | Instant (background connection) / Periodic (every 10 min) / Off | +| Notification preview | Hidden / Contact name only / Message preview | +| Token status indicator | Icon color reflects: new, registered, confirmed (yellow), active (green), expired, invalid | + +#### Network & Servers (`NetworkAndServers`) + +| Setting | Description | +|---|---| +| SMP servers | Messaging relay servers; per-operator configuration | +| XFTP servers | File transfer servers; per-operator configuration | +| Server operators | `OperatorView` for each configured operator | +| Advanced network | `AdvancedNetworkSettings` -- timeouts, TCP keep-alive, reconnect intervals | +| Proxy configuration | SOCKS proxy, .onion host settings | +| Show sent via proxy | Toggle to show proxy indicator on sent messages | +| Show subscription % | Toggle to show server subscription percentage | + +Sub-files: `NetworkAndServers.swift`, `ProtocolServersView.swift`, `ProtocolServerView.swift`, `NewServerView.swift`, `ScanProtocolServer.swift`, `AdvancedNetworkSettings.swift`, `OperatorView.swift`, `ConditionsWebView.swift`, `ChatRelayView.swift` + +##### Chat Relays + +Chat relays forward messages to channel subscribers. They appear in two locations: + +- **Operator View** (`OperatorView`): "Chat relays" section lists relays for each operator with `ChatRelayViewLink` rows. Footer: "Chat relays forward messages in channels you create." +- **Your Servers** (`YourServersView` in `ProtocolServersView`): "Chat relays" section for non-operator relays. "Add server" dialog includes a "Chat relay" option. + +Each relay is managed via `ChatRelayView`: + +| Element | Preset relay | Custom relay | +|---|---|---| +| Name | Read-only display | Editable text field | +| Address | Read-only display | Editable text field (validates as `.simplexLink(_, .relay, _, _)`) | +| Test button | "Test relay" (shows "Not implemented" alert) | Same | +| Enable toggle | "Use for new channels" | Same | +| Delete | Not available | "Delete relay" button | + +Adding a relay: `NewChatRelayView` form with name, address, test, and enable toggle. Back-button validates name/address and shows alerts for invalid input. + +##### Server Warnings + +`ServersWarningView` displays an orange exclamation triangle with warning text when `UserServersWarning.noChatRelays` is detected. Appears in: +- Network & Servers footer (`globalServersWarning`) +- Operator view footer +- Your servers footer + +Server validation (`validateServers_`) now returns both errors and warnings. + +#### Privacy & Security (`PrivacySettings`) + +| Setting | Description | +|---|---| +| SimpleX Lock | Enable biometric (Face ID / Touch ID) or passcode lock | +| Lock mode | System biometric or custom passcode | +| Lock timeout | Delay before lock activates (0s to 30min) | +| Self-destruct | Optional self-destruct passcode that wipes all data | +| Screen protection | Hide app content in app switcher | +| Encrypt local files | Encrypt media and files stored on device | +| Auto-accept images | Automatically download received images | +| Link previews | Generate link previews for sent URLs | +| SimpleX link mode | Description / Full link / Via browser | +| Chat previews | Show message previews in chat list | +| Save last draft | Remember unsent message drafts | +| Delivery receipts | Enable/disable read receipts globally | +| Media blur radius | Blur level for received media before tapping | + +#### Appearance (`AppearanceSettings`) + +| Setting | Description | +|---|---| +| App icon | Alternative app icon selection | +| Language | Interface language | +| Theme | System / Light / Dark | +| Dark theme variant | Dark / SimpleX / Black | +| Active theme colors | Accent color, chat bubble colors, text colors | +| Wallpapers | Chat background wallpaper selection and customization | +| Profile image corner radius | Adjust avatar roundness | +| Chat bubble roundness | Adjust message bubble corner radius | +| Chat bubble tail | Toggle message bubble tail/pointer | +| Toolbar opacity | `ToolbarMaterial` transparency setting | +| One-hand UI | Bottom toolbar layout for reachability | + +#### Audio & Video Calls (`CallSettings`) + +| Setting | Description | +|---|---| +| WebRTC relay policy | Always relay / Allow direct | +| ICE servers | Custom STUN/TURN server configuration | +| CallKit integration | Enable/disable native iOS call UI | +| Calls in recents | Show/hide calls in Phone app history | +| Lock screen calls | Show/accept on lock screen options | + +### Chat Database Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Database passphrase & export | `internaldrive` (orange if unencrypted) | `DatabaseView` | Passphrase management, export/import database, file storage stats | +| Migrate to another device | `tray.and.arrow.up` | `MigrateFromDevice` | Export database and generate migration link | + +Database row shows exclamation octagon icon in red when `chatRunning == false`. + +### Help Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| How to use it | `questionmark` | `ChatHelp` | Usage guide with user's display name | +| What's new | `plus` | `WhatsNewView` | Changelog and new features | +| About SimpleX Chat | `info` | `SimpleXInfo` | About page with privacy explanation | +| Send questions and ideas | `number` | Opens SimpleX team chat link | Direct contact with developers | +| Send us email | `envelope` | `mailto:chat@simplex.chat` | Email link | + +### Support SimpleX Chat Section + +| Row | Icon | Action | +|---|---|---| +| Contribute | `keyboard` | Opens GitHub contribution guide | +| Rate the app | `star` | `SKStoreReviewController.requestReview` | +| Star on GitHub | GitHub icon | Opens GitHub repository | + +### Develop Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Developer tools | `chevron.left.forwardslash.chevron.right` | `DeveloperView` | Chat console/terminal, log level, confirm DB upgrades | +| App version | (none) | `VersionView` | Shows "v{version} ({build})" | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Chat not running | Most navigation links disabled; database row shows warning | +| Database not encrypted | Database icon shown in orange | +| Migration in progress | `showProgress` overlays `ProgressView` on entire settings view | +| Terminal cleanup | On disappear: `chatModel.showingTerminal = false`, terminal items cleared | + +## App Defaults + +Key `UserDefaults` / `AppStorage` keys managed by settings: +- `DEFAULT_PERFORM_LA`, `DEFAULT_LA_MODE`, `DEFAULT_LA_LOCK_DELAY`, `DEFAULT_LA_SELF_DESTRUCT` +- `DEFAULT_PRIVACY_ACCEPT_IMAGES`, `DEFAULT_PRIVACY_LINK_PREVIEWS`, `DEFAULT_PRIVACY_PROTECT_SCREEN` +- `DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS`, `DEFAULT_PRIVACY_SAVE_LAST_DRAFT` +- `DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET`, `DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS` +- `DEFAULT_WEBRTC_POLICY_RELAY`, `DEFAULT_WEBRTC_ICE_SERVERS`, `DEFAULT_CALL_KIT_CALLS_IN_RECENTS` +- `DEFAULT_CURRENT_THEME`, `DEFAULT_SYSTEM_DARK_THEME`, `DEFAULT_THEME_OVERRIDES` +- `DEFAULT_PROFILE_IMAGE_CORNER_RADIUS`, `DEFAULT_CHAT_ITEM_ROUNDNESS`, `DEFAULT_CHAT_ITEM_TAIL` +- `DEFAULT_TOOLBAR_MATERIAL`, `DEFAULT_ONE_HAND_UI_CARD_SHOWN` +- `DEFAULT_DEVELOPER_TOOLS`, `DEFAULT_SHOW_SENT_VIA_RPOXY`, `DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE` + +## Related Specs + +- `spec/architecture.md` -- App architecture overview +- `spec/services/theme.md` -- Theme system specification +- [Chat List](chat-list.md) -- Parent view via UserPicker +- [User Profiles](user-profiles.md) -- Profile management (separate UserPicker option) + +## Source Files + +- `Shared/Views/UserSettings/SettingsView.swift` -- Main settings view, section layout, app defaults definitions +- `Shared/Views/UserSettings/NotificationsView.swift` -- Notification mode and preview settings +- `Shared/Views/UserSettings/AppearanceSettings.swift` -- Theme, wallpaper, UI customization +- `Shared/Views/UserSettings/PrivacySettings.swift` -- Privacy and security settings +- `Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift` -- Server and network configuration +- `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` -- TCP/timeout settings +- `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` -- SMP/XFTP server list +- `Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift` -- Individual server edit +- `Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift` -- Add new server +- `Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift` -- Scan server QR code +- `Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift` -- Server operator configuration +- `Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift` -- Chat relay detail/edit/add views +- `Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift` -- Operator conditions display diff --git a/apps/ios/product/views/user-profiles.md b/apps/ios/product/views/user-profiles.md new file mode 100644 index 0000000000..5a38db1816 --- /dev/null +++ b/apps/ios/product/views/user-profiles.md @@ -0,0 +1,137 @@ +# User Profiles + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/state.md](../../spec/state.md) + +## Purpose + +Manage multiple chat profiles within a single app instance. Users can create, switch between, hide, mute, and delete profiles. Hidden profiles are protected by password and support a self-destruct password option. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> "Your chat profiles" +- **Presented by**: `UserPickerSheetView(sheet: .chatProfiles)` wrapping `UserProfilesView` in a `NavigationView` +- **Navigation title**: "Your chat profiles" +- **Sub-navigation**: + - Create profile -> `CreateProfile` + - Edit profile -> profile detail view (via `selectedUser`) + - User address -> `UserAddressView` (via UserPicker `.address` sheet) + +## Page Sections + +### Search / Password Field + +Combined text field at the top (`searchTextOrPassword`): +- In normal mode: Filters visible profiles by name +- For hidden profiles: Acts as password entry to reveal hidden profiles +- Trimmed search text compared against profile names and hidden profile passwords + +### Profile List + +Each row rendered by `userView()`: + +| Element | Description | +|---|---| +| Active indicator | Checkmark or highlighted state for the current active profile | +| Profile image | Avatar circle with profile image or colored initials | +| Display name | Profile's display name | +| Unread count | Badge showing unread message count across all chats for this profile | +| Muted indicator | Bell-slash icon if profile notifications are muted | +| Hidden indicator | Lock icon for hidden profiles (only shown when revealed via password) | + +### Profile Actions + +Available via tap on a profile row: + +| Action | Condition | Description | +|---|---|---| +| Switch active | Different from current | Activates the selected profile; all chats switch context | +| Mute / Unmute | Any profile | Toggle notification muting for the profile; shows alert on first mute (`showMuteProfileAlert`) | +| Hide / Unhide | Non-active profile | Hide with password or reveal a hidden profile | +| Delete | Non-active profile | Delete with confirmation; option to delete data from servers | + +### Add Profile Button + +| Element | Description | +|---|---| +| "Add profile" label | `Label("Add profile", systemImage: "plus")` | +| Navigation | `NavigationLink` to `CreateProfile` view | +| Auth required | Requires local authentication before creating | + +Only shown when `trimmedSearchTextOrPassword` is empty (not searching/entering password). + +### Hidden Profile Banner + +Shown when `profileHidden` is true (a profile was just hidden): + +| Element | Description | +|---|---| +| Lock icon | `lock.open` system image | +| Message | "Enter password above to show!" | +| Tap action | Dismisses the banner with animation | + +### Create Profile (`CreateProfile`) + +| Field | Description | +|---|---| +| Display name | Required text field with validation (`mkValidName`) | +| Bio | Optional bio text (max 160 bytes) | +| Create button | Disabled until valid name entered and bio within limit | + +Validation alerts: `duplicateUserError`, `invalidDisplayNameError`, `createUserError`, `invalidNameError`. + +## Profile Visibility + +| Visibility | Description | +|---|---| +| Public | Normal profile, always visible in the list | +| Hidden | Protected by password; not shown unless password entered in search field | +| Muted | Notifications suppressed; visual indicator in profile list | + +### Hidden Profile Password Management + +- Set password when hiding a profile +- Password verified when entering in the search/password field +- `UserProfileAction.unhideUser` requires password entry +- Self-destruct password: Optional secondary password (`DEFAULT_LA_SELF_DESTRUCT`) that wipes all app data when entered + +### Delete Profile + +Two-stage confirmation: + +1. `confirmDeleteUser()` shows initial confirmation +2. `UserProfilesAlert.deleteUser(user:, delSMPQueues:)` with option to delete queues from servers +3. Requires local authentication (`withAuth`) before proceeding + +## Loading / Error States + +| State | Behavior | +|---|---| +| Authentication required | `authorized` state; prompts biometric/passcode before profile operations | +| Profile switch | Async operation; profile switch errors shown via `activateUserError` alert | +| Delete in progress | Profile removed from list; server queue deletion is async | +| Errors | Alert with localized error title and description | + +## Alerts + +| Alert | Trigger | +|---|---| +| `deleteUser` | Confirm profile deletion | +| `hiddenProfilesNotice` | First-time hidden profiles explanation (`showHiddenProfilesNotice`) | +| `muteProfileAlert` | First-time mute explanation (`showMuteProfileAlert`) | +| `activateUserError` | Profile switch failure | +| `error` | General error display | + +## Related Specs + +- `spec/api.md` -- User management API commands (create user, delete user, activate user, hide user) +- `spec/state.md` -- Application state: `chatModel.users`, `chatModel.currentUser` +- [Chat List](chat-list.md) -- Reflects active profile's chats +- [Settings](settings.md) -- Accessed from same UserPicker menu +- [Onboarding](onboarding.md) -- Initial profile creation during first launch + +## Source Files + +- `Shared/Views/UserSettings/UserProfilesView.swift` -- Main profiles list, search/password, profile actions, delete confirmation +- `Shared/Views/Onboarding/CreateProfile.swift` -- Profile creation form (shared with onboarding and profiles view) +- `Shared/Views/UserSettings/UserAddressView.swift` -- User's SimpleX address management (create, share, delete) +- `Shared/Views/ChatList/UserPicker.swift` -- Profile switcher sheet that navigates to this view diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index b2bdf39086..c2b01228a2 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -5,11 +5,14 @@ "_italic_" = "\\_курсив_"; /* No comment provided by engineer. */ -"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- соединиться с [каталогом групп](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- отчеты о доставке (до 20 членов).\n- быстрее и стабильнее."; +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- соединиться с [каталогом групп](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- отчёты о доставке (до 20 членов).\n- быстрее и стабильнее."; /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- более стабильная доставка сообщений.\n- немного улучшенные группы.\n- и прочее!"; +/* No comment provided by engineer. */ +"- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking." = "- включение картинок ссылок.\n- защита от фишинга.\n- удаление трекинга ссылок."; + /* No comment provided by engineer. */ "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- опционально уведомляйте удалённые контакты.\n- имена профилей с пробелами.\n- и прочее!"; @@ -19,21 +22,21 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 цвет!"; +/* chat link info line */ +"(from owner)" = "(от владельца)"; + /* No comment provided by engineer. */ "(new)" = "(новое)"; +/* chat link info line */ +"(signed)" = "(с подписью)"; + /* No comment provided by engineer. */ "(this device v%@)" = "(это устройство v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Внести свой вклад](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Отправить email](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Поставить звездочку в GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Добавить контакт**: создать и поделиться новой ссылкой-приглашением."; @@ -56,7 +59,7 @@ "**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Обратите внимание**: использование одной и той же базы данных на двух устройствах нарушит расшифровку сообщений от ваших контактов, как свойство защиты соединений."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Внимание**: Вы не сможете восстановить или поменять пароль, если Вы его потеряете."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Внимание**: Вы не сможете восстановить или поменять пароль, если потеряете его."; /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Рекомендовано**: токен устройства и уведомления отправляются на сервер SimpleX Chat, но сервер не получает сами сообщения, их размер или от кого они."; @@ -65,10 +68,13 @@ "**Scan / Paste link**: to connect via a link you received." = "**Сканировать / Вставить ссылку**: чтобы соединиться через полученную ссылку."; /* No comment provided by engineer. */ -"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Внимание**: для работы мгновенных уведомлений пароль должен быть сохранен в Keychain."; +"**Test relay** to retrieve its name." = "**Протестируйте релей**, чтобы получить его имя."; /* No comment provided by engineer. */ -"**Warning**: the archive will be removed." = "**Внимание**: архив будет удален."; +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Внимание**: для работы мгновенных уведомлений пароль должен быть сохранён в Keychain."; + +/* No comment provided by engineer. */ +"**Warning**: the archive will be removed." = "**Внимание**: архив будет удалён."; /* No comment provided by engineer. */ "*bold*" = "\\*жирный*"; @@ -164,7 +170,7 @@ "%d file(s) were not downloaded." = "%d файлов не было загружено."; /* time interval */ -"%d hours" = "%d час."; +"%d hours" = "%d ч"; /* alert title */ "%d messages not forwarded" = "%d сообщений не переслано"; @@ -173,7 +179,19 @@ "%d min" = "%d мин"; /* time interval */ -"%d months" = "%d мес."; +"%d months" = "%d мес"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays failed" = "%d релеев с ошибками"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays not active" = "%d релеев неактивны"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays removed" = "%d релеев удалены"; /* time interval */ "%d sec" = "%d сек"; @@ -184,15 +202,50 @@ /* integrity error chat item */ "%d skipped message(s)" = "%d пропущенных сообщение(й)"; +/* channel subscriber count */ +"%d subscriber" = "%d подписчик"; + +/* channel subscriber count */ +"%d subscribers" = "%d подписчиков"; + /* time interval */ "%d weeks" = "%d недель"; +/* channel creation progress +channel relay bar progress */ +"%d/%d relays active" = "%1$d/%2$d релеев активны"; + +/* channel relay bar */ +"%d/%d relays active, %d errors" = "%1$d/%2$d релеев активны, %3$d с ошибками"; + +/* channel creation progress with errors +channel relay bar */ +"%d/%d relays active, %d failed" = "%1$d/%2$d релеев активны, %3$d с ошибками"; + +/* channel relay bar */ +"%d/%d relays active, %d removed" = "%1$d/%2$d релеев активны, %3$d удалены"; + +/* channel subscriber relay bar progress */ +"%d/%d relays connected" = "%1$d/%2$d релеев подключены"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d errors" = "%1$d/%2$d релеев подключены, %3$d с ошибками"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d failed" = "%1$d/%2$d релеев подключены, %3$d с ошибками"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d removed" = "%1$d/%2$d релеев подключены, %3$d удалены"; + /* No comment provided by engineer. */ "%lld" = "%lld"; /* No comment provided by engineer. */ "%lld %@" = "%lld %@"; +/* No comment provided by engineer. */ +"%lld channel events" = "%lld событий канала"; + /* No comment provided by engineer. */ "%lld contact(s) selected" = "Выбрано контактов: %lld"; @@ -209,7 +262,7 @@ "%lld messages blocked" = "%lld сообщений заблокировано"; /* No comment provided by engineer. */ -"%lld messages blocked by admin" = "%lld сообщений заблокировано администратором"; +"%lld messages blocked by admin" = "%lld сообщений заблокировано админом"; /* No comment provided by engineer. */ "%lld messages marked deleted" = "%lld сообщений помечено удалёнными"; @@ -218,7 +271,7 @@ "%lld messages moderated by %@" = "%lld сообщений модерировано членом %@"; /* No comment provided by engineer. */ -"%lld minutes" = "%lld минуты"; +"%lld minutes" = "%lld минут(ы)"; /* No comment provided by engineer. */ "%lld new interface languages" = "%lld новых языков интерфейса"; @@ -262,6 +315,9 @@ /* No comment provided by engineer. */ "~strike~" = "\\~зачеркнуть~"; +/* owner verification */ +"⚠️ Signature verification failed: %@." = "⚠️ Ошибка проверки подписи: %@."; + /* time to disappear */ "0 sec" = "0 сек"; @@ -305,7 +361,10 @@ time interval */ "30 seconds" = "30 секунд"; /* No comment provided by engineer. */ -"A few more things" = "Еще несколько изменений"; +"A few more things" = "Ещё несколько изменений"; + +/* No comment provided by engineer. */ +"A link for one person to connect" = "Ссылка для одного человека"; /* notification title */ "A new contact" = "Новый контакт"; @@ -317,7 +376,7 @@ time interval */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "Отдельное TCP-соединение будет использоваться **для каждого профиля чата, который Вы имеете в приложении**."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Будет использовано отдельное TCP соединение **для каждого контакта и члена группы**.\n**Примечание**: Чем больше подключений, тем быстрее разряжается батарея и расходуется трафик, а некоторые соединения могут отваливаться."; +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Будет использовано отдельное TCP-соединение **для каждого контакта и члена группы**.\n**Примечание**: Чем больше подключений, тем быстрее разряжается батарея и расходуется трафик, а некоторые соединения могут отваливаться."; /* No comment provided by engineer. */ "Abort" = "Прекратить"; @@ -369,7 +428,10 @@ swipe action */ "Accept incognito" = "Принять инкогнито"; /* alert title */ -"Accept member" = "Принять члена"; +"Accept member" = "Принять члена группы"; + +/* No comment provided by engineer. */ +"accepted" = "принят(а)"; /* rcv group event chat item */ "accepted %@" = "принят %@"; @@ -392,6 +454,9 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledgement errors" = "Ошибки подтверждения"; +/* No comment provided by engineer. */ +"active" = "активный"; + /* token status text */ "Active" = "Активный"; @@ -399,7 +464,7 @@ swipe action */ "Active connections" = "Активные соединения"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Добавьте адрес в свой профиль, чтобы Ваши контакты могли поделиться им. Профиль будет отправлен Вашим контактам."; +"Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts." = "Добавьте адрес в свой профиль, чтобы Ваши SimpleX контакты могли поделиться им. Профиль будет отправлен Вашим SimpleX контактам."; /* No comment provided by engineer. */ "Add friends" = "Добавить друзей"; @@ -408,7 +473,7 @@ swipe action */ "Add list" = "Добавить список"; /* placeholder for sending contact request */ -"Add message" = "Добавить cообщение"; +"Add message" = "Добавить сообщение"; /* No comment provided by engineer. */ "Add profile" = "Добавить профиль"; @@ -417,7 +482,7 @@ swipe action */ "Add server" = "Добавить сервер"; /* No comment provided by engineer. */ -"Add servers by scanning QR codes." = "Добавить серверы через QR код."; +"Add servers by scanning QR codes." = "Добавить серверы через QR-код."; /* No comment provided by engineer. */ "Add team members" = "Добавить сотрудников"; @@ -440,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Дополнительные серверы сообщений"; +/* No comment provided by engineer. */ +"Adding relays will be supported later." = "Добавление релеев будет поддерживаться позже."; + /* No comment provided by engineer. */ "Additional accent" = "Дополнительный акцент"; @@ -477,7 +545,7 @@ swipe action */ "Advanced network settings" = "Настройки сети"; /* No comment provided by engineer. */ -"Advanced settings" = "Настройки сети"; +"Advanced settings" = "Дополнительные настройки"; /* chat item text */ "agreeing encryption for %@…" = "шифрование согласовывается для %@…"; @@ -498,7 +566,7 @@ swipe action */ "All chats and messages will be deleted - this cannot be undone!" = "Все чаты и сообщения будут удалены - это нельзя отменить!"; /* alert message */ -"All chats will be removed from the list %@, and the list deleted." = "Все чаты будут удалены из списка %@, и список удален."; +"All chats will be removed from the list %@, and the list deleted." = "Все чаты будут удалены из списка %@, и список удалён."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Все данные удаляются при его вводе."; @@ -513,7 +581,10 @@ swipe action */ "all members" = "все члены"; /* No comment provided by engineer. */ -"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Все сообщения и файлы отправляются с **end-to-end шифрованием**, с постквантовой безопасностью в прямых разговорах."; +"All messages" = "Все сообщения"; + +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Все сообщения и файлы отправляются с **сквозным шифрованием**, с пост-квантовой безопасностью в прямых разговорах."; /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Все сообщения будут удалены - это нельзя отменить!"; @@ -527,6 +598,12 @@ swipe action */ /* profile dropdown */ "All profiles" = "Все профили"; +/* No comment provided by engineer. */ +"All relays failed" = "Все релеи недоступны"; + +/* No comment provided by engineer. */ +"All relays removed" = "Все релеи удалены"; + /* No comment provided by engineer. */ "All reports will be archived for you." = "Все сообщения о нарушениях будут заархивированы для вас."; @@ -537,10 +614,10 @@ swipe action */ "All your contacts will remain connected." = "Все контакты, которые соединились через этот адрес, сохранятся."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "Все Ваши контакты сохранятся. Обновленный профиль будет отправлен Вашим контактам."; +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Все Ваши контакты сохранятся. Обновлённый профиль будет отправлен Вашим контактам."; /* No comment provided by engineer. */ -"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Все ваши контакты, разговоры и файлы будут надежно зашифрованы и загружены на выбранные XFTP серверы."; +"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Все ваши контакты, разговоры и файлы будут надёжно зашифрованы и загружены на выбранные XFTP-серверы."; /* No comment provided by engineer. */ "Allow" = "Разрешить"; @@ -563,6 +640,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Разрешить необратимое удаление сообщений, только если Ваш контакт разрешает это Вам. (24 часа)"; +/* No comment provided by engineer. */ +"Allow members to chat with admins." = "Разрешить членам группы общаться с админами."; + /* No comment provided by engineer. */ "Allow message reactions only if your contact allows them." = "Разрешить реакции на сообщения, только если ваш контакт разрешает их."; @@ -572,12 +652,18 @@ swipe action */ /* No comment provided by engineer. */ "Allow sending direct messages to members." = "Разрешить личные сообщения членам группы."; +/* No comment provided by engineer. */ +"Allow sending direct messages to subscribers." = "Разрешить отправку личных сообщений подписчикам."; + /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Разрешить посылать исчезающие сообщения."; /* No comment provided by engineer. */ "Allow sharing" = "Разрешить поделиться"; +/* No comment provided by engineer. */ +"Allow subscribers to chat with admins." = "Разрешить подписчикам общаться с админами."; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Разрешить необратимо удалять отправленные сообщения. (24 часа)"; @@ -633,7 +719,7 @@ swipe action */ "Always use private routing." = "Всегда использовать конфиденциальную доставку."; /* No comment provided by engineer. */ -"Always use relay" = "Всегда соединяться через relay"; +"Always use relay" = "Всегда соединяться через релей"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Будет создан пустой профиль чата с указанным именем, и приложение откроется в обычном режиме."; @@ -647,9 +733,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Принять звонок"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Кто угодно может запустить сервер."; - /* No comment provided by engineer. */ "App build: %@" = "Сборка приложения: %@"; @@ -669,7 +752,7 @@ swipe action */ "App passcode" = "Код доступа в приложение"; /* No comment provided by engineer. */ -"App passcode is replaced with self-destruct passcode." = "Код доступа в приложение будет заменен кодом самоуничтожения."; +"App passcode is replaced with self-destruct passcode." = "Код доступа в приложение будет заменён кодом самоуничтожения."; /* No comment provided by engineer. */ "App session" = "Сессия приложения"; @@ -729,11 +812,14 @@ swipe action */ "attempts" = "попытки"; /* No comment provided by engineer. */ -"Audio & video calls" = "Аудио- и видеозвонки"; +"Audio & video calls" = "Аудио и видеозвонки"; /* No comment provided by engineer. */ "Audio and video calls" = "Аудио и видео звонки"; +/* No comment provided by engineer. */ +"Audio call" = "Аудиозвонок"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; @@ -759,13 +845,13 @@ swipe action */ "author" = "автор"; /* No comment provided by engineer. */ -"Auto-accept" = "Автоприем"; +"Auto-accept" = "Автоприём"; /* No comment provided by engineer. */ "Auto-accept contact requests" = "Автоматически принимать запросы контактов"; /* No comment provided by engineer. */ -"Auto-accept images" = "Автоприем изображений"; +"Auto-accept images" = "Автоприём изображений"; /* No comment provided by engineer. */ "Back" = "Назад"; @@ -777,10 +863,10 @@ swipe action */ "Bad desktop address" = "Неверный адрес компьютера"; /* integrity error chat item */ -"bad message hash" = "ошибка хэш сообщения"; +"bad message hash" = "ошибка хэша сообщения"; /* No comment provided by engineer. */ -"Bad message hash" = "Ошибка хэш сообщения"; +"Bad message hash" = "Ошибка хэша сообщения"; /* integrity error chat item */ "bad message ID" = "ошибка ID сообщения"; @@ -788,6 +874,15 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "Ошибка ID сообщения"; +/* No comment provided by engineer. */ +"Be free\nin your network" = "Будь свободен\nв своей сети"; + +/* No comment provided by engineer. */ +"Be free in your network." = "Будь свободен в своей сети."; + +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "Потому что мы разрушили саму возможность узнать, кто вы. Чтобы вашу свободу невозможно было отнять."; + /* No comment provided by engineer. */ "Better calls" = "Улучшенные звонки"; @@ -825,7 +920,7 @@ swipe action */ "Bio too large" = "Описание слишком длинное"; /* No comment provided by engineer. */ -"Black" = "Черная"; +"Black" = "Чёрная"; /* No comment provided by engineer. */ "Block" = "Заблокировать"; @@ -845,6 +940,9 @@ swipe action */ /* No comment provided by engineer. */ "Block member?" = "Заблокировать члена группы?"; +/* No comment provided by engineer. */ +"Block subscriber for all?" = "Заблокировать подписчика для всех?"; + /* marked deleted chat item preview text */ "blocked" = "заблокировано"; @@ -889,16 +987,22 @@ marked deleted chat item preview text */ "Both you and your contact can send voice messages." = "Вы и Ваш контакт можете отправлять голосовые сообщения."; /* No comment provided by engineer. */ -"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Болгарский, финский, тайский и украинский - благодаря пользователям и [Weblate] (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +"Bottom bar" = "Нижнее меню"; + +/* compose placeholder for channel owner */ +"Broadcast" = "Опубликовать"; /* No comment provided by engineer. */ -"Business address" = "Бизнес адрес"; +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Болгарский, финский, тайский и украинский - благодаря пользователям и [Weblate] (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + +/* chat link info line */ +"Business address" = "Бизнес-адрес"; /* No comment provided by engineer. */ "Business chats" = "Бизнес разговоры"; /* No comment provided by engineer. */ -"Business connection" = "Бизнес контакт"; +"Business connection" = "Бизнес-контакт"; /* No comment provided by engineer. */ "Businesses" = "Бизнесы"; @@ -906,14 +1010,11 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Используя SimpleX Chat, Вы согласны:\n- отправлять только законные сообщения в публичных группах.\n- уважать других пользователей – не отправлять спам."; - /* No comment provided by engineer. */ "call" = "звонок"; /* No comment provided by engineer. */ -"Call already ended!" = "Звонок уже завершен!"; +"Call already ended!" = "Звонок уже завершён!"; /* call status */ "call error" = "ошибка звонка"; @@ -934,7 +1035,10 @@ marked deleted chat item preview text */ "Camera not available" = "Камера недоступна"; /* No comment provided by engineer. */ -"Can't call contact" = "Не удается позвонить контакту"; +"can't broadcast" = "нельзя публиковать"; + +/* No comment provided by engineer. */ +"Can't call contact" = "Не удаётся позвонить контакту"; /* No comment provided by engineer. */ "Can't call member" = "Не удаётся позвонить члену группы"; @@ -987,7 +1091,7 @@ new chat action */ "Change automatic message deletion?" = "Измененить автоматическое удаление сообщений?"; /* authentication reason */ -"Change chat profiles" = "Поменять профили"; +"Change chat profiles" = "Изменить профили чата"; /* No comment provided by engineer. */ "Change database passphrase?" = "Поменять пароль базы данных?"; @@ -1032,6 +1136,58 @@ set passcode view */ /* chat item text */ "changing address…" = "смена адреса…"; +/* shown as sender role for channel messages */ +"channel" = "канал"; + +/* No comment provided by engineer. */ +"Channel" = "Канал"; + +/* No comment provided by engineer. */ +"Channel display name" = "Имя канала"; + +/* No comment provided by engineer. */ +"Channel full name (optional)" = "Полное имя канала (необязательно)"; + +/* alert message +alert subtitle */ +"Channel has no active relays. Please try to join later." = "У канала нет активных релеев. Попробуйте подключиться позже."; + +/* No comment provided by engineer. */ +"Channel image" = "Картинка канала"; + +/* chat link info line */ +"Channel link" = "Ссылка канала"; + +/* No comment provided by engineer. */ +"Channel preferences" = "Предпочтения канала"; + +/* No comment provided by engineer. */ +"Channel profile" = "Профиль канала"; + +/* No comment provided by engineer. */ +"Channel profile is stored on subscribers' devices and on the chat relays." = "Профиль канала хранится на устройствах подписчиков и на чат-релеях."; + +/* snd group event chat item */ +"channel profile updated" = "профиль канала обновлён"; + +/* alert message */ +"Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers." = "Профиль канала был изменен. Если Вы сохраните его, обновлённый профиль будет отправлен подписчикам канала."; + +/* alert title */ +"Channel temporarily unavailable" = "Канал временно недоступен"; + +/* No comment provided by engineer. */ +"Channel will be deleted for all subscribers - this cannot be undone!" = "Канал будет удалён для всех подписчиков - это нельзя отменить!"; + +/* No comment provided by engineer. */ +"Channel will be deleted for you - this cannot be undone!" = "Канал будет удалён для Вас - это нельзя отменить!"; + +/* alert message */ +"Channel will start working with %d of %d relays. Proceed?" = "Канал начнёт работу с %1$d из %2$d релеев. Продолжить?"; + +/* No comment provided by engineer. */ +"Channels" = "Каналы"; + /* No comment provided by engineer. */ "Chat" = "Разговор"; @@ -1066,7 +1222,7 @@ set passcode view */ "Chat is stopped" = "Чат остановлен"; /* No comment provided by engineer. */ -"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "Чат остановлен. Если вы уже использовали эту базу данных на другом устройстве, перенесите ее обратно до запуска чата."; +"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "Чат остановлен. Если вы уже использовали эту базу данных на другом устройстве, перенесите её обратно до запуска чата."; /* No comment provided by engineer. */ "Chat list" = "Список чатов"; @@ -1075,7 +1231,7 @@ set passcode view */ "Chat migrated!" = "Чат мигрирован!"; /* No comment provided by engineer. */ -"Chat preferences" = "Предпочтения"; +"Chat preferences" = "Настройки чатов"; /* alert message */ "Chat preferences were changed." = "Настройки чата были изменены."; @@ -1083,36 +1239,64 @@ set passcode view */ /* No comment provided by engineer. */ "Chat profile" = "Профиль чата"; +/* No comment provided by engineer. */ +"Chat relay" = "Чат-релей"; + +/* No comment provided by engineer. */ +"Chat relays" = "Чат-релеи"; + +/* No comment provided by engineer. */ +"Chat relays forward messages in channels you create." = "Чат-релеи пересылают сообщения в Ваших каналах."; + +/* No comment provided by engineer. */ +"Chat relays forward messages to channel subscribers." = "Чат-релеи пересылают сообщения подписчикам каналов."; + /* No comment provided by engineer. */ "Chat theme" = "Тема чата"; /* No comment provided by engineer. */ -"Chat will be deleted for all members - this cannot be undone!" = "Разговор будет удален для всех участников - это действие нельзя отменить!"; +"Chat will be deleted for all members - this cannot be undone!" = "Разговор будет удалён для всех участников - это действие нельзя отменить!"; /* No comment provided by engineer. */ -"Chat will be deleted for you - this cannot be undone!" = "Разговор будет удален для Вас - это действие нельзя отменить!"; +"Chat will be deleted for you - this cannot be undone!" = "Разговор будет удалён для Вас - это действие нельзя отменить!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Чат с админами"; /* No comment provided by engineer. */ "Chat with member" = "Чат с членом группы"; /* No comment provided by engineer. */ -"Chat with members before they join." = "Общайтесь с членами до того как принять их."; +"Chat with members before they join." = "Общайтесь с членами группы до того как принять их."; /* No comment provided by engineer. */ "Chats" = "Чаты"; +/* No comment provided by engineer. */ +"Chats with admins are prohibited." = "Чаты с админами запрещены."; + +/* alert message */ +"Chats with admins in public channels have no E2E encryption - use only with trusted chat relays." = "Чаты с админами в публичных каналах не имеют E2E шифрования - используйте только с доверенными чат-релеями."; + /* No comment provided by engineer. */ "Chats with members" = "Чаты с членами группы"; +/* No comment provided by engineer. */ +"Chats with members are disabled" = "Чаты с членами группы отключены"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Проверять сообщения каждые 20 минут."; /* No comment provided by engineer. */ "Check messages when allowed." = "Проверять сообщения по возможности."; +/* alert message */ +"Check relay address and try again." = "Проверьте адрес релея и попробуйте снова."; + +/* alert message */ +"Check relay name and try again." = "Проверьте имя релея и попробуйте снова."; + /* alert title */ "Check server address and try again." = "Проверьте адрес сервера и попробуйте снова."; @@ -1120,7 +1304,7 @@ set passcode view */ "Chinese and Spanish interface" = "Китайский и Испанский интерфейс"; /* No comment provided by engineer. */ -"Choose _Migrate from another device_ on the new device and scan QR code." = "Выберите _Мигрировать с другого устройства_ на новом устройстве и сосканируйте QR код."; +"Choose _Migrate from another device_ on the new device and scan QR code." = "Выберите _Мигрировать с другого устройства_ на новом устройстве и сосканируйте QR-код."; /* No comment provided by engineer. */ "Choose file" = "Выбрать файл"; @@ -1191,7 +1375,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Условия уже приняты для следующих оператора(ов): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Условия использования"; /* No comment provided by engineer. */ @@ -1204,10 +1388,10 @@ set passcode view */ "Conditions will be automatically accepted for enabled operators on: %@." = "Условия будут автоматически приняты для включенных операторов: %@."; /* No comment provided by engineer. */ -"Configure ICE servers" = "Настройка ICE серверов"; +"Configure ICE servers" = "Настройка ICE-серверов"; /* No comment provided by engineer. */ -"Configure server operators" = "Настроить операторов серверов"; +"Configure relays" = "Настроить релеи"; /* No comment provided by engineer. */ "Confirm" = "Подтвердить"; @@ -1234,7 +1418,7 @@ set passcode view */ "Confirm password" = "Подтвердить пароль"; /* No comment provided by engineer. */ -"Confirm that you remember database passphrase to migrate it." = "Подтвердите, что Вы помните пароль базы данных для ее миграции."; +"Confirm that you remember database passphrase to migrate it." = "Подтвердите, что Вы помните пароль базы данных для её миграции."; /* No comment provided by engineer. */ "Confirm upload" = "Подтвердить загрузку"; @@ -1242,7 +1426,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Подтвержденный"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Соединиться"; /* No comment provided by engineer. */ @@ -1272,6 +1457,9 @@ set passcode view */ /* new chat sheet title */ "Connect via link" = "Соединиться через ссылку"; +/* No comment provided by engineer. */ +"Connect via link or QR code" = "Соединитесь по ссылке или QR"; + /* new chat sheet title */ "Connect via one-time link" = "Соединиться через одноразовую ссылку"; @@ -1279,7 +1467,7 @@ set passcode view */ "Connect with %@" = "Соединиться с %@"; /* No comment provided by engineer. */ -"connected" = "соединение установлено"; +"connected" = "соединен(а)"; /* No comment provided by engineer. */ "Connected" = "Соединено"; @@ -1341,12 +1529,15 @@ set passcode view */ /* alert title */ "Connection error" = "Ошибка соединения"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Ошибка соединения (AUTH)"; /* chat list item title (it should not be shown */ "connection established" = "соединение установлено"; +/* No comment provided by engineer. */ +"Connection failed" = "Ошибка соединения"; + /* No comment provided by engineer. */ "Connection is blocked by server operator:\n%@" = "Соединение заблокировано сервером оператора:\n%@"; @@ -1383,6 +1574,9 @@ set passcode view */ /* profile update event chat item */ "contact %@ changed to %@" = "контакт %1$@ изменён на %2$@"; +/* chat link info line */ +"Contact address" = "Адрес контакта"; + /* No comment provided by engineer. */ "Contact allows" = "Контакт разрешает"; @@ -1390,10 +1584,10 @@ set passcode view */ "Contact already exists" = "Существующий контакт"; /* No comment provided by engineer. */ -"contact deleted" = "контакт удален"; +"contact deleted" = "контакт удалён"; /* No comment provided by engineer. */ -"Contact deleted!" = "Контакт удален!"; +"Contact deleted!" = "Контакт удалён!"; /* No comment provided by engineer. */ "contact disabled" = "контакт выключен"; @@ -1411,7 +1605,7 @@ set passcode view */ "Contact is connected" = "Соединение с контактом установлено"; /* No comment provided by engineer. */ -"Contact is deleted." = "Контакт удален."; +"Contact is deleted." = "Контакт удалён."; /* No comment provided by engineer. */ "Contact name" = "Имена контактов"; @@ -1429,7 +1623,7 @@ set passcode view */ "contact should accept…" = "контакт должен принять…"; /* No comment provided by engineer. */ -"Contact will be deleted - this cannot be undone!" = "Контакт будет удален — это нельзя отменить!"; +"Contact will be deleted - this cannot be undone!" = "Контакт будет удалён - это нельзя отменить!"; /* No comment provided by engineer. */ "Contacts" = "Контакты"; @@ -1444,13 +1638,16 @@ set passcode view */ "Continue" = "Продолжить"; /* No comment provided by engineer. */ -"Conversation deleted!" = "Разговор удален!"; +"Contribute" = "Внести свой вклад"; /* No comment provided by engineer. */ -"Copy" = "Скопировать"; +"Conversation deleted!" = "Разговор удалён!"; /* No comment provided by engineer. */ -"Copy error" = "Ошибка копирования"; +"Copy" = "Копировать"; + +/* No comment provided by engineer. */ +"Copy error" = "Скопировать ошибку"; /* No comment provided by engineer. */ "Core version: v%@" = "Версия ядра: v%@"; @@ -1458,12 +1655,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Угол"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Исправить имя на %@?"; -/* No comment provided by engineer. */ -"Create" = "Создать"; - /* No comment provided by engineer. */ "Create 1-time link" = "Создать одноразовую ссылку"; @@ -1491,6 +1685,12 @@ set passcode view */ /* No comment provided by engineer. */ "Create profile" = "Создать профиль"; +/* No comment provided by engineer. */ +"Create public channel" = "Создать публичный канал"; + +/* No comment provided by engineer. */ +"Create public channel (BETA)" = "Создать публичный канал (БЕТА)"; + /* server test step */ "Create queue" = "Создание очереди"; @@ -1500,9 +1700,15 @@ set passcode view */ /* No comment provided by engineer. */ "Create your address" = "Создайте Ваш адрес"; +/* No comment provided by engineer. */ +"Create your link" = "Создайте Вашу ссылку"; + /* No comment provided by engineer. */ "Create your profile" = "Создать профиль"; +/* No comment provided by engineer. */ +"Create your public address" = "Создайте Ваш публичный адрес"; + /* No comment provided by engineer. */ "Created" = "Создано"; @@ -1515,6 +1721,9 @@ set passcode view */ /* No comment provided by engineer. */ "Creating archive link" = "Создание ссылки на архив"; +/* No comment provided by engineer. */ +"Creating channel" = "Создание канала"; + /* No comment provided by engineer. */ "Creating link…" = "Создаётся ссылка…"; @@ -1561,7 +1770,7 @@ set passcode view */ "Database encrypted!" = "База данных зашифрована!"; /* No comment provided by engineer. */ -"Database encryption passphrase will be updated and stored in the keychain.\n" = "Пароль базы данных будет изменен и сохранен в Keychain.\n"; +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Пароль базы данных будет изменен и сохранён в Keychain.\n"; /* No comment provided by engineer. */ "Database encryption passphrase will be updated.\n" = "Пароль базы данных будет изменен.\n"; @@ -1591,10 +1800,10 @@ set passcode view */ "Database passphrase & export" = "Пароль и экспорт базы"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Пароль базы данных отличается от сохраненного в Keychain."; +"Database passphrase is different from saved in the keychain." = "Пароль базы данных отличается от сохранённого в Keychain."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "Введите пароль базы данных чтобы открыть чат."; +"Database passphrase is required to open chat." = "Введите пароль базы данных, чтобы открыть чат."; /* No comment provided by engineer. */ "Database upgrade" = "Обновление базы данных"; @@ -1603,7 +1812,7 @@ set passcode view */ "database version is newer than the app, but no down migration for: %@" = "версия базы данных новее чем приложения, но нет миграции для отката: %@"; /* No comment provided by engineer. */ -"Database will be encrypted and the passphrase stored in the keychain.\n" = "База данных будет зашифрована и пароль сохранен в Keychain.\n"; +"Database will be encrypted and the passphrase stored in the keychain.\n" = "База данных будет зашифрована и пароль сохранён в Keychain.\n"; /* No comment provided by engineer. */ "Database will be encrypted.\n" = "База данных будет зашифрована.\n"; @@ -1617,8 +1826,8 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Отладка доставки"; -/* No comment provided by engineer. */ -"Decentralized" = "Децентрализованный"; +/* relay test step */ +"Decode link" = "Расшифровать ссылку"; /* message decrypt error item */ "Decryption error" = "Ошибка расшифровки"; @@ -1661,6 +1870,12 @@ swipe action */ /* No comment provided by engineer. */ "Delete and notify contact" = "Удалить и уведомить контакт"; +/* No comment provided by engineer. */ +"Delete channel" = "Удалить канал"; + +/* No comment provided by engineer. */ +"Delete channel?" = "Удалить канал?"; + /* No comment provided by engineer. */ "Delete chat" = "Удалить разговор"; @@ -1704,7 +1919,7 @@ swipe action */ "Delete files for all chat profiles" = "Удалить файлы во всех профилях чата"; /* chat feature */ -"Delete for everyone" = "Удалить для всех"; +"Delete for everyone" = "Удаление для всех"; /* No comment provided by engineer. */ "Delete for me" = "Удалить для меня"; @@ -1728,12 +1943,19 @@ swipe action */ "Delete list?" = "Удалить список?"; /* No comment provided by engineer. */ -"Delete member message?" = "Удалить сообщение участника?"; +"Delete member message?" = "Удалить сообщение члена группы\\?"; + +/* No comment provided by engineer. */ +"Delete member messages" = "Удалить сообщения члена группы"; + +/* alert title */ +"Delete member messages?" = "Удалить сообщения члена группы?"; /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Удалить сообщения"; /* No comment provided by engineer. */ @@ -1757,6 +1979,9 @@ swipe action */ /* server test step */ "Delete queue" = "Удаление очереди"; +/* No comment provided by engineer. */ +"Delete relay" = "Удалить релей"; + /* No comment provided by engineer. */ "Delete report" = "Удалить сообщение о нарушении"; @@ -1781,6 +2006,9 @@ swipe action */ /* copied message info */ "Deleted at: %@" = "Удалено: %@"; +/* rcv group event chat item */ +"deleted channel" = "удалил(а) канал"; + /* rcv direct event chat item */ "deleted contact" = "удалил(а) контакт"; @@ -1866,10 +2094,16 @@ swipe action */ "Direct messages" = "Прямые сообщения"; /* No comment provided by engineer. */ -"Direct messages between members are prohibited in this chat." = "Личные сообщения запрещены в этой группе."; +"Direct messages between members are prohibited in this chat." = "Прямые сообщения между членами группы запрещены."; /* No comment provided by engineer. */ -"Direct messages between members are prohibited." = "Прямые сообщения между членами запрещены."; +"Direct messages between members are prohibited." = "Прямые сообщения между членами группы запрещены."; + +/* No comment provided by engineer. */ +"Direct messages between subscribers are prohibited." = "Прямые сообщения между подписчиками запрещены."; + +/* alert button */ +"Disable" = "Выключить"; /* No comment provided by engineer. */ "Disable (keep overrides)" = "Выключить (кроме исключений)"; @@ -1887,7 +2121,7 @@ swipe action */ "Disable SimpleX Lock" = "Отключить блокировку SimpleX"; /* No comment provided by engineer. */ -"disabled" = "выключено"; +"disabled" = "выключен"; /* No comment provided by engineer. */ "Disabled" = "Выключено"; @@ -1902,7 +2136,7 @@ swipe action */ "Disappearing messages are prohibited in this chat." = "Исчезающие сообщения запрещены в этом чате."; /* No comment provided by engineer. */ -"Disappearing messages are prohibited." = "Исчезающие сообщения запрещены в этой группе."; +"Disappearing messages are prohibited." = "Исчезающие сообщения запрещены."; /* No comment provided by engineer. */ "Disappears at" = "Исчезает"; @@ -1911,7 +2145,7 @@ swipe action */ "Disappears at: %@" = "Исчезает: %@"; /* server test step */ -"Disconnect" = "Разрыв соединения"; +"Disconnect" = "Отключить"; /* No comment provided by engineer. */ "Disconnect desktop?" = "Отключить компьютер?"; @@ -1928,11 +2162,14 @@ swipe action */ /* No comment provided by engineer. */ "Do not send history to new members." = "Не отправлять историю новым членам."; +/* No comment provided by engineer. */ +"Do not send history to new subscribers." = "Не отправлять историю новым подписчикам."; + /* No comment provided by engineer. */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "Не отправлять сообщения напрямую, даже если сервер получателя не поддерживает конфиденциальную доставку."; /* No comment provided by engineer. */ -"Do not use credentials with proxy." = "Не использовать учетные данные с прокси."; +"Do not use credentials with proxy." = "Не использовать учётные данные с прокси."; /* No comment provided by engineer. */ "Do NOT use private routing." = "Не использовать конфиденциальную доставку."; @@ -1966,7 +2203,7 @@ chat item action */ "Download" = "Загрузить"; /* No comment provided by engineer. */ -"Download errors" = "Ошибки приема"; +"Download errors" = "Ошибки приёма"; /* No comment provided by engineer. */ "Download failed" = "Ошибка загрузки"; @@ -2007,27 +2244,39 @@ chat item action */ /* No comment provided by engineer. */ "E2E encrypted notifications." = "E2E зашифрованные нотификации."; +/* No comment provided by engineer. */ +"Easier to invite your friends 👋" = "Проще пригласить друзей 👋"; + /* chat item action */ "Edit" = "Редактировать"; +/* No comment provided by engineer. */ +"Edit channel profile" = "Редактировать профиль канала"; + /* No comment provided by engineer. */ "Edit group profile" = "Редактировать профиль группы"; /* No comment provided by engineer. */ "Empty message!" = "Пустое сообщение!"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Включить"; /* No comment provided by engineer. */ "Enable (keep overrides)" = "Включить (кроме исключений)"; +/* channel creation warning */ +"Enable at least one chat relay in Network & Servers." = "Включите хотя бы один чат-релей в настройках Сеть и серверы."; + /* alert title */ "Enable automatic message deletion?" = "Включить автоматическое удаление сообщений?"; /* No comment provided by engineer. */ "Enable camera access" = "Включить доступ к камере"; +/* alert title */ +"Enable chats with admins?" = "Включить чаты с админами?"; + /* No comment provided by engineer. */ "Enable disappearing messages by default." = "Включите исчезающие сообщения по умолчанию."; @@ -2043,11 +2292,11 @@ chat item action */ /* No comment provided by engineer. */ "Enable instant notifications?" = "Включить мгновенные уведомления?"; -/* No comment provided by engineer. */ -"Enable lock" = "Включить блокировку"; +/* alert title */ +"Enable link previews?" = "Включить картинки ссылок?"; /* No comment provided by engineer. */ -"Enable notifications" = "Включить уведомления"; +"Enable lock" = "Включить блокировку"; /* No comment provided by engineer. */ "Enable periodic notifications?" = "Включить периодические уведомления?"; @@ -2089,7 +2338,7 @@ chat item action */ "Encrypt local files" = "Шифровать локальные файлы"; /* No comment provided by engineer. */ -"Encrypt stored files & media" = "Шифруйте сохраненные файлы и медиа"; +"Encrypt stored files & media" = "Шифруйте сохранённые файлы и медиа"; /* No comment provided by engineer. */ "Encrypted database" = "База данных зашифрована"; @@ -2110,7 +2359,7 @@ chat item action */ "Encrypted message: keychain error" = "Зашифрованное сообщение: ошибка Keychain"; /* notification */ -"Encrypted message: no passphrase" = "Зашифрованное сообщение: пароль не сохранен"; +"Encrypted message: no passphrase" = "Зашифрованное сообщение: пароль не сохранён"; /* notification */ "Encrypted message: unexpected error" = "Зашифрованное сообщение: неожиданная ошибка"; @@ -2154,6 +2403,9 @@ chat item action */ /* call status */ "ended call %@" = "завершённый звонок %@"; +/* No comment provided by engineer. */ +"Enter channel name…" = "Введите имя канала…"; + /* No comment provided by engineer. */ "Enter correct passphrase." = "Введите правильный пароль."; @@ -2172,6 +2424,12 @@ chat item action */ /* No comment provided by engineer. */ "Enter password above to show!" = "Введите пароль выше, чтобы раскрыть!"; +/* No comment provided by engineer. */ +"Enter profile name..." = "Введите имя профиля..."; + +/* No comment provided by engineer. */ +"Enter relay name…" = "Введите имя релея…"; + /* No comment provided by engineer. */ "Enter server manually" = "Ввести сервер вручную"; @@ -2190,14 +2448,14 @@ chat item action */ /* No comment provided by engineer. */ "error" = "ошибка"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Ошибка"; /* No comment provided by engineer. */ "Error aborting address change" = "Ошибка при прекращении изменения адреса"; /* alert title */ -"Error accepting conditions" = "Ошибка приема условий"; +"Error accepting conditions" = "Ошибка приёма условий"; /* No comment provided by engineer. */ "Error accepting contact request" = "Ошибка при принятии запроса на соединение"; @@ -2208,6 +2466,9 @@ chat item action */ /* No comment provided by engineer. */ "Error adding member(s)" = "Ошибка при добавлении членов группы"; +/* alert title */ +"Error adding relay" = "Ошибка добавления релея"; + /* alert title */ "Error adding server" = "Ошибка добавления сервера"; @@ -2238,9 +2499,15 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Ошибка подключения к пересылающему серверу %@. Попробуйте позже."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Ошибка подключения к серверу, используемому для получения сообщений от этого соединения: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Ошибка при создании адреса"; +/* alert title */ +"Error creating channel" = "Ошибка при создании канала"; + /* No comment provided by engineer. */ "Error creating group" = "Ошибка при создании группы"; @@ -2322,9 +2589,6 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "Ошибка при открытии чата"; -/* No comment provided by engineer. */ -"Error opening group" = "Ошибка при открытии группы"; - /* alert title */ "Error receiving file" = "Ошибка при получении файла"; @@ -2349,6 +2613,9 @@ chat item action */ /* No comment provided by engineer. */ "Error resetting statistics" = "Ошибка сброса статистики"; +/* No comment provided by engineer. */ +"Error saving channel profile" = "Ошибка при сохранении профиля канала"; + /* alert title */ "Error saving chat list" = "Ошибка сохранения списка чатов"; @@ -2356,7 +2623,7 @@ chat item action */ "Error saving group profile" = "Ошибка при сохранении профиля группы"; /* No comment provided by engineer. */ -"Error saving ICE servers" = "Ошибка при сохранении ICE серверов"; +"Error saving ICE servers" = "Ошибка при сохранении ICE-серверов"; /* No comment provided by engineer. */ "Error saving passcode" = "Ошибка сохранения кода"; @@ -2391,6 +2658,9 @@ chat item action */ /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Ошибка настроек отчётов о доставке!"; +/* alert title */ +"Error sharing channel" = "Ошибка при публикации канала"; + /* No comment provided by engineer. */ "Error starting chat" = "Ошибка при запуске чата"; @@ -2433,12 +2703,16 @@ chat item action */ /* No comment provided by engineer. */ "Error: " = "Ошибка: "; +/* receive error chat item */ +"error: %@" = "ошибка: %@"; + /* alert message file error text snd error text */ "Error: %@" = "Ошибка: %@"; -/* server test error */ +/* relay test error +server test error */ "Error: %@." = "Ошибка: %@."; /* No comment provided by engineer. */ @@ -2486,6 +2760,9 @@ snd error text */ /* No comment provided by engineer. */ "Exporting database archive…" = "Архив чата экспортируется…"; +/* No comment provided by engineer. */ +"failed" = "ошибка"; + /* No comment provided by engineer. */ "Failed to remove passphrase" = "Ошибка удаления пароля"; @@ -2496,7 +2773,7 @@ snd error text */ "Faster deletion of groups." = "Ускорено удаление групп."; /* No comment provided by engineer. */ -"Faster joining and more reliable messages." = "Быстрое вступление и надежная доставка сообщений."; +"Faster joining and more reliable messages." = "Быстрое вступление и надёжная доставка сообщений."; /* No comment provided by engineer. */ "Faster sending messages." = "Ускорена отправка сообщений."; @@ -2517,7 +2794,7 @@ snd error text */ "File is blocked by server operator:\n%@." = "Файл заблокирован оператором сервера:\n%@."; /* file error text */ -"File not found - most likely file was deleted or cancelled." = "Файл не найден - скорее всего, файл был удален или отменен."; +"File not found - most likely file was deleted or cancelled." = "Файл не найден - скорее всего, файл был удалён или отменен."; /* file error text */ "File server error: %@" = "Ошибка сервера файлов: %@"; @@ -2553,7 +2830,7 @@ snd error text */ "Files and media are prohibited in this chat." = "Файлы и медиа запрещены в этом чате."; /* No comment provided by engineer. */ -"Files and media are prohibited." = "Файлы и медиа запрещены в этой группе."; +"Files and media are prohibited." = "Файлы и медиа запрещены."; /* No comment provided by engineer. */ "Files and media not allowed" = "Файлы и медиа не разрешены"; @@ -2561,6 +2838,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "Файлы и медиа запрещены!"; +/* No comment provided by engineer. */ +"Filter" = "Фильтр"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Фильтровать непрочитанные и избранные чаты."; @@ -2574,7 +2854,7 @@ snd error text */ "Finally, we have them! 🚀" = "Наконец-то, мы их добавили! 🚀"; /* No comment provided by engineer. */ -"Find chats faster" = "Быстро найти чаты"; +"Find chats faster" = "Быстрый поиск чатов"; /* No comment provided by engineer. */ "Fingerprint in destination server address does not match certificate: %@." = "Хэш в адресе сервера назначения не соответствует сертификату: %@."; @@ -2585,8 +2865,9 @@ snd error text */ /* No comment provided by engineer. */ "Fingerprint in server address does not match certificate: %@." = "Хэш в адресе сервера не соответствует сертификату: %@."; -/* server test error */ -"Fingerprint in server address does not match certificate." = "Возможно, хэш сертификата в адресе сервера неверный"; +/* relay test error +server test error */ +"Fingerprint in server address does not match certificate." = "Хэш в адресе сервера не соответствует сертификату."; /* No comment provided by engineer. */ "Fix" = "Починить"; @@ -2604,12 +2885,16 @@ snd error text */ "Fix not supported by contact" = "Починка не поддерживается контактом"; /* No comment provided by engineer. */ -"Fix not supported by group member" = "Починка не поддерживается членом группы."; +"Fix not supported by group member" = "Починка не поддерживается членом группы"; /* No comment provided by engineer. */ "For all moderators" = "Для всех модераторов"; -/* servers error */ +/* No comment provided by engineer. */ +"For anyone to reach you" = "Любой может связаться с Вами"; + +/* servers error +servers warning */ "For chat profile %@:" = "Для профиля чата %@:"; /* No comment provided by engineer. */ @@ -2693,9 +2978,15 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "Уменьшенное потребление батареи"; +/* relay test step */ +"Get link" = "Получить ссылку"; + /* No comment provided by engineer. */ "Get notified when mentioned." = "Уведомления, когда Вас упомянули."; +/* No comment provided by engineer. */ +"Get started" = "Начать"; + /* No comment provided by engineer. */ "GIFs and stickers" = "ГИФ файлы и стикеры"; @@ -2741,7 +3032,7 @@ snd error text */ /* No comment provided by engineer. */ "group is deleted" = "группа удалена"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Ссылка группы"; /* No comment provided by engineer. */ @@ -2763,7 +3054,7 @@ snd error text */ "Group profile is stored on members' devices, not on the servers." = "Профиль группы хранится на устройствах членов, а не на серверах."; /* snd group event chat item */ -"group profile updated" = "профиль группы обновлен"; +"group profile updated" = "профиль группы обновлён"; /* alert message */ "Group profile was changed. If you save it, the updated profile will be sent to group members." = "Профиль группы изменен. Если Вы сохраните его, новый профиль будет отправлен членам группы."; @@ -2784,7 +3075,7 @@ snd error text */ "Help" = "Помощь"; /* No comment provided by engineer. */ -"Help admins moderating their groups." = "Помогайте администраторам модерировать их группы."; +"Help admins moderating their groups." = "Помогайте админам модерировать их группы."; /* No comment provided by engineer. */ "Hidden" = "Скрытое"; @@ -2796,7 +3087,7 @@ snd error text */ "Hidden profile password" = "Пароль скрытого профиля"; /* chat item action */ -"Hide" = "Спрятать"; +"Hide" = "Скрыть"; /* No comment provided by engineer. */ "Hide app screen in the recent apps." = "Скрыть экран приложения."; @@ -2813,6 +3104,9 @@ snd error text */ /* No comment provided by engineer. */ "History is not sent to new members." = "История не отправляется новым членам."; +/* No comment provided by engineer. */ +"History is not sent to new subscribers." = "История не отправляется новым подписчикам."; + /* time unit */ "hours" = "часов"; @@ -2832,7 +3126,7 @@ snd error text */ "How to" = "Инфо"; /* No comment provided by engineer. */ -"How to use it" = "Про адрес"; +"How to use it" = "Как использовать"; /* No comment provided by engineer. */ "How to use your servers" = "Как использовать серверы"; @@ -2841,7 +3135,7 @@ snd error text */ "Hungarian interface" = "Венгерский интерфейс"; /* No comment provided by engineer. */ -"ICE servers (one per line)" = "ICE серверы (один на строке)"; +"ICE servers (one per line)" = "ICE-серверы (один на строке)"; /* No comment provided by engineer. */ "If you can't meet in person, show QR code in a video call, or share the link." = "Если Вы не можете встретиться лично, покажите QR-код во время видеозвонка или поделитесь ссылкой."; @@ -2852,6 +3146,9 @@ snd error text */ /* No comment provided by engineer. */ "If you enter your self-destruct passcode while opening the app:" = "Если Вы введёте код самоуничтожения при открытии приложения:"; +/* down migration warning */ +"If you joined or created channels, they will stop working permanently." = "Если Вы присоединились к каналам или создали их, они перестанут работать навсегда."; + /* No comment provided by engineer. */ "If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Если сейчас Вам нужно использовать чат, нажмите **Отложить** внизу (Вы сможете мигрировать данные чата при следующем запуске приложения)."; @@ -2865,10 +3162,10 @@ snd error text */ "Image will be received when your contact is online, please wait or check later!" = "Изображение будет принято, когда Ваш контакт будет в сети, подождите или проверьте позже!"; /* No comment provided by engineer. */ -"Immediately" = "Сразу"; +"Images" = "Изображения"; /* No comment provided by engineer. */ -"Immune to spam" = "Защищен от спама"; +"Immediately" = "Сразу"; /* No comment provided by engineer. */ "Import" = "Импортировать"; @@ -2928,7 +3225,7 @@ snd error text */ "Incognito mode" = "Режим Инкогнито"; /* No comment provided by engineer. */ -"Incognito mode protects your privacy by using a new random profile for each contact." = "Режим Инкогнито защищает Вашу конфиденциальность — для каждого контакта создается новый случайный профиль."; +"Incognito mode protects your privacy by using a new random profile for each contact." = "Режим Инкогнито защищает Вашу конфиденциальность - для каждого контакта создаётся новый случайный профиль."; /* chat list item description */ "incognito via contact address link" = "инкогнито через ссылку-контакт"; @@ -2970,7 +3267,7 @@ snd error text */ "Initial role" = "Роль при вступлении"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "SimpleX Chat для терминала"; /* No comment provided by engineer. */ "Instant" = "Мгновенно"; @@ -3005,7 +3302,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "ошибка данных чата"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Ошибка в ссылке контакта"; /* invalid chat item */ @@ -3020,11 +3317,17 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Ошибка подтверждения миграции"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Неверное имя!"; /* No comment provided by engineer. */ -"Invalid QR code" = "Неверный QR код"; +"Invalid QR code" = "Ошибка QR-кода"; + +/* alert title */ +"Invalid relay address!" = "Неверный адрес релея!"; + +/* alert title */ +"Invalid relay name!" = "Неверное имя релея!"; /* No comment provided by engineer. */ "Invalid response" = "Ошибка ответа"; @@ -3048,7 +3351,13 @@ snd error text */ "Invite friends" = "Пригласить друзей"; /* No comment provided by engineer. */ -"Invite members" = "Пригласить членов группы"; +"Invite member" = "Пригласить члена группы"; + +/* No comment provided by engineer. */ +"Invite members" = "Пригласить в группу"; + +/* No comment provided by engineer. */ +"Invite someone privately" = "Пригласите конфиденциально"; /* No comment provided by engineer. */ "Invite to chat" = "Пригласить в разговор"; @@ -3072,19 +3381,19 @@ snd error text */ "iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "iOS Keychain используется для безопасного хранения пароля - это позволяет получать мгновенные уведомления."; /* No comment provided by engineer. */ -"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления."; +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранён в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления."; /* No comment provided by engineer. */ -"IP address" = "IP адрес"; +"IP address" = "IP-адрес"; /* No comment provided by engineer. */ "Irreversible message deletion" = "Окончательное удаление сообщений"; /* No comment provided by engineer. */ -"Irreversible message deletion is prohibited in this chat." = "Необратимое удаление сообщений запрещено в этом чате."; +"Irreversible message deletion is prohibited in this chat." = "Необратимое удаление сообщений запрещено."; /* No comment provided by engineer. */ -"Irreversible message deletion is prohibited." = "Необратимое удаление сообщений запрещено в этой группе."; +"Irreversible message deletion is prohibited." = "Необратимое удаление сообщений запрещено."; /* No comment provided by engineer. */ "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя."; @@ -3096,7 +3405,7 @@ snd error text */ "It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Это может произойти, когда:\n1. Клиент отправителя удалил неотправленные сообщения через 2 дня, или сервер – через 30 дней.\n2. Расшифровка сообщения была невозможна, когда Вы или Ваш контакт использовали старую копию базы данных.\n3. Соединение компроментировано."; /* No comment provided by engineer. */ -"It protects your IP address and connections." = "Защищает ваш IP адрес и соединения."; +"It protects your IP address and connections." = "Защищает ваш IP-адрес и соединения."; /* No comment provided by engineer. */ "It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Возможно, Вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@)."; @@ -3114,7 +3423,10 @@ snd error text */ "Join" = "Вступить"; /* No comment provided by engineer. */ -"Join as %@" = "вступить как %@"; +"Join as %@" = "Вступить как %s"; + +/* No comment provided by engineer. */ +"Join channel" = "Вступить в канал"; /* new chat sheet title */ "Join group" = "Вступить в группу"; @@ -3164,6 +3476,12 @@ snd error text */ /* swipe action */ "Leave" = "Выйти"; +/* No comment provided by engineer. */ +"Leave channel" = "Покинуть канал"; + +/* No comment provided by engineer. */ +"Leave channel?" = "Выйти из канала?"; + /* No comment provided by engineer. */ "Leave chat" = "Покинуть разговор"; @@ -3182,6 +3500,9 @@ snd error text */ /* No comment provided by engineer. */ "Less traffic on mobile networks." = "Меньше трафик в мобильных сетях."; +/* No comment provided by engineer. */ +"Let someone connect to you" = "Дайте собеседнику Вашу ссылку"; + /* email subject */ "Let's talk in SimpleX Chat" = "Давайте поговорим в SimpleX Chat"; @@ -3191,15 +3512,24 @@ snd error text */ /* No comment provided by engineer. */ "Limitations" = "Ограничения"; +/* No comment provided by engineer. */ +"link" = "ссылка"; + /* No comment provided by engineer. */ "Link mobile and desktop apps! 🔗" = "Свяжите мобильное и настольное приложения! 🔗"; +/* owner verification */ +"Link signature verified." = "Подпись ссылки проверена."; + /* No comment provided by engineer. */ "Linked desktop options" = "Опции связанных компьютеров"; /* No comment provided by engineer. */ "Linked desktops" = "Связанные компьютеры"; +/* No comment provided by engineer. */ +"Links" = "Ссылки"; + /* swipe action */ "List" = "Список"; @@ -3240,10 +3570,10 @@ snd error text */ "Make profile private!" = "Сделайте профиль скрытым!"; /* No comment provided by engineer. */ -"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Пожалуйста, проверьте, что адреса WebRTC ICE серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется."; +"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Пожалуйста, проверьте, что адреса WebRTC ICE-серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется."; /* No comment provided by engineer. */ -"Mark deleted for everyone" = "Пометить как удаленное для всех"; +"Mark deleted for everyone" = "Пометить как удалённое для всех"; /* No comment provided by engineer. */ "Mark read" = "Прочитано"; @@ -3285,14 +3615,17 @@ snd error text */ "member connected" = "соединен(а)"; /* No comment provided by engineer. */ -"member has old version" = "член имеет старую версию"; +"member has old version" = "член группы имеет старую версию"; /* item status text */ -"Member inactive" = "Член неактивен"; +"Member inactive" = "Член группы неактивен"; /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Член группы удалён - невозможно принять запрос"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Сообщения члена группы будут удалены - это нельзя отменить!"; + /* chat feature */ "Member reports" = "Сообщения о нарушениях"; @@ -3305,18 +3638,21 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль члена будет изменена на \"%@\". Будет отправлено новое приглашение."; -/* No comment provided by engineer. */ -"Member will be removed from chat - this cannot be undone!" = "Член будет удален из разговора - это действие нельзя отменить!"; - -/* No comment provided by engineer. */ -"Member will be removed from group - this cannot be undone!" = "Член группы будет удален - это действие нельзя отменить!"; +/* alert message */ +"Member will be removed from chat - this cannot be undone!" = "Член будет удалён из разговора - это действие нельзя отменить!"; /* alert message */ -"Member will join the group, accept member?" = "Участник хочет присоединиться к группе. Принять?"; +"Member will be removed from group - this cannot be undone!" = "Член группы будет удалён - это действие нельзя отменить!"; + +/* alert message */ +"Member will join the group, accept member?" = "Член группы хочет присоединиться. Принять?"; /* No comment provided by engineer. */ "Members can add message reactions." = "Члены могут добавлять реакции на сообщения."; +/* No comment provided by engineer. */ +"Members can chat with admins." = "Члены группы могут общаться с админами."; + /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Члены могут необратимо удалять отправленные сообщения. (24 часа)"; @@ -3324,7 +3660,7 @@ snd error text */ "Members can report messsages to moderators." = "Члены группы могут пожаловаться модераторам."; /* No comment provided by engineer. */ -"Members can send direct messages." = "Члены могут посылать прямые сообщения."; +"Members can send direct messages." = "Члены могут посылать личные сообщения."; /* No comment provided by engineer. */ "Members can send disappearing messages." = "Члены могут посылать исчезающие сообщения."; @@ -3333,13 +3669,13 @@ snd error text */ "Members can send files and media." = "Члены могут слать файлы и медиа."; /* No comment provided by engineer. */ -"Members can send SimpleX links." = "Члены могут отправлять ссылки SimpleX."; +"Members can send SimpleX links." = "Члены группы могут отправлять ссылки SimpleX."; /* No comment provided by engineer. */ -"Members can send voice messages." = "Члены могут отправлять голосовые сообщения."; +"Members can send voice messages." = "Члены группы могут отправлять голосовые сообщения."; /* No comment provided by engineer. */ -"Mention members 👋" = "Упоминайте участников 👋"; +"Mention members 👋" = "Упоминайте членов группы 👋"; /* No comment provided by engineer. */ "Menus" = "Меню"; @@ -3351,7 +3687,7 @@ snd error text */ "Message delivery error" = "Ошибка доставки сообщения"; /* No comment provided by engineer. */ -"Message delivery receipts!" = "Отчеты о доставке сообщений!"; +"Message delivery receipts!" = "Отчёты о доставке сообщений!"; /* item status text */ "Message delivery warning" = "Предупреждение доставки сообщения"; @@ -3359,6 +3695,9 @@ snd error text */ /* No comment provided by engineer. */ "Message draft" = "Черновик сообщения"; +/* No comment provided by engineer. */ +"Message error" = "Ошибка сообщения"; + /* item status text */ "Message forwarded" = "Сообщение переслано"; @@ -3378,13 +3717,13 @@ snd error text */ "Message reactions are prohibited in this chat." = "Реакции на сообщения в этом чате запрещены."; /* No comment provided by engineer. */ -"Message reactions are prohibited." = "Реакции на сообщения запрещены в этой группе."; +"Message reactions are prohibited." = "Реакции на сообщения запрещены."; /* notification */ "message received" = "получено сообщение"; /* No comment provided by engineer. */ -"Message reception" = "Прием сообщений"; +"Message reception" = "Приём сообщений"; /* No comment provided by engineer. */ "Message servers" = "Серверы сообщений"; @@ -3414,11 +3753,17 @@ snd error text */ "Messages & files" = "Сообщения"; /* No comment provided by engineer. */ -"Messages are protected by **end-to-end encryption**." = "Сообщения защищены **end-to-end шифрованием**."; +"Messages are protected by **end-to-end encryption**." = "Сообщения защищены **сквозным шифрованием**."; /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Сообщения от %@ будут показаны!"; +/* No comment provided by engineer. */ +"Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages." = "Сообщения в этом канале **не защищены сквозным шифрованием**. Чат-релеи могут видеть эти сообщения."; + +/* E2EE info chat item */ +"Messages in this channel are not end-to-end encrypted. Chat relays can see these messages." = "Сообщения в этом канале не защищены сквозным шифрованием. Чат-релеи могут видеть эти сообщения."; + /* alert message */ "Messages in this chat will never be deleted." = "Сообщения в этом чате никогда не будут удалены."; @@ -3432,17 +3777,17 @@ snd error text */ "Messages were deleted after you selected them." = "Сообщения были удалены после того, как вы их выбрали."; /* No comment provided by engineer. */ -"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Сообщения, файлы и звонки защищены **end-to-end шифрованием** с прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома."; +"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Сообщения, файлы и звонки защищены **сквозным шифрованием** с прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома."; /* No comment provided by engineer. */ -"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Сообщения, файлы и звонки защищены **квантово-устойчивым end-to-end шифрованием** с прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома."; +"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Сообщения, файлы и звонки защищены **квантово-устойчивым сквозным шифрованием** с идеальной прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома."; + +/* No comment provided by engineer. */ +"Migrate" = "Мигрировать"; /* No comment provided by engineer. */ "Migrate device" = "Мигрировать устройство"; -/* No comment provided by engineer. */ -"Migrate from another device" = "Миграция с другого устройства"; - /* No comment provided by engineer. */ "Migrate here" = "Мигрировать сюда"; @@ -3450,7 +3795,7 @@ snd error text */ "Migrate to another device" = "Мигрировать на другое устройство"; /* No comment provided by engineer. */ -"Migrate to another device via QR code." = "Мигрируйте на другое устройство через QR код."; +"Migrate to another device via QR code." = "Мигрируйте на другое устройство через QR-код."; /* No comment provided by engineer. */ "Migrating" = "Миграция"; @@ -3507,10 +3852,10 @@ snd error text */ "More improvements are coming soon!" = "Дополнительные улучшения скоро!"; /* No comment provided by engineer. */ -"More reliable network connection." = "Более надежное соединение с сетью."; +"More reliable network connection." = "Более надёжное соединение с сетью."; /* No comment provided by engineer. */ -"More reliable notifications" = "Более надежные уведомления"; +"More reliable notifications" = "Более надёжные уведомления"; /* item status description */ "Most likely this connection is deleted." = "Скорее всего, соединение удалено."; @@ -3531,7 +3876,10 @@ snd error text */ "Name" = "Имя"; /* No comment provided by engineer. */ -"Network & servers" = "Сеть & серверы"; +"Network & servers" = "Сеть и серверы"; + +/* No comment provided by engineer. */ +"Network commitments" = "Обязательства сети"; /* No comment provided by engineer. */ "Network connection" = "Интернет-соединение"; @@ -3539,6 +3887,9 @@ snd error text */ /* No comment provided by engineer. */ "Network decentralization" = "Децентрализация сети"; +/* conn error description */ +"Network error" = "Ошибка сети"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Ошибка сети - сообщение не было отправлено после многократных попыток."; @@ -3549,23 +3900,35 @@ snd error text */ "Network operator" = "Оператор сети"; /* No comment provided by engineer. */ -"Network settings" = "Настройки сети"; +"Network routers cannot know\nwho talks to whom" = "Серверы сети не могут знать,\nкто с кем общается"; /* No comment provided by engineer. */ +"Network settings" = "Настройки сети"; + +/* alert title */ "Network status" = "Состояние сети"; /* delete after time */ "never" = "никогда"; +/* No comment provided by engineer. */ +"new" = "новый"; + /* token status text */ "New" = "Новый"; +/* No comment provided by engineer. */ +"New 1-time link" = "Новая одноразовая ссылка"; + /* No comment provided by engineer. */ "New chat" = "Новый чат"; /* No comment provided by engineer. */ "New chat experience 🎉" = "Новый интерфейс 🎉"; +/* No comment provided by engineer. */ +"New chat relay" = "Новый чат-релей"; + /* notification */ "New contact request" = "Новый запрос на соединение"; @@ -3594,7 +3957,7 @@ snd error text */ "New member role" = "Роль члена группы"; /* rcv group event chat item */ -"New member wants to join the group." = "Новый участник хочет присоединиться к группе."; +"New member wants to join the group." = "Новый член группы хочет присоединиться."; /* notification */ "new message" = "новое сообщение"; @@ -3612,10 +3975,10 @@ snd error text */ "New server" = "Новый сервер"; /* No comment provided by engineer. */ -"New SOCKS credentials will be used every time you start the app." = "Новые учетные данные SOCKS будут использоваться при каждом запуске приложения."; +"New SOCKS credentials will be used every time you start the app." = "Новые учётные данные SOCKS будут использоваться при каждом запуске приложения."; /* No comment provided by engineer. */ -"New SOCKS credentials will be used for each server." = "Новые учетные данные SOCKS будут использоваться для каждого сервера."; +"New SOCKS credentials will be used for each server." = "Новые учётные данные SOCKS будут использоваться для каждого сервера."; /* pref value */ "no" = "нет"; @@ -3623,9 +3986,21 @@ snd error text */ /* No comment provided by engineer. */ "No" = "Нет"; +/* No comment provided by engineer. */ +"No account. No phone. No email. No ID.\nThe most secure encryption." = "Без аккаунта. Без номера. Без email. Без ID.\nСамое безопасное шифрование."; + +/* No comment provided by engineer. */ +"No active relays" = "Нет активных релеев"; + /* Authentication unavailable */ "No app password" = "Нет кода доступа"; +/* No comment provided by engineer. */ +"No chat relays" = "Нет чат-релеев"; + +/* servers warning */ +"No chat relays enabled." = "Чат-релеи не включены."; + /* No comment provided by engineer. */ "No chats" = "Нет чатов"; @@ -3702,14 +4077,17 @@ snd error text */ "No servers for private message routing." = "Нет серверов для доставки сообщений."; /* servers error */ -"No servers to receive files." = "Нет серверов для приема файлов."; +"No servers to receive files." = "Нет серверов для приёма файлов."; /* servers error */ -"No servers to receive messages." = "Нет серверов для приема сообщений."; +"No servers to receive messages." = "Нет серверов для приёма сообщений."; /* servers error */ "No servers to send files." = "Нет серверов для отправки файлов."; +/* No comment provided by engineer. */ +"no subscription" = "нет подписки"; + /* copied message info in history */ "no text" = "нет текста"; @@ -3720,7 +4098,16 @@ snd error text */ "No unread chats" = "Нет непрочитанных чатов"; /* No comment provided by engineer. */ -"No user identifiers." = "Без идентификаторов пользователей."; +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Никто не отслеживал ваши разговоры. Никто не составлял карту ваших перемещений. Конфиденциальность не была функцией - это был образ жизни."; + +/* No comment provided by engineer. */ +"Non-profit governance" = "Некоммерческое управление"; + +/* No comment provided by engineer. */ +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Не более надёжный замок на чужой двери. Не более вежливый хозяин, который уважает вашу частную жизнь, но всё равно ведёт учёт всех посетителей. Вы не гость. Вы у себя дома. Ни один король не войдёт в ваш дом - вы суверенны."; + +/* alert title */ +"Not all relays connected" = "Не все релеи подключены"; /* No comment provided by engineer. */ "Not compatible!" = "Несовместимая версия!"; @@ -3765,7 +4152,7 @@ time to disappear */ "off" = "нет"; /* blur media */ -"Off" = "Выключено"; +"Off" = "Нет"; /* feature offered item */ "offered %@" = "предложил(a) %@"; @@ -3778,7 +4165,7 @@ alert button new chat action */ "Ok" = "Ок"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "OK"; /* No comment provided by engineer. */ @@ -3787,9 +4174,15 @@ new chat action */ /* group pref value */ "on" = "да"; +/* No comment provided by engineer. */ +"On your phone, not on servers." = "На Вашем телефоне, не на серверах."; + /* No comment provided by engineer. */ "One-time invitation link" = "Одноразовая ссылка"; +/* chat link info line */ +"One-time link" = "Одноразовая ссылка"; + /* No comment provided by engineer. */ "Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Подключаться только к **onion** хостам.\nТребуется совместимый VPN."; @@ -3799,6 +4192,9 @@ new chat action */ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Onion хосты не используются."; +/* No comment provided by engineer. */ +"Only channel owners can change channel preferences." = "Изменить настройки канала могут только владельцы канала."; + /* No comment provided by engineer. */ "Only chat owners can change preferences." = "Только владельцы разговора могут поменять предпочтения."; @@ -3859,12 +4255,16 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Только Ваш контакт может отправлять голосовые сообщения."; -/* alert action */ +/* alert action +alert button */ "Open" = "Открыть"; /* No comment provided by engineer. */ "Open changes" = "Открыть изменения"; +/* new chat action */ +"Open channel" = "Открыть канал"; + /* new chat action */ "Open chat" = "Открыть чат"; @@ -3877,6 +4277,9 @@ new chat action */ /* No comment provided by engineer. */ "Open conditions" = "Открыть условия"; +/* alert title */ +"Open external link?" = "Открыть внешнюю ссылку?"; + /* alert action */ "Open full link" = "Открыть полную ссылку"; @@ -3889,6 +4292,9 @@ new chat action */ /* authentication reason */ "Open migration to another device" = "Открытие миграции на другое устройство"; +/* new chat action */ +"Open new channel" = "Открыть новый канал"; + /* new chat action */ "Open new chat" = "Открыть новый чат"; @@ -3919,6 +4325,9 @@ new chat action */ /* alert title */ "Operator server" = "Сервер оператора"; +/* No comment provided by engineer. */ +"Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code" = "Операторы обязуются:\n- Быть независимыми\n- Минимизировать использование метаданных\n- Использовать проверенный и открытый исходный код"; + /* No comment provided by engineer. */ "Or import archive file" = "Или импортировать файл архива"; @@ -3926,17 +4335,23 @@ new chat action */ "Or paste archive link" = "Или вставьте ссылку архива"; /* No comment provided by engineer. */ -"Or scan QR code" = "Или отсканируйте QR код"; +"Or scan QR code" = "Или отсканируйте QR-код"; /* No comment provided by engineer. */ "Or securely share this file link" = "Или передайте эту ссылку"; +/* No comment provided by engineer. */ +"Or show QR in person or via video call." = "Или покажите QR лично или через видеозвонок."; + /* No comment provided by engineer. */ "Or show this code" = "Или покажите этот код"; /* No comment provided by engineer. */ "Or to share privately" = "Или поделиться конфиденциально"; +/* No comment provided by engineer. */ +"Or use this QR - print or show online." = "Или используйте этот QR - распечатайте или покажите онлайн."; + /* No comment provided by engineer. */ "Organize chats into lists" = "Организуйте чаты в списки"; @@ -3955,9 +4370,18 @@ new chat action */ /* member role */ "owner" = "владелец"; +/* No comment provided by engineer. */ +"Owner" = "Владелец"; + /* feature role */ "owners" = "владельцы"; +/* No comment provided by engineer. */ +"Owners" = "Владельцы"; + +/* No comment provided by engineer. */ +"Ownership: you can run your own relays." = "Владение: Вы можете запустить свои собственные релеи."; + /* No comment provided by engineer. */ "Passcode" = "Код доступа"; @@ -3985,6 +4409,9 @@ new chat action */ /* No comment provided by engineer. */ "Paste image" = "Вставить изображение"; +/* No comment provided by engineer. */ +"Paste link / Scan" = "Вставить ссылку / Сканировать"; + /* No comment provided by engineer. */ "Paste link to connect!" = "Вставьте ссылку, чтобы соединиться!"; @@ -4034,10 +4461,10 @@ new chat action */ "Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers." = "Пожалуйста, проверьте, что мобильный и компьютер находятся в одной и той же локальной сети, и что брандмауэр компьютера разрешает подключение.\nПожалуйста, поделитесь любыми другими ошибками с разработчиками."; /* No comment provided by engineer. */ -"Please check that you used the correct link or ask your contact to send you another one." = "Пожалуйста, проверьте, что Вы использовали правильную ссылку или попросите, чтобы Ваш контакт отправил Вам другую ссылку."; +"Please check that you used the correct link or ask your contact to send you another one." = "Пожалуйста, проверьте, что Вы использовали правильную ссылку, или попросите Ваш контакт отправить Вам новую."; /* alert message */ -"Please check your network connection with %@ and try again." = "Пожалуйста, проверьте Ваше соединение с %@ и попробуйте еще раз."; +"Please check your network connection with %@ and try again." = "Пожалуйста, проверьте Ваше соединение с %@ и попробуйте ещё раз."; /* No comment provided by engineer. */ "Please check yours and your contact preferences." = "Проверьте предпочтения Вашего контакта."; @@ -4061,16 +4488,16 @@ new chat action */ "Please remember or store it securely - there is no way to recover a lost passcode!" = "Пожалуйста, запомните или сохраните его - восстановить потерянный пароль невозможно!"; /* No comment provided by engineer. */ -"Please report it to the developers." = "Пожалуйста, сообщите об этой ошибке девелоперам."; +"Please report it to the developers." = "Пожалуйста, сообщите об этой ошибке разработчикам."; /* No comment provided by engineer. */ "Please restart the app and migrate the database to enable push notifications." = "Пожалуйста, перезапустите приложение и переместите данные чата, чтобы включить доставку уведомлений."; /* No comment provided by engineer. */ -"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Пожалуйста, надежно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его."; +"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его."; /* No comment provided by engineer. */ -"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете."; +"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете его поменять, если потеряете."; /* token info */ "Please try to disable and re-enable notfications." = "Попробуйте выключить и снова включить уведомления."; @@ -4093,6 +4520,12 @@ new chat action */ /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Сохранить последний черновик, вместе с вложениями."; +/* No comment provided by engineer. */ +"Preset relay address" = "Адрес релея по умолчанию"; + +/* No comment provided by engineer. */ +"Preset relay name" = "Имя релея по умолчанию"; + /* No comment provided by engineer. */ "Preset server address" = "Адрес сервера по умолчанию"; @@ -4115,13 +4548,13 @@ new chat action */ "Privacy policy and conditions of use." = "Политика конфиденциальности и условия использования."; /* No comment provided by engineer. */ -"Privacy redefined" = "Более конфиденциальный"; +"Privacy: for owners and subscribers." = "Конфиденциальность: для владельцев и подписчиков."; /* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "Частные разговоры, группы и Ваши контакты недоступны для операторов серверов."; +"Private and secure messaging." = "Конфиденциальный и безопасный обмен сообщениями."; /* No comment provided by engineer. */ -"Private filenames" = "Защищенные имена файлов"; +"Private filenames" = "Защищённые имена файлов"; /* No comment provided by engineer. */ "Private media file names." = "Конфиденциальные названия медиафайлов."; @@ -4144,6 +4577,9 @@ new chat action */ /* alert title */ "Private routing timeout" = "Таймаут конфиденциальной доставки"; +/* alert action */ +"Proceed" = "Продолжить"; + /* No comment provided by engineer. */ "Profile and server connections" = "Профиль и соединения на сервере"; @@ -4160,11 +4596,14 @@ new chat action */ "Profile theme" = "Тема профиля"; /* alert message */ -"Profile update will be sent to your contacts." = "Обновлённый профиль будет отправлен Вашим контактам."; +"Profile update will be sent to your SimpleX contacts." = "Обновление профиля будет отправлено Вашим SimpleX контактам."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Запретить аудио/видео звонки."; +/* No comment provided by engineer. */ +"Prohibit chats with admins." = "Запретить чаты с админами."; + /* No comment provided by engineer. */ "Prohibit irreversible message deletion." = "Запретить необратимое удаление сообщений."; @@ -4178,31 +4617,34 @@ new chat action */ "Prohibit reporting messages to moderators." = "Запретить жаловаться модераторам группы."; /* No comment provided by engineer. */ -"Prohibit sending direct messages to members." = "Запретить посылать прямые сообщения членам группы."; +"Prohibit sending direct messages to members." = "Запретить посылать личные сообщения членам группы."; /* No comment provided by engineer. */ -"Prohibit sending disappearing messages." = "Запретить посылать исчезающие сообщения."; +"Prohibit sending direct messages to subscribers." = "Запретить отправку личных сообщений подписчикам."; /* No comment provided by engineer. */ -"Prohibit sending files and media." = "Запретить слать файлы и медиа."; +"Prohibit sending disappearing messages." = "Запретить отправлять исчезающие сообщения."; + +/* No comment provided by engineer. */ +"Prohibit sending files and media." = "Запретить отправлять файлы и медиа."; /* No comment provided by engineer. */ "Prohibit sending SimpleX links." = "Запретить отправку ссылок SimpleX."; /* No comment provided by engineer. */ -"Prohibit sending voice messages." = "Запретить отправлять голосовые сообщений."; +"Prohibit sending voice messages." = "Запретить отправлять голосовые сообщения."; /* No comment provided by engineer. */ "Protect app screen" = "Защитить экран приложения"; /* No comment provided by engineer. */ -"Protect IP address" = "Защитить IP адрес"; +"Protect IP address" = "Защитить IP-адрес"; /* No comment provided by engineer. */ "Protect your chat profiles with a password!" = "Защитите Ваши профили чата паролем!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Защитите ваш IP адрес от серверов сообщений, выбранных Вашими контактами.\nВключите в настройках *Сети и серверов*."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Защитите ваш IP-адрес от серверов сообщений, выбранных Вашими контактами.\nВключите в настройках *Сети и серверов*."; /* No comment provided by engineer. */ "Protocol background timeout" = "Фоновый таймаут протокола"; @@ -4222,6 +4664,9 @@ new chat action */ /* No comment provided by engineer. */ "Proxy requires password" = "Прокси требует пароль"; +/* No comment provided by engineer. */ +"Public channels - speak freely 🚀" = "Публичные каналы - говорите свободно 🚀"; + /* No comment provided by engineer. */ "Push notifications" = "Доставка уведомлений"; @@ -4250,22 +4695,16 @@ new chat action */ "Read more" = "Узнать больше"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Дополнительная информация в [Руководстве пользователя](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Узнайте больше из нашего GitHub репозитория."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Узнать больше в [Руководстве пользователя](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Узнать больше в [Руководстве пользователя](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Узнайте больше из нашего [GitHub репозитория](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Узнать больше в Руководстве пользователя."; /* No comment provided by engineer. */ "Receipts are disabled" = "Отчёты о доставке выключены"; /* No comment provided by engineer. */ -"Receive errors" = "Ошибки приема"; +"Receive errors" = "Ошибки приёма"; /* No comment provided by engineer. */ "received answer…" = "получен ответ…"; @@ -4279,9 +4718,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "получено подтверждение…"; -/* notification */ -"Received file event" = "Загрузка файла"; - /* message info title */ "Received message" = "Полученное сообщение"; @@ -4363,7 +4799,7 @@ swipe action */ "Reject contact request" = "Отклонить запрос"; /* alert title */ -"Reject member?" = "Отклонить участника?"; +"Reject member?" = "Отклонить члена группы?"; /* No comment provided by engineer. */ "rejected" = "отклонён"; @@ -4371,15 +4807,42 @@ swipe action */ /* call status */ "rejected call" = "отклонённый звонок"; -/* No comment provided by engineer. */ -"Relay server is only used if necessary. Another party can observe your IP address." = "Relay сервер используется только при необходимости. Другая сторона может видеть Ваш IP адрес."; +/* member role */ +"relay" = "релей"; /* No comment provided by engineer. */ -"Relay server protects your IP address, but it can observe the duration of the call." = "Relay сервер защищает Ваш IP адрес, но может отслеживать продолжительность звонка."; +"Relay" = "Релей"; + +/* alert title */ +"Relay address" = "Адрес релея"; + +/* alert title */ +"Relay connection failed" = "Ошибка подключения релея"; /* No comment provided by engineer. */ +"Relay link" = "Ссылка релея"; + +/* alert message */ +"Relay results:" = "Результаты релея:"; + +/* No comment provided by engineer. */ +"Relay server is only used if necessary. Another party can observe your IP address." = "Релей-сервер используется только при необходимости. Другая сторона может видеть Ваш IP-адрес."; + +/* No comment provided by engineer. */ +"Relay server protects your IP address, but it can observe the duration of the call." = "Релей-сервер защищает Ваш IP-адрес, но может отслеживать продолжительность звонка."; + +/* No comment provided by engineer. */ +"Relay test failed!" = "Тест релея не пройден!"; + +/* No comment provided by engineer. */ +"Reliability: many relays per channel." = "Надёжность: несколько релеев на каждый канал."; + +/* alert action */ "Remove" = "Удалить"; +/* alert action */ +"Remove and delete messages" = "Удалить вместе с сообщениями"; + /* No comment provided by engineer. */ "Remove archive?" = "Удалить архив?"; @@ -4392,23 +4855,35 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Удалить члена группы"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Удалить члена группы?"; /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Удалить пароль из Keychain?"; +/* No comment provided by engineer. */ +"Remove subscriber" = "Удалить подписчика"; + +/* alert title */ +"Remove subscriber?" = "Удалить подписчика?"; + /* No comment provided by engineer. */ "removed" = "удален(а)"; +/* receive error chat item */ +"removed (%d attempts)" = "удалено (%d попыток)"; + /* rcv group event chat item */ "removed %@" = "удалил(а) %@"; +/* No comment provided by engineer. */ +"removed by operator" = "удалено оператором"; + /* profile update event chat item */ "removed contact address" = "удалён адрес контакта"; /* No comment provided by engineer. */ -"removed from group" = "удален из группы"; +"removed from group" = "удалён из группы"; /* profile update event chat item */ "removed profile picture" = "удалена картинка профиля"; @@ -4516,10 +4991,10 @@ swipe action */ "Reset to user theme" = "Сбросить на тему пользователя"; /* No comment provided by engineer. */ -"Restart the app to create a new chat profile" = "Перезапустите приложение, чтобы создать новый профиль."; +"Restart the app to create a new chat profile" = "Перезапустите приложение, чтобы создать новый профиль"; /* No comment provided by engineer. */ -"Restart the app to use imported chat database" = "Перезапустите приложение, чтобы использовать импортированные данные чата."; +"Restart the app to use imported chat database" = "Перезапустите приложение, чтобы использовать импортированные данные чата"; /* No comment provided by engineer. */ "Restore" = "Восстановить"; @@ -4549,10 +5024,10 @@ swipe action */ "Review group members" = "Одобрять членов группы"; /* admission stage */ -"Review members" = "Одобрять членов"; +"Review members" = "Одобрять членов группы"; /* admission stage description */ -"Review members before admitting (\"knocking\")." = "Одобрять членов для вступления в группу."; +"Review members before admitting (\"knocking\")." = "Вручную одобрять членов для вступления в группу."; /* No comment provided by engineer. */ "reviewed by admins" = "одобрен админами"; @@ -4572,6 +5047,9 @@ swipe action */ /* No comment provided by engineer. */ "Run chat" = "Запустить chat"; +/* No comment provided by engineer. */ +"Safe web links" = "Безопасные веб-ссылки"; + /* No comment provided by engineer. */ "Safely receive files" = "Получайте файлы безопасно"; @@ -4588,6 +5066,9 @@ chat item action */ /* alert button */ "Save (and notify members)" = "Сохранить (и уведомить членов)"; +/* alert button */ +"Save (and notify subscribers)" = "Сохранить (и уведомить подписчиков)"; + /* alert title */ "Save admission settings?" = "Сохранить настройки вступления?"; @@ -4597,12 +5078,21 @@ chat item action */ /* No comment provided by engineer. */ "Save and notify group members" = "Сохранить и уведомить членов группы"; +/* No comment provided by engineer. */ +"Save and notify subscribers" = "Сохранить и уведомить подписчиков"; + /* No comment provided by engineer. */ "Save and reconnect" = "Сохранить и переподключиться"; /* No comment provided by engineer. */ "Save and update group profile" = "Сохранить сообщение и обновить группу"; +/* No comment provided by engineer. */ +"Save channel profile" = "Сохранить профиль канала"; + +/* alert title */ +"Save channel profile?" = "Сохранить профиль канала?"; + /* No comment provided by engineer. */ "Save group profile" = "Сохранить профиль группы"; @@ -4649,10 +5139,10 @@ chat item action */ "saved from %@" = "сохранено из %@"; /* message info title */ -"Saved message" = "Сохраненное сообщение"; +"Saved message" = "Сохранённое сообщение"; /* No comment provided by engineer. */ -"Saved WebRTC ICE servers will be removed" = "Сохраненные WebRTC ICE серверы будут удалены"; +"Saved WebRTC ICE servers will be removed" = "Сохранённые WebRTC ICE-серверы будут удалены"; /* No comment provided by engineer. */ "Saving %lld messages" = "Сохранение %lld сообщений"; @@ -4667,16 +5157,16 @@ chat item action */ "Scan code" = "Сканировать код"; /* No comment provided by engineer. */ -"Scan QR code" = "Сканировать QR код"; +"Scan QR code" = "Сканировать QR-код"; /* No comment provided by engineer. */ -"Scan QR code from desktop" = "Сканировать QR код с компьютера"; +"Scan QR code from desktop" = "Сканировать QR-код с компьютера"; /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Сканируйте код безопасности из приложения контакта."; /* No comment provided by engineer. */ -"Scan server QR code" = "Сканировать QR код сервера"; +"Scan server QR code" = "Сканировать QR-код сервера"; /* No comment provided by engineer. */ "search" = "поиск"; @@ -4688,7 +5178,22 @@ chat item action */ "Search bar accepts invitation links." = "Поле поиска поддерживает ссылки-приглашения."; /* No comment provided by engineer. */ -"Search or paste SimpleX link" = "Искать или вставьте ссылку SimpleX"; +"Search files" = "Поиск файлов"; + +/* No comment provided by engineer. */ +"Search images" = "Поиск изображений"; + +/* No comment provided by engineer. */ +"Search links" = "Поиск ссылок"; + +/* No comment provided by engineer. */ +"Search or paste SimpleX link" = "Искать или вставить ссылку SimpleX"; + +/* No comment provided by engineer. */ +"Search videos" = "Поиск видео"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Поиск голосовых сообщений"; /* network option */ "sec" = "сек"; @@ -4717,6 +5222,9 @@ chat item action */ /* chat item text */ "security code changed" = "код безопасности изменился"; +/* No comment provided by engineer. */ +"Security: owners hold channel keys." = "Безопасность: владельцы хранят ключи канала."; + /* chat item action */ "Select" = "Выбрать"; @@ -4745,7 +5253,7 @@ chat item action */ "Send" = "Отправить"; /* No comment provided by engineer. */ -"Send a live message - it will update for the recipient(s) as you type it" = "Отправить живое сообщение — оно будет обновляться для получателей по мере того, как Вы его вводите"; +"Send a live message - it will update for the recipient(s) as you type it" = "Отправить живое сообщение - оно будет обновляться для получателей по мере того, как Вы его вводите"; /* No comment provided by engineer. */ "Send contact request?" = "Отправить запрос на соединение?"; @@ -4754,7 +5262,7 @@ chat item action */ "Send delivery receipts to" = "Отправка отчётов о доставке"; /* No comment provided by engineer. */ -"Send direct message to connect" = "Отправьте сообщение чтобы соединиться"; +"Send direct message to connect" = "Отправить личное сообщение контакту"; /* No comment provided by engineer. */ "Send disappearing message" = "Отправить исчезающее сообщение"; @@ -4772,7 +5280,7 @@ chat item action */ "Send message to enable calls." = "Отправьте сообщение, чтобы включить звонки."; /* No comment provided by engineer. */ -"Send messages directly when IP address is protected and your or destination server does not support private routing." = "Отправлять сообщения напрямую, когда IP адрес защищен, и Ваш сервер или сервер получателя не поддерживает конфиденциальную доставку."; +"Send messages directly when IP address is protected and your or destination server does not support private routing." = "Отправлять сообщения напрямую, когда IP-адрес защищён, и Ваш сервер или сервер получателя не поддерживает конфиденциальную доставку."; /* No comment provided by engineer. */ "Send messages directly when your or destination server does not support private routing." = "Отправлять сообщения напрямую, когда Ваш сервер или сервер получателя не поддерживает конфиденциальную доставку."; @@ -4784,7 +5292,7 @@ chat item action */ "Send private reports" = "Вы можете сообщить о нарушениях"; /* No comment provided by engineer. */ -"Send questions and ideas" = "Отправьте вопросы и идеи"; +"Send questions and ideas" = "Вопросы и предложения"; /* No comment provided by engineer. */ "Send receipts" = "Отчёты о доставке"; @@ -4795,12 +5303,18 @@ chat item action */ /* No comment provided by engineer. */ "Send request without message" = "Отправить запрос без сообщения"; +/* No comment provided by engineer. */ +"Send the link via any messenger - it's secure. Ask to paste into SimpleX." = "Отправьте ссылку через любой мессенджер - это безопасно. Попросите вставить её в SimpleX."; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Отправьте из галереи или из дополнительных клавиатур."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Отправить до 100 последних сообщений новым членам."; +/* No comment provided by engineer. */ +"Send up to 100 last messages to new subscribers." = "Отправлять до 100 последних сообщений новым подписчикам."; + /* No comment provided by engineer. */ "Send your private feedback to groups." = "Отправляйте Ваши конфиденциальные предложения группе."; @@ -4810,6 +5324,9 @@ chat item action */ /* No comment provided by engineer. */ "Sender may have deleted the connection request." = "Отправитель мог удалить запрос на соединение."; +/* alert message */ +"Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later." = "Отправка картинки ссылки может раскрыть Ваш IP-адрес веб-сайту. Вы можете изменить это в настройках безопасности позже."; + /* No comment provided by engineer. */ "Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Отправка отчётов о доставке будет включена для всех контактов во всех видимых профилях чата."; @@ -4843,9 +5360,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Отправлено напрямую"; -/* notification */ -"Sent file event" = "Отправка файла"; - /* message info title */ "Sent message" = "Отправленное сообщение"; @@ -4880,7 +5394,7 @@ chat item action */ "Server address is incompatible with network settings." = "Адрес сервера несовместим с настройками сети."; /* alert title */ -"Server operator changed." = "Оператор серверов изменен."; +"Server operator changed." = "Оператор сервера изменен."; /* No comment provided by engineer. */ "Server operators" = "Операторы серверов"; @@ -4891,11 +5405,14 @@ chat item action */ /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "информация сервера об очереди: %1$@\n\nпоследнее полученное сообщение: %2$@"; -/* server test error */ -"Server requires authorization to create queues, check password." = "Сервер требует авторизации для создания очередей, проверьте пароль"; +/* relay test error */ +"Server requires authorization to connect to relay, check password." = "Для подключения к релею требуется авторизация, проверьте пароль."; /* server test error */ -"Server requires authorization to upload, check password." = "Сервер требует авторизации для загрузки, проверьте пароль"; +"Server requires authorization to create queues, check password." = "Сервер требует авторизации для создания очередей, проверьте пароль."; + +/* server test error */ +"Server requires authorization to upload, check password." = "Сервер требует авторизации для загрузки, проверьте пароль."; /* No comment provided by engineer. */ "Server test failed!" = "Ошибка теста сервера!"; @@ -4975,6 +5492,12 @@ chat item action */ /* alert message */ "Settings were changed." = "Настройки были изменены."; +/* No comment provided by engineer. */ +"Setup notifications" = "Настроить уведомления"; + +/* No comment provided by engineer. */ +"Setup routers" = "Настроить серверы"; + /* No comment provided by engineer. */ "Shape profile images" = "Форма картинок профилей"; @@ -4995,7 +5518,10 @@ chat item action */ "Share address publicly" = "Поделитесь адресом"; /* alert title */ -"Share address with contacts?" = "Поделиться адресом с контактами?"; +"Share address with SimpleX contacts?" = "Поделиться адресом с контактами SimpleX?"; + +/* No comment provided by engineer. */ +"Share channel" = "Поделиться каналом"; /* No comment provided by engineer. */ "Share from other apps." = "Поделитесь из других приложений."; @@ -5012,6 +5538,9 @@ chat item action */ /* No comment provided by engineer. */ "Share profile" = "Поделиться профилем"; +/* No comment provided by engineer. */ +"Share relay address" = "Поделиться адресом релея"; + /* No comment provided by engineer. */ "Share SimpleX address on social media." = "Поделитесь SimpleX адресом в социальных сетях."; @@ -5022,7 +5551,10 @@ chat item action */ "Share to SimpleX" = "Поделиться в SimpleX"; /* No comment provided by engineer. */ -"Share with contacts" = "Поделиться с контактами"; +"Share via chat" = "Поделиться в чате"; + +/* No comment provided by engineer. */ +"Share with SimpleX contacts" = "Поделиться с контактами SimpleX"; /* No comment provided by engineer. */ "Share your address" = "Поделитесь Вашим адресом"; @@ -5043,7 +5575,7 @@ chat item action */ "Show calls in phone history" = "Показать звонки в истории телефона"; /* No comment provided by engineer. */ -"Show developer options" = "Показать опции для девелоперов"; +"Show developer options" = "Показать опции для разработчиков"; /* No comment provided by engineer. */ "Show last messages" = "Показывать последние сообщения"; @@ -5058,7 +5590,7 @@ chat item action */ "Show preview" = "Показывать уведомления"; /* No comment provided by engineer. */ -"Show QR code" = "Показать QR код"; +"Show QR code" = "Показать QR-код"; /* No comment provided by engineer. */ "Show:" = "Показать:"; @@ -5079,7 +5611,7 @@ chat item action */ "SimpleX address or 1-time link?" = "Адрес SimpleX или одноразовая ссылка?"; /* alert title */ -"SimpleX address settings" = "Настройки автоприема"; +"SimpleX address settings" = "Настройки автоприёма"; /* simplex link type */ "SimpleX channel link" = "SimpleX ссылка канала"; @@ -5100,7 +5632,7 @@ chat item action */ "SimpleX group link" = "SimpleX ссылка группы"; /* chat feature */ -"SimpleX links" = "SimpleX ссылки"; +"SimpleX links" = "Ссылки SimpleX"; /* No comment provided by engineer. */ "SimpleX links are prohibited." = "Ссылки SimpleX запрещены в этой группе."; @@ -5127,10 +5659,10 @@ chat item action */ "SimpleX protocols reviewed by Trail of Bits." = "Аудит SimpleX протоколов от Trail of Bits."; /* simplex link type */ -"SimpleX relay link" = "Ссылка SimpleX relay"; +"SimpleX relay address" = "Адрес релея SimpleX"; /* No comment provided by engineer. */ -"Simplified incognito mode" = "Упрощенный режим Инкогнито"; +"Simplified incognito mode" = "Упрощённый режим Инкогнито"; /* No comment provided by engineer. */ "Size" = "Размер"; @@ -5145,10 +5677,10 @@ chat item action */ "Small groups (max 20)" = "Маленькие группы (до 20)"; /* No comment provided by engineer. */ -"SMP server" = "SMP сервер"; +"SMP server" = "SMP-сервер"; /* No comment provided by engineer. */ -"SOCKS proxy" = "SOCKS прокси"; +"SOCKS proxy" = "SOCKS-прокси"; /* blur media */ "Soft" = "Слабое"; @@ -5179,7 +5711,10 @@ report reason */ "Square, circle, or anything in between." = "Квадрат, круг и все, что между ними."; /* chat item text */ -"standard end-to-end encryption" = "стандартное end-to-end шифрование"; +"standard end-to-end encryption" = "стандартное сквозное шифрование"; + +/* No comment provided by engineer. */ +"Star on GitHub" = "Поставить звёздочку на GitHub"; /* No comment provided by engineer. */ "Start chat" = "Запустить чат"; @@ -5247,6 +5782,48 @@ report reason */ /* No comment provided by engineer. */ "Subscribed" = "Подписано"; +/* No comment provided by engineer. */ +"Subscriber" = "Подписчик"; + +/* chat feature */ +"Subscriber reports" = "Сообщения о нарушениях"; + +/* alert message */ +"Subscriber will be removed from channel - this cannot be undone!" = "Подписчик будет удалён из канала - это нельзя отменить!"; + +/* No comment provided by engineer. */ +"Subscribers" = "Подписчики"; + +/* No comment provided by engineer. */ +"Subscribers can add message reactions." = "Подписчики могут добавлять реакции на сообщения."; + +/* No comment provided by engineer. */ +"Subscribers can chat with admins." = "Подписчики могут общаться с админами."; + +/* No comment provided by engineer. */ +"Subscribers can irreversibly delete sent messages. (24 hours)" = "Подписчики могут необратимо удалять отправленные сообщения. (24 часа)"; + +/* No comment provided by engineer. */ +"Subscribers can report messsages to moderators." = "Подписчики могут отправлять сообщения о нарушениях модераторам."; + +/* No comment provided by engineer. */ +"Subscribers can send direct messages." = "Подписчики могут отправлять личные сообщения."; + +/* No comment provided by engineer. */ +"Subscribers can send disappearing messages." = "Подписчики могут отправлять исчезающие сообщения."; + +/* No comment provided by engineer. */ +"Subscribers can send files and media." = "Подписчики могут отправлять файлы и медиа."; + +/* No comment provided by engineer. */ +"Subscribers can send SimpleX links." = "Подписчики могут отправлять ссылки SimpleX."; + +/* No comment provided by engineer. */ +"Subscribers can send voice messages." = "Подписчики могут отправлять голосовые сообщения."; + +/* No comment provided by engineer. */ +"Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." = "Подписчики используют ссылку релея для подключения к каналу.\nАдрес релея был использован для настройки этого релея для канала."; + /* No comment provided by engineer. */ "Subscription errors" = "Ошибки подписки"; @@ -5274,6 +5851,9 @@ report reason */ /* No comment provided by engineer. */ "Take picture" = "Сделать фото"; +/* No comment provided by engineer. */ +"Talk to someone" = "Начните разговор"; + /* No comment provided by engineer. */ "Tap button " = "Нажмите кнопку "; @@ -5284,19 +5864,19 @@ report reason */ "Tap Connect to send request" = "Нажмите Соединиться, чтобы отправить запрос"; /* No comment provided by engineer. */ -"Tap Connect to use bot" = "Нажмите Соединиться, чтобы использовать бот."; +"Tap Connect to use bot" = "Нажмите Соединиться, чтобы использовать бот"; /* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Нажмите Создать адрес SimpleX в меню, чтобы создать его позже."; +"Tap Join channel" = "Нажмите Войти в канал"; /* No comment provided by engineer. */ "Tap Join group" = "Нажмите Вступить в группу"; /* No comment provided by engineer. */ -"Tap to activate profile." = "Нажмите, чтобы сделать профиль активным."; +"Tap to activate profile." = "Нажмите на профиль, чтобы переключиться."; /* No comment provided by engineer. */ -"Tap to Connect" = "Нажмите чтобы соединиться"; +"Tap to Connect" = "Нажмите, чтобы соединиться"; /* No comment provided by engineer. */ "Tap to join" = "Нажмите, чтобы вступить"; @@ -5304,6 +5884,9 @@ report reason */ /* No comment provided by engineer. */ "Tap to join incognito" = "Нажмите, чтобы вступить инкогнито"; +/* No comment provided by engineer. */ +"Tap to open" = "Нажмите, чтобы открыть"; + /* No comment provided by engineer. */ "Tap to paste link" = "Нажмите, чтобы вставить ссылку"; @@ -5317,7 +5900,7 @@ report reason */ "TCP connection bg timeout" = "Фоновый таймаут TCP-соединения"; /* No comment provided by engineer. */ -"TCP connection timeout" = "Таймаут TCP соединения"; +"TCP connection timeout" = "Таймаут TCP-соединения"; /* No comment provided by engineer. */ "TCP port for messaging" = "TCP-порт для отправки сообщений"; @@ -5334,12 +5917,16 @@ report reason */ /* file error alert title */ "Temporary file error" = "Временная ошибка файла"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Ошибка теста на шаге %@."; /* No comment provided by engineer. */ "Test notifications" = "Протестировать уведомления"; +/* No comment provided by engineer. */ +"Test relay" = "Тест релея"; + /* No comment provided by engineer. */ "Test server" = "Тестировать сервер"; @@ -5367,6 +5954,9 @@ report reason */ /* No comment provided by engineer. */ "The app protects your privacy by using different operators in each conversation." = "Приложение улучшает конфиденциальность используя разных операторов в каждом разговоре."; +/* No comment provided by engineer. */ +"The app removed this message after %lld attempts to receive it." = "Приложение удалило это сообщение после %lld попыток его получить."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "Приложение будет запрашивать подтверждение загрузки с неизвестных серверов (за исключением .onion адресов)."; @@ -5374,7 +5964,10 @@ report reason */ "The attempt to change database passphrase was not completed." = "Попытка поменять пароль базы данных не была завершена."; /* No comment provided by engineer. */ -"The code you scanned is not a SimpleX link QR code." = "Этот QR код не является SimpleX-ccылкой."; +"The code you scanned is not a SimpleX link QR code." = "Этот QR-код не является SimpleX-ccылкой."; + +/* conn error description */ +"The connection reached the limit of undelivered messages" = "Соединение достигло лимита недоставленных сообщений"; /* No comment provided by engineer. */ "The connection reached the limit of undelivered messages, your contact may be offline." = "Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети."; @@ -5392,7 +5985,7 @@ report reason */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения!"; /* No comment provided by engineer. */ -"The future of messaging" = "Будущее коммуникаций"; +"The first network where you own\nyour contacts and groups." = "Первая сеть, в которой Вы владеете\nсвоими контактами и группами."; /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хэш предыдущего сообщения отличается."; @@ -5407,19 +6000,22 @@ report reason */ "The message will be deleted for all members." = "Сообщение будет удалено для всех членов группы."; /* No comment provided by engineer. */ -"The message will be marked as moderated for all members." = "Сообщение будет помечено как удаленное для всех членов группы."; +"The message will be marked as moderated for all members." = "Сообщение будет помечено как удалённое для всех членов группы."; /* No comment provided by engineer. */ "The messages will be deleted for all members." = "Сообщения будут удалены для всех членов группы."; /* No comment provided by engineer. */ -"The messages will be marked as moderated for all members." = "Сообщения будут помечены как удаленные для всех членов группы."; +"The messages will be marked as moderated for all members." = "Сообщения будут помечены как удалённые для всех членов группы."; /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Предыдущая версия данных чата не удалена при перемещении, её можно удалить."; /* No comment provided by engineer. */ -"The same conditions will apply to operator **%@**." = "Те же самые условия будут приняты для оператора **%@**."; +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "Древнейшая человеческая свобода - говорить с другим человеком без слежки - построенная на инфраструктуре, которая не может её предать."; + +/* No comment provided by engineer. */ +"The same conditions will apply to operator **%@**." = "Те же условия будут действовать для оператора **%s**."; /* No comment provided by engineer. */ "The second preset operator in the app!" = "Второй оператор серверов в приложении!"; @@ -5440,11 +6036,17 @@ report reason */ "The text you pasted is not a SimpleX link." = "Вставленный текст не является SimpleX-ссылкой."; /* No comment provided by engineer. */ -"The uploaded database archive will be permanently removed from the servers." = "Загруженный архив базы данных будет навсегда удален с серверов."; +"The uploaded database archive will be permanently removed from the servers." = "Загруженный архив базы данных будет навсегда удалён с серверов."; /* No comment provided by engineer. */ "Themes" = "Темы"; +/* No comment provided by engineer. */ +"Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "Потом мы вышли в интернет, и каждая платформа попросила частичку вас - ваше имя, ваш номер, ваших друзей. Мы смирились с тем, что за возможность общаться приходится отдавать информацию о том, с кем мы общаемся. Каждое поколение людей и технологий жило так - телефон, электронная почта, мессенджеры, социальные сети. Казалось, что другого пути нет."; + +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "Другой путь есть. Сеть без номеров телефонов. Без имён пользователей. Без аккаунтов. Без каких-либо идентификаторов пользователей. Сеть, которая соединяет людей и передаёт зашифрованные сообщения, не зная, кто с кем связан."; + /* No comment provided by engineer. */ "These conditions will also apply for: **%@**." = "Эти условия также будут применены к: **%@**."; @@ -5452,25 +6054,25 @@ report reason */ "These settings are for your current profile **%@**." = "Установки для Вашего активного профиля **%@**."; /* No comment provided by engineer. */ -"They can be overridden in contact and group settings." = "Они могут быть переопределены в настройках контактов и групп."; +"They can be overridden in contact and group settings." = "Они могут быть изменены в настройках контактов и групп."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Это действие нельзя отменить — все полученные и отправленные файлы будут удалены. Изображения останутся в низком разрешении."; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Это действие нельзя отменить - все полученные и отправленные файлы будут удалены. Изображения останутся в низком разрешении."; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Это действие нельзя отменить - все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут."; /* alert message */ "This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Это действие нельзя отменить - сообщения в этом чате, отправленные или полученные раньше чем выбрано, будут удалены."; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Это действие нельзя отменить - Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны."; /* E2EE info chat item */ -"This chat is protected by end-to-end encryption." = "Чат защищен end-to-end шифрованием."; +"This chat is protected by end-to-end encryption." = "Чат защищён сквозным шифрованием."; /* E2EE info chat item */ -"This chat is protected by quantum resistant end-to-end encryption." = "Чат защищен квантово-устойчивым end-to-end шифрованием."; +"This chat is protected by quantum resistant end-to-end encryption." = "Чат защищён квантово-устойчивым сквозным шифрованием."; /* notification title */ "this contact" = "этот контакт"; @@ -5487,6 +6089,12 @@ report reason */ /* No comment provided by engineer. */ "This group no longer exists." = "Эта группа больше не существует."; +/* alert message */ +"This is a chat relay address, it cannot be used to connect." = "Это адрес чат-релея, с ним нельзя соединиться."; + +/* new chat action */ +"This is your link for channel %@!" = "Это ваша ссылка на канал %@!"; + /* No comment provided by engineer. */ "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку."; @@ -5494,7 +6102,7 @@ report reason */ "This link was used with another mobile device, please create a new link on the desktop." = "Эта ссылка была использована на другом мобильном, пожалуйста, создайте новую ссылку на компьютере."; /* No comment provided by engineer. */ -"This message was deleted or not received yet." = "Это сообщение было удалено или еще не получено."; +"This message was deleted or not received yet." = "Это сообщение было удалено или ещё не получено."; /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**."; @@ -5520,6 +6128,9 @@ report reason */ /* No comment provided by engineer. */ "To make a new connection" = "Чтобы соединиться"; +/* No comment provided by engineer. */ +"To make SimpleX Network last." = "Чтобы сохранить сеть SimpleX для всех."; + /* No comment provided by engineer. */ "To protect against your link being replaced, you can compare contact security codes." = "Чтобы защитить Вашу ссылку от замены, Вы можете сравнить код безопасности."; @@ -5530,10 +6141,10 @@ report reason */ "To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Чтобы защитить Вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки."; /* No comment provided by engineer. */ -"To protect your IP address, private routing uses your SMP servers to deliver messages." = "Чтобы защитить ваш IP адрес, приложение использует Ваши SMP серверы для конфиденциальной доставки сообщений."; +"To protect your IP address, private routing uses your SMP servers to deliver messages." = "Чтобы защитить Ваш IP-адрес, приложение использует Ваши SMP-серверы для конфиденциальной доставки сообщений."; /* No comment provided by engineer. */ -"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Чтобы защитить Вашу конфиденциальность, SimpleX использует разные идентификаторы для каждого Вашeго контакта."; +"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Чтобы защитить Вашу конфиденциальность, SimpleX использует разные ID для каждого Вашего контакта."; /* No comment provided by engineer. */ "To receive" = "Для получения"; @@ -5551,7 +6162,7 @@ report reason */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Чтобы показать Ваш скрытый профиль, введите его пароль в поле поиска на странице **Ваши профили чата**."; /* No comment provided by engineer. */ -"To send" = "Для оправки"; +"To send" = "Для отправки"; /* alert message */ "To send commands you must be connected." = "Вы должны быть соединены, чтобы отправлять команды."; @@ -5566,10 +6177,7 @@ report reason */ "To use the servers of **%@**, accept conditions of use." = "Чтобы использовать серверы оператора **%@**, примите условия использования."; /* No comment provided by engineer. */ -"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Чтобы подтвердить end-to-end шифрование с Вашим контактом сравните (или сканируйте) код безопасности на Ваших устройствах."; - -/* No comment provided by engineer. */ -"Toggle chat list:" = "Переключите список чатов:"; +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Чтобы подтвердить безопасность сквозного шифрования с Вашим контактом сравните (или сканируйте) код на ваших устройствах."; /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Установите режим Инкогнито при соединении."; @@ -5580,20 +6188,20 @@ report reason */ /* No comment provided by engineer. */ "Toolbar opacity" = "Прозрачность тулбара"; +/* No comment provided by engineer. */ +"Top bar" = "Верхнее меню"; + /* No comment provided by engineer. */ "Total" = "Всего"; /* No comment provided by engineer. */ -"Transport isolation" = "Отдельные сессии для"; +"Transport isolation" = "Отдельные транспортные сессии"; /* No comment provided by engineer. */ "Transport sessions" = "Транспортные сессии"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта."; /* No comment provided by engineer. */ "Turkish interface" = "Турецкий интерфейс"; @@ -5622,6 +6230,9 @@ report reason */ /* No comment provided by engineer. */ "Unblock member?" = "Разблокировать члена группы?"; +/* No comment provided by engineer. */ +"Unblock subscriber for all?" = "Разблокировать подписчика для всех?"; + /* rcv group event chat item */ "unblocked %@" = "%@ разблокирован"; @@ -5671,7 +6282,7 @@ report reason */ "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "Если Вы не используете интерфейс iOS, включите режим Не отвлекать, чтобы звонок не прерывался."; /* No comment provided by engineer. */ -"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." = "Возможно, Ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите Ваш контакт создать еще одну ссылку и проверьте Ваше соединение с сетью."; +"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." = "Возможно, Ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите Ваш контакт создать ещё одну ссылку и проверьте Ваше соединение с сетью."; /* No comment provided by engineer. */ "Unlink" = "Забыть"; @@ -5694,12 +6305,15 @@ report reason */ /* swipe action */ "Unread" = "Не прочитано"; -/* No comment provided by engineer. */ +/* conn error description */ "Unsupported connection link" = "Ссылка не поддерживается"; /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "До 100 последних сообщений отправляются новым членам."; +/* No comment provided by engineer. */ +"Up to 100 last messages are sent to new subscribers." = "До 100 последних сообщений отправляется новым подписчикам."; + /* No comment provided by engineer. */ "Update" = "Обновить"; @@ -5712,8 +6326,11 @@ report reason */ /* No comment provided by engineer. */ "Update settings?" = "Обновить настройки?"; +/* rcv group event chat item */ +"updated channel profile" = "обновлён профиль канала"; + /* No comment provided by engineer. */ -"Updated conditions" = "Обновленные условия"; +"Updated conditions" = "Обновлённые условия"; /* rcv group event chat item */ "updated group profile" = "обновил(а) профиль группы"; @@ -5722,7 +6339,7 @@ report reason */ "updated profile" = "профиль обновлён"; /* No comment provided by engineer. */ -"Updating settings will re-connect the client to all servers." = "Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами."; +"Updating settings will re-connect the client to all servers." = "Обновление настроек приведёт к сбросу и установке нового соединения со всеми серверами."; /* alert button */ "Upgrade" = "Обновить"; @@ -5769,9 +6386,6 @@ report reason */ /* No comment provided by engineer. */ "Use %@" = "Использовать %@"; -/* No comment provided by engineer. */ -"Use chat" = "Использовать чат"; - /* new chat action */ "Use current profile" = "Использовать активный профиль"; @@ -5781,6 +6395,9 @@ report reason */ /* No comment provided by engineer. */ "Use for messages" = "Использовать для сообщений"; +/* No comment provided by engineer. */ +"Use for new channels" = "Использовать для новых каналов"; + /* No comment provided by engineer. */ "Use for new connections" = "Использовать для новых соединений"; @@ -5794,17 +6411,20 @@ report reason */ "Use iOS call interface" = "Использовать интерфейс iOS для звонков"; /* new chat action */ -"Use new incognito profile" = "Использовать новый Инкогнито профиль"; +"Use new incognito profile" = "Использовать новый профиль инкогнито"; /* No comment provided by engineer. */ "Use only local notifications?" = "Использовать только локальные нотификации?"; /* No comment provided by engineer. */ -"Use private routing with unknown servers when IP address is not protected." = "Использовать конфиденциальную доставку с неизвестными серверами, когда IP адрес не защищен."; +"Use private routing with unknown servers when IP address is not protected." = "Использовать конфиденциальную доставку с неизвестными серверами, когда IP-адрес не защищён."; /* No comment provided by engineer. */ "Use private routing with unknown servers." = "Использовать конфиденциальную доставку с неизвестными серверами."; +/* No comment provided by engineer. */ +"Use relay" = "Использовать релей"; + /* No comment provided by engineer. */ "Use server" = "Использовать сервер"; @@ -5815,7 +6435,7 @@ report reason */ "Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; /* No comment provided by engineer. */ -"Use SOCKS proxy" = "Использовать SOCKS прокси"; +"Use SOCKS proxy" = "Использовать SOCKS-прокси"; /* No comment provided by engineer. */ "Use TCP port %@ when no port is specified." = "Использовать TCP-порт %@, когда порт не указан."; @@ -5829,6 +6449,9 @@ report reason */ /* No comment provided by engineer. */ "Use the app with one hand." = "Используйте приложение одной рукой."; +/* No comment provided by engineer. */ +"Use this address in your social media profile, website, or email signature." = "Используйте этот адрес в профиле социальных сетей, на сайте или в подписи email."; + /* No comment provided by engineer. */ "Use web port" = "Использовать веб-порт"; @@ -5847,6 +6470,9 @@ report reason */ /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* relay test step */ +"Verify" = "Проверить"; + /* No comment provided by engineer. */ "Verify code with desktop" = "Сверьте код с компьютером"; @@ -5868,6 +6494,9 @@ report reason */ /* No comment provided by engineer. */ "Verify security code" = "Подтвердить код безопасности"; +/* relay hostname */ +"via %@" = "через %@"; + /* No comment provided by engineer. */ "Via browser" = "В браузере"; @@ -5881,7 +6510,7 @@ report reason */ "via one-time link" = "через одноразовую ссылку"; /* No comment provided by engineer. */ -"via relay" = "через relay сервер"; +"via relay" = "через релей-сервер"; /* No comment provided by engineer. */ "Via secure quantum resistant protocol." = "Через безопасный квантово-устойчивый протокол."; @@ -5901,6 +6530,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже!"; +/* No comment provided by engineer. */ +"Videos" = "Видео"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Видео и файлы до 1гб"; @@ -5934,9 +6566,18 @@ report reason */ /* No comment provided by engineer. */ "Voice messages prohibited!" = "Голосовые сообщения запрещены!"; +/* alert action */ +"Wait" = "Подождать"; + +/* relay test step */ +"Wait response" = "Ожидание ответа"; + /* No comment provided by engineer. */ "waiting for answer…" = "ожидается ответ…"; +/* No comment provided by engineer. */ +"Waiting for channel owner to add relays." = "Ожидает, когда владелец канала добавит релеи."; + /* No comment provided by engineer. */ "waiting for confirmation…" = "ожидается подтверждение…"; @@ -5944,10 +6585,10 @@ report reason */ "Waiting for desktop..." = "Ожидается подключение компьютера..."; /* No comment provided by engineer. */ -"Waiting for file" = "Ожидается прием файла"; +"Waiting for file" = "Ожидается приём файла"; /* No comment provided by engineer. */ -"Waiting for image" = "Ожидается прием изображения"; +"Waiting for image" = "Ожидается приём изображения"; /* No comment provided by engineer. */ "Waiting for video" = "Ожидание видео"; @@ -5962,13 +6603,16 @@ report reason */ "wants to connect to you!" = "хочет соединиться с Вами!"; /* No comment provided by engineer. */ -"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "Внимание: запуск чата на нескольких устройствах не поддерживается и приведет к сбоям доставки сообщений"; +"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "Внимание: запуск чата на нескольких устройствах не поддерживается и приведёт к сбоям доставки сообщений"; /* No comment provided by engineer. */ -"Warning: you may lose some data!" = "Предупреждение: Вы можете потерять какие то данные!"; +"Warning: you may lose some data!" = "Предупреждение: Вы можете потерять некоторые данные!"; /* No comment provided by engineer. */ -"WebRTC ICE servers" = "WebRTC ICE серверы"; +"We made connecting simpler for new users." = "Мы упростили подключение для новых пользователей."; + +/* No comment provided by engineer. */ +"WebRTC ICE servers" = "WebRTC ICE-серверы"; /* time unit */ "weeks" = "недель"; @@ -5986,7 +6630,7 @@ report reason */ "Welcome your contacts 👋" = "Приветствуйте Ваши контакты 👋"; /* No comment provided by engineer. */ -"What's new" = "Новые функции"; +"What's new" = "Что нового"; /* No comment provided by engineer. */ "When available" = "Когда возможно"; @@ -6001,7 +6645,10 @@ report reason */ "When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Когда больше чем один оператор включен, ни один из них не видит метаданные, чтобы определить, кто соединен с кем."; /* No comment provided by engineer. */ -"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Когда Вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом."; +"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Когда Вы соединены с контактом инкогнито, тот же самый профиль инкогнито будет использоваться для групп с этим контактом."; + +/* No comment provided by engineer. */ +"Why SimpleX is built." = "Зачем создан SimpleX."; /* No comment provided by engineer. */ "WiFi" = "WiFi"; @@ -6022,10 +6669,10 @@ report reason */ "With reduced battery usage." = "С уменьшенным потреблением батареи."; /* No comment provided by engineer. */ -"Without Tor or VPN, your IP address will be visible to file servers." = "Без Тора или ВПН, Ваш IP адрес будет доступен серверам файлов."; +"Without Tor or VPN, your IP address will be visible to file servers." = "Без Tor или VPN, Ваш IP-адрес будет доступен серверам файлов."; /* alert message */ -"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Без Тора или ВПН, Ваш IP адрес будет доступен этим серверам файлов: %@."; +"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Без Тора или ВПН, Ваш IP-адрес будет доступен этим серверам файлов: %@."; /* No comment provided by engineer. */ "Wrong database passphrase" = "Неправильный пароль базы данных"; @@ -6034,13 +6681,13 @@ report reason */ "Wrong key or unknown connection - most likely this connection is deleted." = "Неверный ключ или неизвестное соединение - скорее всего, это соединение удалено."; /* file error text */ -"Wrong key or unknown file chunk address - most likely file is deleted." = "Неверный ключ или неизвестный адрес блока файла - скорее всего, файл удален."; +"Wrong key or unknown file chunk address - most likely file is deleted." = "Неверный ключ или неизвестный адрес блока файла - скорее всего, файл удалён."; /* No comment provided by engineer. */ "Wrong passphrase!" = "Неправильный пароль!"; /* No comment provided by engineer. */ -"XFTP server" = "XFTP сервер"; +"XFTP server" = "XFTP-сервер"; /* pref value */ "yes" = "да"; @@ -6055,7 +6702,7 @@ report reason */ "You accepted connection" = "Вы приняли приглашение соединиться"; /* snd group event chat item */ -"you accepted this member" = "Вы приняли этого члена"; +"you accepted this member" = "Вы приняли этого члена группы"; /* No comment provided by engineer. */ "You allow" = "Вы разрешаете"; @@ -6087,18 +6734,24 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вы уже вступаете в группу!\nПовторить запрос на вступление?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Вы подключены к серверу, используемому для приёма сообщений от этого соединения."; /* No comment provided by engineer. */ "You are invited to group" = "Вы приглашены в группу"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Вы не подключены к серверу, через который Вы получали сообщения от этого контакта (нет подписки)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Вы не подключены к этим серверам. Для доставки сообщений на них используется конфиденциальная доставка."; /* No comment provided by engineer. */ "you are observer" = "только чтение сообщений"; +/* No comment provided by engineer. */ +"you are subscriber" = "Вы подписчик"; + /* snd group event chat item */ "you blocked %@" = "Вы заблокировали %@"; @@ -6121,7 +6774,7 @@ report reason */ "You can enable them later via app Privacy & Security settings." = "Вы можете включить их позже в настройках Конфиденциальности."; /* No comment provided by engineer. */ -"You can give another try." = "Вы можете попробовать еще раз."; +"You can give another try." = "Вы можете попробовать ещё раз."; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "Вы можете скрыть профиль или выключить уведомления - потяните его вправо."; @@ -6142,13 +6795,16 @@ report reason */ "You can set lock screen notification preview via settings." = "Вы можете установить просмотр уведомлений на экране блокировки в настройках."; /* No comment provided by engineer. */ -"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Вы можете поделиться ссылкой или QR кодом - через них можно присоединиться к группе. Вы сможете удалить ссылку, сохранив членов группы, которые через нее соединились."; +"You can share a link or a QR code - anybody will be able to join the channel." = "Вы можете поделиться ссылкой или QR-кодом - любой сможет вступить в канал."; + +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Вы можете поделиться ссылкой или QR-кодом - любой сможет присоединиться к группе. Члены группы останутся, даже если вы позже удалите ссылку."; /* No comment provided by engineer. */ "You can share this address with your contacts to let them connect with **%@**." = "Вы можете поделиться этим адресом с Вашими контактами, чтобы они могли соединиться с **%@**."; /* No comment provided by engineer. */ -"You can start chat via app Settings / Database or by restarting the app" = "Вы можете запустить чат через Настройки приложения или перезапустив приложение."; +"You can start chat via app Settings / Database or by restarting the app" = "Вы можете запустить чат через Настройки приложения или перезапустив приложение"; /* No comment provided by engineer. */ "You can still view conversation with %@ in the list of chats." = "Вы по-прежнему можете просмотреть разговор с %@ в списке чатов."; @@ -6181,16 +6837,19 @@ report reason */ "you changed role of %@ to %@" = "Вы поменяли роль члена %1$@ на: %2$@"; /* No comment provided by engineer. */ -"You could not be verified; please try again." = "Верификация не удалась; пожалуйста, попробуйте ещё раз."; +"You commit to:\n- Only legal content in public groups\n- Respect other users - no spam" = "Вы обязуетесь:\n- Только законный контент в публичных группах\n- Уважать других пользователей - без спама"; /* No comment provided by engineer. */ -"You decide who can connect." = "Вы определяете, кто может соединиться."; +"You connected to the channel via this relay link." = "Вы подключились к каналу через эту ссылку релея."; + +/* No comment provided by engineer. */ +"You could not be verified; please try again." = "Ошибка аутентификации; попробуйте ещё раз."; /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Вы уже запросили соединение!\nПовторить запрос?"; /* No comment provided by engineer. */ -"You have to enter passphrase every time the app starts - it is not stored on the device." = "Пароль не сохранен на устройстве — Вы будете должны ввести его при каждом запуске чата."; +"You have to enter passphrase every time the app starts - it is not stored on the device." = "Пароль не сохранён на устройстве - Вы будете должны ввести его при каждом запуске чата."; /* No comment provided by engineer. */ "You invited a contact" = "Вы пригласили контакт"; @@ -6199,7 +6858,7 @@ report reason */ "You joined this group" = "Вы вступили в эту группу"; /* No comment provided by engineer. */ -"You joined this group. Connecting to inviting group member." = "Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы."; +"You joined this group. Connecting to inviting group member." = "Вы вступили в группу. Устанавливается соединение с пригласившим Вас членом группы."; /* snd group event chat item */ "you left" = "Вы покинули группу"; @@ -6211,7 +6870,7 @@ report reason */ "You may save the exported archive." = "Вы можете сохранить экспортированный архив."; /* No comment provided by engineer. */ -"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Вы должны всегда использовать самую новую версию данных чата, ТОЛЬКО на одном устройстве, иначе Вы можете перестать получать сообщения от каких то контактов."; +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе Вы можете перестать получать сообщения от некоторых контактов."; /* No comment provided by engineer. */ "You need to allow your contact to call to be able to call them." = "Чтобы включить звонки, разрешите их Вашему контакту."; @@ -6240,6 +6899,9 @@ report reason */ /* snd group event chat item */ "you unblocked %@" = "Вы разблокировали %@"; +/* No comment provided by engineer. */ +"You were born without an account" = "Вы родились без аккаунта"; + /* No comment provided by engineer. */ "You will be able to send messages **only after your request is accepted**." = "Вы сможете отправлять сообщения **только после того как Ваш запрос будет принят**."; @@ -6259,7 +6921,10 @@ report reason */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме."; /* No comment provided by engineer. */ -"You will still receive calls and notifications from muted profiles when they are active." = "Вы все равно получите звонки и уведомления в профилях без звука, когда они активные."; +"You will still receive calls and notifications from muted profiles when they are active." = "Вы всё равно получите звонки и уведомления в профилях без звука, когда они активные."; + +/* No comment provided by engineer. */ +"You will stop receiving messages from this channel. Chat history will be preserved." = "Вы перестанете получать сообщения из этого канала. История чата сохранится."; /* No comment provided by engineer. */ "You will stop receiving messages from this chat. Chat history will be preserved." = "Вы прекратите получать сообщения в этом разговоре. История будет сохранена."; @@ -6268,23 +6933,26 @@ report reason */ "You will stop receiving messages from this group. Chat history will be preserved." = "Вы перестанете получать сообщения от этой группы. История чата будет сохранена."; /* No comment provided by engineer. */ -"You won't lose your contacts if you later delete your address." = "Вы сможете удалить адрес, сохранив контакты, которые через него соединились."; +"You won't lose your contacts if you later delete your address." = "Вы не потеряете контакты, если позже удалите Ваш адрес."; /* No comment provided by engineer. */ "you: " = "Вы: "; /* No comment provided by engineer. */ -"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" = "Вы пытаетесь пригласить инкогнито контакт в группу, где Вы используете свой основной профиль"; +"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" = "Вы пытаетесь пригласить контакт, который знает Ваш профиль инкогнито, в группу, где Вы используете основной профиль"; /* No comment provided by engineer. */ -"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие Вашего основного профиля, приглашать контакты не разрешено"; +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Вы используете профиль инкогнито в этой группе. Для защиты Вашего основного профиля приглашать контакты запрещено"; /* No comment provided by engineer. */ -"Your business contact" = "Ваш бизнес контакт"; +"Your business contact" = "Ваш бизнес-контакт"; /* No comment provided by engineer. */ "Your calls" = "Ваши звонки"; +/* No comment provided by engineer. */ +"Your channel" = "Ваш канал"; + /* No comment provided by engineer. */ "Your chat database" = "База данных"; @@ -6316,7 +6984,10 @@ report reason */ "Your contacts will remain connected." = "Ваши контакты сохранятся."; /* No comment provided by engineer. */ -"Your credentials may be sent unencrypted." = "Ваши учетные данные могут быть отправлены в незашифрованном виде."; +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "Ваши разговоры принадлежат вам, как это всегда было до интернета. Сеть - это не место, куда вы приходите. Это место, которое вы создаёте и которым владеете. И никто не может это у вас отнять, делаете ли вы его конфиденциальным или публичным."; + +/* No comment provided by engineer. */ +"Your credentials may be sent unencrypted." = "Ваши учётные данные могут быть отправлены в незашифрованном виде."; /* No comment provided by engineer. */ "Your current chat database will be DELETED and REPLACED with the imported one." = "Текущие данные Вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными."; @@ -6328,7 +6999,10 @@ report reason */ "Your group" = "Ваша группа"; /* No comment provided by engineer. */ -"Your ICE servers" = "Ваши ICE серверы"; +"Your ICE servers" = "Ваши ICE-серверы"; + +/* No comment provided by engineer. */ +"Your network" = "Ваша сеть"; /* No comment provided by engineer. */ "Your preferences" = "Ваши предпочтения"; @@ -6339,6 +7013,9 @@ report reason */ /* No comment provided by engineer. */ "Your profile" = "Ваш профиль"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared with channel relays and subscribers.\nRelays can access channel messages." = "Ваш профиль **%@** будет отправлен чат-релеям и подписчикам канала.\nРелеи могут видеть сообщения канала."; + /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Будет отправлен Ваш профиль **%@**."; @@ -6346,14 +7023,23 @@ report reason */ "Your profile is stored on your device and only shared with your contacts." = "Ваш профиль хранится на Вашем устройстве и отправляется только контактам."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. Серверы SimpleX не могут получить доступ к Вашему профилю."; /* alert message */ -"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ваш профиль был изменен. Если вы сохраните его, обновленный профиль будет отправлен всем вашим контактам."; +"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ваш профиль был изменен. Если вы сохраните его, обновлённый профиль будет отправлен всем вашим контактам."; + +/* No comment provided by engineer. */ +"Your public address" = "Ваш публичный адрес"; /* No comment provided by engineer. */ "Your random profile" = "Случайный профиль"; +/* No comment provided by engineer. */ +"Your relay address" = "Ваш адрес релея"; + +/* No comment provided by engineer. */ +"Your relay name" = "Ваше имя релея"; + /* No comment provided by engineer. */ "Your server address" = "Адрес Вашего сервера"; diff --git a/apps/ios/spec/README.md b/apps/ios/spec/README.md new file mode 100644 index 0000000000..eca6103582 --- /dev/null +++ b/apps/ios/spec/README.md @@ -0,0 +1,74 @@ +# SimpleX Chat iOS -- Specification Overview + +> Technical specification suite for the SimpleX Chat iOS application. Each document provides bidirectional links to product documentation and source code. + +## Executive Summary + +The SimpleX Chat iOS app is a native SwiftUI frontend that communicates with a Haskell core library via C FFI. All chat logic, encryption, protocol handling, and database operations happen in the Haskell core (`chat_ctrl`). The iOS layer handles UI rendering, system integration (CallKit, Push Notifications, Background Tasks), local preferences, and theming. The app shares its database with a Notification Service Extension (NSE) for decrypting push payloads while the main app is inactive. + +## Dependency Graph + +``` +SimpleXApp (root entry point) +├── ChatModel (ObservableObject state) <-> SimpleXAPI (FFI bridge) <-> Haskell Core (chat_ctrl) +├── Views (SwiftUI) +│ ├── ChatListView -> ChatView -> ComposeView +│ ├── ChatItemView (renders individual messages) +│ ├── Settings, UserProfiles, Onboarding +│ └── ActiveCallView (WebRTC + CallKit) +├── Models +│ ├── ChatModel (global app state -- singleton) +│ ├── ItemsModel (per-chat message list state -- singleton + secondary instances) +│ ├── ChatTagsModel (tag filtering state) +│ └── Chat (per-conversation observable state) +├── Services +│ ├── NtfManager (push notification coordination) +│ ├── BGManager (background task scheduling) +│ ├── CallController (CallKit + VoIP push) +│ └── ThemeManager (theme resolution engine) +└── Extensions + ├── SimpleX NSE (Notification Service Extension -- decrypts push payloads) + └── SimpleX SE (Share Extension) +``` + +## Specification Documents + +| Document | Description | +|----------|-------------| +| [Architecture](architecture.md) | System architecture, FFI bridge, app lifecycle, extension model | +| [Chat API Reference](api.md) | Complete ChatCommand, ChatResponse, ChatEvent, ChatError type reference | +| [State Management](state.md) | ChatModel, ItemsModel, Chat, ChatInfo, preference storage | +| [Database & Storage](database.md) | SQLite databases, encryption, file storage, export/import | +| [Chat View](client/chat-view.md) | Message rendering, chat item types, context menu actions | +| [Chat List](client/chat-list.md) | Conversation list, filtering, search, swipe actions | +| [Message Composition](client/compose.md) | Compose bar, attachments, reply/edit/forward modes, voice recording | +| [Navigation](client/navigation.md) | Navigation stack, deep linking, sheet presentation, call overlay | +| [Push Notifications](services/notifications.md) | NtfManager, NSE, notification modes, token lifecycle | +| [WebRTC Calling](services/calls.md) | CallController, WebRTCClient, CallKit, signaling via SMP | +| [File Transfer](services/files.md) | Inline/XFTP transfer, auto-receive, CryptoFile, file constants | +| [Theme Engine](services/theme.md) | ThemeManager, default themes, customization layers, wallpapers | +| [Impact Graph](impact.md) | Source file → product concept mapping, risk levels | + +## Related Product Documentation + +- [Product Overview](../product/README.md) +- [Concept Index](../product/concepts.md) +- [Business Rules](../product/rules.md) +- [Known Gaps](../product/gaps.md) +- [Glossary](../product/glossary.md) +- [Chat List View](../product/views/chat-list.md) +- [Chat View](../product/views/chat.md) + +## Source Code Entry Points + +| File | Role | +|------|------| +| `Shared/SimpleXApp.swift` | App entry point, Haskell init, lifecycle management | +| `Shared/AppDelegate.swift` | UIApplicationDelegate for push token registration | +| `Shared/ContentView.swift` | Root view -- authentication gate, call overlay, navigation | +| `Shared/Model/ChatModel.swift` | Primary observable state (ChatModel, ItemsModel, Chat) | +| `Shared/Model/SimpleXAPI.swift` | FFI bridge -- chatSendCmd, chatApiSendCmd, sendSimpleXCmd | +| `Shared/Model/AppAPITypes.swift` | ChatCommand, ChatResponse, ChatEvent enums (iOS app layer) | +| `SimpleXChat/APITypes.swift` | APIResult, ChatError, ChatCmdProtocol (shared framework) | +| `SimpleXChat/ChatTypes.swift` | User, ChatInfo, Contact, GroupInfo, ChatItem data types | +| `SimpleXChat/SimpleX.h` | C header for Haskell FFI functions | diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md new file mode 100644 index 0000000000..f9a3c35917 --- /dev/null +++ b/apps/ios/spec/api.md @@ -0,0 +1,610 @@ +# SimpleX Chat iOS -- Chat API Reference + +> Complete specification of the ChatCommand, ChatResponse, ChatEvent, and ChatError types that form the API between the Swift UI layer and the Haskell core. +> +> Related specs: [Architecture](architecture.md) | [State Management](state.md) | [README](README.md) +> Related product: [Concept Index](../product/concepts.md) + +**Source:** [`AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) | [`SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | [`APITypes.swift`](../SimpleXChat/APITypes.swift) | [`API.swift`](../SimpleXChat/API.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Command Categories (ChatCommand)](#2-command-categories) +3. [Response Types (ChatResponse)](#3-response-types) +4. [Event Types (ChatEvent)](#4-event-types) +5. [Error Types (ChatError)](#5-error-types) +6. [FFI Bridge Functions](#6-ffi-bridge-functions) +7. [Result Type (APIResult)](#7-result-type) + +--- + +## 1. Overview + +The iOS app communicates with the Haskell core exclusively through a command/response protocol: + +1. Swift constructs a `ChatCommand` enum value +2. The command's `cmdString` property serializes it to a text command +3. The FFI bridge sends the string to Haskell via `chat_send_cmd_retry` +4. Haskell returns a JSON response, decoded as `APIResult` +5. Async events arrive separately via `chat_recv_msg_wait`, decoded as `ChatEvent` + +**Source files**: +- [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) -- `ChatCommand` ([L15](../Shared/Model/AppAPITypes.swift#L15)), `ChatResponse0` ([L657](../Shared/Model/AppAPITypes.swift#L657)), `ChatResponse1` ([L779](../Shared/Model/AppAPITypes.swift#L779)), `ChatResponse2` ([L919](../Shared/Model/AppAPITypes.swift#L919)), `ChatEvent` ([L1069](../Shared/Model/AppAPITypes.swift#L1069)) enums +- [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift) -- `APIResult` ([L27](../SimpleXChat/APITypes.swift#L27)), `ChatAPIResult` ([L65](../SimpleXChat/APITypes.swift#L65)), `ChatError` ([L699](../SimpleXChat/APITypes.swift#L699)) +- [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) -- FFI bridge functions (`chatSendCmd` [L121](../Shared/Model/SimpleXAPI.swift#L121), `chatRecvMsg` [L237](../Shared/Model/SimpleXAPI.swift#L237)) +- [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) -- Low-level FFI (`sendSimpleXCmd` [L115](../SimpleXChat/API.swift#L115), `recvSimpleXMsg` [L137](../SimpleXChat/API.swift#L137)) +- `SimpleXChat/ChatTypes.swift` -- Data types used in commands/responses (User, Contact, GroupInfo, ChatItem, etc.) +- `../../src/Simplex/Chat/Controller.hs` -- Haskell controller (function `chat_send_cmd_retry`, `chat_recv_msg_wait`) + +--- + +## 2. Command Categories + +The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.swift#L15)) contains all commands the iOS app can send to the Haskell core. Commands are organized below by functional area. + +### 2.1 User Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `showActiveUser` | -- | Get current active user | [L16](../Shared/Model/AppAPITypes.swift#L16) | +| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L17](../Shared/Model/AppAPITypes.swift#L17) | +| `listUsers` | -- | List all user profiles | [L18](../Shared/Model/AppAPITypes.swift#L18) | +| `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L19](../Shared/Model/AppAPITypes.swift#L19) | +| `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L24](../Shared/Model/AppAPITypes.swift#L24) | +| `apiUnhideUser` | `userId: Int64, viewPwd: String` | Unhide hidden user | [L25](../Shared/Model/AppAPITypes.swift#L25) | +| `apiMuteUser` | `userId: Int64` | Mute notifications for user | [L26](../Shared/Model/AppAPITypes.swift#L26) | +| `apiUnmuteUser` | `userId: Int64` | Unmute notifications for user | [L27](../Shared/Model/AppAPITypes.swift#L27) | +| `apiDeleteUser` | `userId: Int64, delSMPQueues: Bool, viewPwd: String?` | Delete user profile | [L28](../Shared/Model/AppAPITypes.swift#L28) | +| `apiUpdateProfile` | `userId: Int64, profile: Profile` | Update user display name/image | [L141](../Shared/Model/AppAPITypes.swift#L141) | +| `setAllContactReceipts` | `enable: Bool` | Set delivery receipts for all contacts | [L20](../Shared/Model/AppAPITypes.swift#L20) | +| `apiSetUserContactReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user contact receipt settings | [L21](../Shared/Model/AppAPITypes.swift#L21) | +| `apiSetUserGroupReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user group receipt settings | [L22](../Shared/Model/AppAPITypes.swift#L22) | +| `apiSetUserAutoAcceptMemberContacts` | `userId: Int64, enable: Bool` | Auto-accept group member contacts | [L23](../Shared/Model/AppAPITypes.swift#L23) | + +### 2.2 Chat Lifecycle Control + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `startChat` | `mainApp: Bool, enableSndFiles: Bool` | Start chat engine | [L29](../Shared/Model/AppAPITypes.swift#L29) | +| `checkChatRunning` | -- | Check if chat is running | [L30](../Shared/Model/AppAPITypes.swift#L30) | +| `apiStopChat` | -- | Stop chat engine | [L31](../Shared/Model/AppAPITypes.swift#L31) | +| `apiActivateChat` | `restoreChat: Bool` | Resume from background | [L32](../Shared/Model/AppAPITypes.swift#L32) | +| `apiSuspendChat` | `timeoutMicroseconds: Int` | Suspend for background | [L33](../Shared/Model/AppAPITypes.swift#L33) | +| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder` | Set file storage paths | [L34](../Shared/Model/AppAPITypes.swift#L34) | +| `apiSetEncryptLocalFiles` | `enable: Bool` | Toggle local file encryption | [L35](../Shared/Model/AppAPITypes.swift#L35) | + +### 2.3 Chat & Message Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetChats` | `userId: Int64` | Get all chat previews for user | [L44](../Shared/Model/AppAPITypes.swift#L44) | +| `apiGetChat` | `chatId, scope, contentTag, pagination, search` | Get messages for a chat | [L45](../Shared/Model/AppAPITypes.swift#L45) | +| `apiGetChatContentTypes` | `chatId, scope` | Get content type counts for a chat | [L46](../Shared/Model/AppAPITypes.swift#L46) | +| `apiGetChatItemInfo` | `type, id, scope, itemId` | Get detailed info for a message | [L47](../Shared/Model/AppAPITypes.swift#L47) | +| `apiSendMessages` | `type, id, scope, sendAsGroup, live, ttl, composedMessages` | Send one or more messages; `sendAsGroup` sends as channel owner | [L48](../Shared/Model/AppAPITypes.swift#L48) | +| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L54](../Shared/Model/AppAPITypes.swift#L54) | +| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L56](../Shared/Model/AppAPITypes.swift#L56) | +| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L57](../Shared/Model/AppAPITypes.swift#L57) | +| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L58](../Shared/Model/AppAPITypes.swift#L58) | +| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction | [L61](../Shared/Model/AppAPITypes.swift#L61) | +| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L62](../Shared/Model/AppAPITypes.swift#L62) | +| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L63](../Shared/Model/AppAPITypes.swift#L63) | +| `apiForwardChatItems` | `toChatType, toChatId, toScope, sendAsGroup, from..., itemIds, ttl` | Forward messages; `sendAsGroup` forwards as channel owner | [L64](../Shared/Model/AppAPITypes.swift#L64) | +| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L55](../Shared/Model/AppAPITypes.swift#L55) | +| `apiChatRead` | `type, id, scope` | Mark entire chat as read | [L166](../Shared/Model/AppAPITypes.swift#L166) | +| `apiChatItemsRead` | `type, id, scope, itemIds` | Mark specific items as read | [L167](../Shared/Model/AppAPITypes.swift#L167) | +| `apiChatUnread` | `type, id, unreadChat` | Toggle unread badge | [L168](../Shared/Model/AppAPITypes.swift#L168) | + +### 2.4 Contact Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiAddContact` | `userId, incognito` | Create invitation link | [L126](../Shared/Model/AppAPITypes.swift#L126) | +| `apiConnect` | `userId, incognito, connLink` | Connect via link | [L136](../Shared/Model/AppAPITypes.swift#L136) | +| `apiConnectPlan` | `userId, connLink` | Plan connection (preview) | [L129](../Shared/Model/AppAPITypes.swift#L129) | +| `apiPrepareContact` | `userId, connLink, contactShortLinkData` | Prepare contact from link | [L130](../Shared/Model/AppAPITypes.swift#L130) | +| `apiPrepareGroup` | `userId, connLink, directLink, groupShortLinkData` | Prepare group from link; `directLink` (required, no default) indicates whether link is a direct (non-relay) group link | [L131](../Shared/Model/AppAPITypes.swift#L131) | +| `apiConnectPreparedContact` | `contactId, incognito, msg` | Connect prepared contact | [L134](../Shared/Model/AppAPITypes.swift#L134) | +| `apiConnectPreparedGroup` | `groupId, incognito, msg` | Connect to a prepared group/channel; returns `(GroupInfo, [RelayConnectionResult])?` | [L135](../Shared/Model/AppAPITypes.swift#L135) | +| `apiConnectContactViaAddress` | `userId, incognito, contactId` | Connect via address | [L137](../Shared/Model/AppAPITypes.swift#L137) | +| `apiAcceptContact` | `incognito, contactReqId` | Accept contact request | [L154](../Shared/Model/AppAPITypes.swift#L154) | +| `apiRejectContact` | `contactReqId` | Reject contact request | [L155](../Shared/Model/AppAPITypes.swift#L155) | +| `apiDeleteChat` | `type, id, chatDeleteMode` | Delete conversation | [L138](../Shared/Model/AppAPITypes.swift#L138) | +| `apiClearChat` | `type, id` | Clear conversation history | [L139](../Shared/Model/AppAPITypes.swift#L139) | +| `apiListContacts` | `userId` | List all contacts | [L140](../Shared/Model/AppAPITypes.swift#L140) | +| `apiSetContactPrefs` | `contactId, preferences` | Set contact preferences | [L142](../Shared/Model/AppAPITypes.swift#L142) | +| `apiSetContactAlias` | `contactId, localAlias` | Set local alias | [L143](../Shared/Model/AppAPITypes.swift#L143) | +| `apiSetConnectionAlias` | `connId, localAlias` | Set pending connection alias | [L145](../Shared/Model/AppAPITypes.swift#L145) | +| `apiContactInfo` | `contactId` | Get contact info + connection stats | [L112](../Shared/Model/AppAPITypes.swift#L112) | +| `apiSetConnectionIncognito` | `connId, incognito` | Toggle incognito on pending connection | [L127](../Shared/Model/AppAPITypes.swift#L127) | + +### 2.5 Group Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiNewGroup` | `userId, incognito, groupProfile` | Create new group | [L72](../Shared/Model/AppAPITypes.swift#L72) | +| `apiNewPublicGroup` | `userId, incognito, relayIds, groupProfile` | Create new public group (channel) with chat relays | [L73](../Shared/Model/AppAPITypes.swift#L73) | +| `apiGetGroupRelays` | `groupId` | Get group relay list with status (owner only) | [L74](../Shared/Model/AppAPITypes.swift#L74) | +| `apiAddMember` | `groupId, contactId, memberRole` | Invite contact to group | [L75](../Shared/Model/AppAPITypes.swift#L75) | +| `apiJoinGroup` | `groupId` | Accept group invitation | [L76](../Shared/Model/AppAPITypes.swift#L76) | +| `apiAcceptMember` | `groupId, groupMemberId, memberRole` | Accept member (knocking) | [L77](../Shared/Model/AppAPITypes.swift#L77) | +| `apiRemoveMembers` | `groupId, memberIds, withMessages` | Remove members | [L81](../Shared/Model/AppAPITypes.swift#L81) | +| `apiLeaveGroup` | `groupId` | Leave group | [L82](../Shared/Model/AppAPITypes.swift#L82) | +| `apiListMembers` | `groupId` | List group members | [L83](../Shared/Model/AppAPITypes.swift#L83) | +| `apiUpdateGroupProfile` | `groupId, groupProfile` | Update group name/image/description | [L84](../Shared/Model/AppAPITypes.swift#L84) | +| `apiMembersRole` | `groupId, memberIds, memberRole` | Change member roles | [L79](../Shared/Model/AppAPITypes.swift#L79) | +| `apiBlockMembersForAll` | `groupId, memberIds, blocked` | Block members for all | [L80](../Shared/Model/AppAPITypes.swift#L80) | +| `apiCreateGroupLink` | `groupId, memberRole` | Create shareable group link | [L85](../Shared/Model/AppAPITypes.swift#L85) | +| `apiGroupLinkMemberRole` | `groupId, memberRole` | Change group link default role | [L86](../Shared/Model/AppAPITypes.swift#L86) | +| `apiDeleteGroupLink` | `groupId` | Delete group link | [L87](../Shared/Model/AppAPITypes.swift#L87) | +| `apiGetGroupLink` | `groupId` | Get existing group link | [L88](../Shared/Model/AppAPITypes.swift#L88) | +| `apiAddGroupShortLink` | `groupId` | Add short link to group | [L89](../Shared/Model/AppAPITypes.swift#L89) | +| `apiCreateMemberContact` | `groupId, groupMemberId` | Create direct contact from group member | [L90](../Shared/Model/AppAPITypes.swift#L90) | +| `apiSendMemberContactInvitation` | `contactId, msg` | Send contact invitation to member | [L91](../Shared/Model/AppAPITypes.swift#L91) | +| `apiGroupMemberInfo` | `groupId, groupMemberId` | Get member info + connection stats | [L113](../Shared/Model/AppAPITypes.swift#L113) | +| `apiDeleteMemberSupportChat` | `groupId, groupMemberId` | Delete member support chat | [L78](../Shared/Model/AppAPITypes.swift#L78) | +| `apiSetMemberSettings` | `groupId, groupMemberId, memberSettings` | Set per-member settings | [L111](../Shared/Model/AppAPITypes.swift#L111) | +| `apiSetGroupAlias` | `groupId, localAlias` | Set local group alias | [L144](../Shared/Model/AppAPITypes.swift#L144) | + +### 2.6 Chat Tags + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetChatTags` | `userId` | Get all user tags | [L43](../Shared/Model/AppAPITypes.swift#L43) | +| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L49](../Shared/Model/AppAPITypes.swift#L49) | +| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L50](../Shared/Model/AppAPITypes.swift#L50) | +| `apiDeleteChatTag` | `tagId` | Delete a tag | [L51](../Shared/Model/AppAPITypes.swift#L51) | +| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L52](../Shared/Model/AppAPITypes.swift#L52) | +| `apiReorderChatTags` | `tagIds` | Reorder tags | [L53](../Shared/Model/AppAPITypes.swift#L53) | + +### 2.7 File Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `receiveFile` | `fileId, userApprovedRelays, encrypted, inline` | Accept and download file | [L169](../Shared/Model/AppAPITypes.swift#L169) | +| `setFileToReceive` | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive | [L170](../Shared/Model/AppAPITypes.swift#L170) | +| `cancelFile` | `fileId` | Cancel file transfer | [L171](../Shared/Model/AppAPITypes.swift#L171) | +| `apiUploadStandaloneFile` | `userId, file: CryptoFile` | Upload file to XFTP (no chat) | [L181](../Shared/Model/AppAPITypes.swift#L181) | +| `apiDownloadStandaloneFile` | `userId, url, file: CryptoFile` | Download from XFTP URL | [L182](../Shared/Model/AppAPITypes.swift#L182) | +| `apiStandaloneFileInfo` | `url` | Get file metadata from XFTP URL | [L183](../Shared/Model/AppAPITypes.swift#L183) | + +### 2.8 WebRTC Call Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSendCallInvitation` | `contact, callType` | Initiate call | [L157](../Shared/Model/AppAPITypes.swift#L157) | +| `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) | +| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) | +| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) | +| `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) | +| `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) | +| `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) | + +### 2.9 Push Notifications + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetNtfToken` | -- | Get current notification token | [L65](../Shared/Model/AppAPITypes.swift#L65) | +| `apiRegisterToken` | `token, notificationMode` | Register device token with server | [L66](../Shared/Model/AppAPITypes.swift#L66) | +| `apiVerifyToken` | `token, nonce, code` | Verify token registration | [L67](../Shared/Model/AppAPITypes.swift#L67) | +| `apiCheckToken` | `token` | Check token status | [L68](../Shared/Model/AppAPITypes.swift#L68) | +| `apiDeleteToken` | `token` | Unregister token | [L69](../Shared/Model/AppAPITypes.swift#L69) | +| `apiGetNtfConns` | `nonce, encNtfInfo` | Get notification connections (NSE) | [L70](../Shared/Model/AppAPITypes.swift#L70) | +| `apiGetConnNtfMessages` | `connMsgReqs` | Get notification messages (NSE) | [L71](../Shared/Model/AppAPITypes.swift#L71) | + +### 2.10 Settings & Configuration + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSaveSettings` | `settings: AppSettings` | Save app settings to core | [L41](../Shared/Model/AppAPITypes.swift#L41) | +| `apiGetSettings` | `settings: AppSettings` | Get settings from core | [L42](../Shared/Model/AppAPITypes.swift#L42) | +| `apiSetChatSettings` | `type, id, chatSettings` | Per-chat notification settings | [L110](../Shared/Model/AppAPITypes.swift#L110) | +| `apiSetChatItemTTL` | `userId, seconds` | Set global message TTL | [L102](../Shared/Model/AppAPITypes.swift#L102) | +| `apiGetChatItemTTL` | `userId` | Get global message TTL | [L103](../Shared/Model/AppAPITypes.swift#L103) | +| `apiSetChatTTL` | `userId, type, id, seconds` | Per-chat message TTL | [L104](../Shared/Model/AppAPITypes.swift#L104) | +| `apiSetNetworkConfig` | `networkConfig: NetCfg` | Set network configuration | [L105](../Shared/Model/AppAPITypes.swift#L105) | +| `apiGetNetworkConfig` | -- | Get network configuration | [L106](../Shared/Model/AppAPITypes.swift#L106) | +| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Set network type/status | [L107](../Shared/Model/AppAPITypes.swift#L107) | +| `reconnectAllServers` | -- | Force reconnect all servers | [L108](../Shared/Model/AppAPITypes.swift#L108) | +| `reconnectServer` | `userId, smpServer` | Reconnect specific server | [L109](../Shared/Model/AppAPITypes.swift#L109) | + +### 2.11 Database & Storage + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiStorageEncryption` | `config: DBEncryptionConfig` | Set/change database encryption | [L39](../Shared/Model/AppAPITypes.swift#L39) | +| `testStorageEncryption` | `key: String` | Test encryption key | [L40](../Shared/Model/AppAPITypes.swift#L40) | +| `apiExportArchive` | `config: ArchiveConfig` | Export database archive | [L36](../Shared/Model/AppAPITypes.swift#L36) | +| `apiImportArchive` | `config: ArchiveConfig` | Import database archive | [L37](../Shared/Model/AppAPITypes.swift#L37) | +| `apiDeleteStorage` | -- | Delete all storage | [L38](../Shared/Model/AppAPITypes.swift#L38) | + +### 2.12 Server Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetServerOperators` | -- | Get server operators | [L94](../Shared/Model/AppAPITypes.swift#L94) | +| `apiSetServerOperators` | `operators` | Set server operators | [L95](../Shared/Model/AppAPITypes.swift#L95) | +| `apiGetUserServers` | `userId` | Get user's configured servers | [L96](../Shared/Model/AppAPITypes.swift#L96) | +| `apiSetUserServers` | `userId, userServers` | Set user's servers | [L97](../Shared/Model/AppAPITypes.swift#L97) | +| `apiValidateServers` | `userId, userServers` | Validate server configuration; returns errors and warnings | [L98](../Shared/Model/AppAPITypes.swift#L98) | +| `apiGetUsageConditions` | -- | Get usage conditions | [L99](../Shared/Model/AppAPITypes.swift#L99) | +| `apiAcceptConditions` | `conditionsId, operatorIds` | Accept usage conditions | [L101](../Shared/Model/AppAPITypes.swift#L101) | +| `apiTestProtoServer` | `userId, server` | Test server connectivity | [L93](../Shared/Model/AppAPITypes.swift#L93) | + +### 2.13 Theme & UI + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSetUserUIThemes` | `userId, themes: ThemeModeOverrides?` | Set per-user theme | [L146](../Shared/Model/AppAPITypes.swift#L146) | +| `apiSetChatUIThemes` | `chatId, themes: ThemeModeOverrides?` | Set per-chat theme | [L147](../Shared/Model/AppAPITypes.swift#L147) | + +### 2.14 Remote Desktop + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `setLocalDeviceName` | `displayName` | Set device name for pairing | [L173](../Shared/Model/AppAPITypes.swift#L173) | +| `connectRemoteCtrl` | `xrcpInvitation` | Connect to desktop via QR code | [L174](../Shared/Model/AppAPITypes.swift#L174) | +| `findKnownRemoteCtrl` | -- | Find previously paired desktops | [L175](../Shared/Model/AppAPITypes.swift#L175) | +| `confirmRemoteCtrl` | `remoteCtrlId` | Confirm known remote controller | [L176](../Shared/Model/AppAPITypes.swift#L176) | +| `verifyRemoteCtrlSession` | `sessionCode` | Verify session code | [L177](../Shared/Model/AppAPITypes.swift#L177) | +| `listRemoteCtrls` | -- | List known remote controllers | [L178](../Shared/Model/AppAPITypes.swift#L178) | +| `stopRemoteCtrl` | -- | Stop remote session | [L179](../Shared/Model/AppAPITypes.swift#L179) | +| `deleteRemoteCtrl` | `remoteCtrlId` | Delete known controller | [L180](../Shared/Model/AppAPITypes.swift#L180) | + +### 2.15 Diagnostics + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `showVersion` | -- | Get core version info | [L185](../Shared/Model/AppAPITypes.swift#L185) | +| `getAgentSubsTotal` | `userId` | Get total SMP subscriptions | [L186](../Shared/Model/AppAPITypes.swift#L186) | +| `getAgentServersSummary` | `userId` | Get server summary stats | [L187](../Shared/Model/AppAPITypes.swift#L187) | +| `resetAgentServersStats` | -- | Reset server statistics | [L188](../Shared/Model/AppAPITypes.swift#L188) | + +### 2.16 Address Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiCreateMyAddress` | `userId` | Create SimpleX address | [L148](../Shared/Model/AppAPITypes.swift#L148) | +| `apiDeleteMyAddress` | `userId` | Delete SimpleX address | [L149](../Shared/Model/AppAPITypes.swift#L149) | +| `apiShowMyAddress` | `userId` | Show current address | [L150](../Shared/Model/AppAPITypes.swift#L150) | +| `apiAddMyAddressShortLink` | `userId` | Add short link to address | [L151](../Shared/Model/AppAPITypes.swift#L151) | +| `apiSetProfileAddress` | `userId, on: Bool` | Toggle address in profile | [L152](../Shared/Model/AppAPITypes.swift#L152) | +| `apiSetAddressSettings` | `userId, addressSettings` | Configure address settings | [L153](../Shared/Model/AppAPITypes.swift#L153) | + +### 2.17 Connection Security + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetContactCode` | `contactId` | Get verification code | [L122](../Shared/Model/AppAPITypes.swift#L122) | +| `apiGetGroupMemberCode` | `groupId, groupMemberId` | Get member verification code | [L123](../Shared/Model/AppAPITypes.swift#L123) | +| `apiVerifyContact` | `contactId, connectionCode` | Verify contact identity | [L124](../Shared/Model/AppAPITypes.swift#L124) | +| `apiVerifyGroupMember` | `groupId, groupMemberId, connectionCode` | Verify group member identity | [L125](../Shared/Model/AppAPITypes.swift#L125) | +| `apiSwitchContact` | `contactId` | Switch contact connection (key rotation) | [L116](../Shared/Model/AppAPITypes.swift#L116) | +| `apiSwitchGroupMember` | `groupId, groupMemberId` | Switch group member connection | [L117](../Shared/Model/AppAPITypes.swift#L117) | +| `apiAbortSwitchContact` | `contactId` | Abort contact switch | [L118](../Shared/Model/AppAPITypes.swift#L118) | +| `apiAbortSwitchGroupMember` | `groupId, groupMemberId` | Abort member switch | [L119](../Shared/Model/AppAPITypes.swift#L119) | +| `apiSyncContactRatchet` | `contactId, force` | Sync double-ratchet state | [L120](../Shared/Model/AppAPITypes.swift#L120) | +| `apiSyncGroupMemberRatchet` | `groupId, groupMemberId, force` | Sync member ratchet | [L121](../Shared/Model/AppAPITypes.swift#L121) | + +--- + +## 3. Response Types + +Responses are split across three enums due to Swift enum size limitations: + +### ChatResponse0 + +Synchronous query responses ([`AppAPITypes.swift` L657](../Shared/Model/AppAPITypes.swift#L657)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `activeUser` | `user: User` | Current active user | [L658](../Shared/Model/AppAPITypes.swift#L658) | +| `usersList` | `users: [UserInfo]` | All user profiles | [L659](../Shared/Model/AppAPITypes.swift#L659) | +| `chatStarted` | -- | Chat engine started | [L660](../Shared/Model/AppAPITypes.swift#L660) | +| `chatRunning` | -- | Chat is already running | [L661](../Shared/Model/AppAPITypes.swift#L661) | +| `chatStopped` | -- | Chat engine stopped | [L662](../Shared/Model/AppAPITypes.swift#L662) | +| `apiChats` | `user, chats: [ChatData]` | All chat previews | [L663](../Shared/Model/AppAPITypes.swift#L663) | +| `apiChat` | `user, chat: ChatData, navInfo` | Single chat with messages | [L664](../Shared/Model/AppAPITypes.swift#L664) | +| `chatTags` | `user, userTags: [ChatTag]` | User's chat tags | [L666](../Shared/Model/AppAPITypes.swift#L666) | +| `chatItemInfo` | `user, chatItem, chatItemInfo` | Message detail info | [L667](../Shared/Model/AppAPITypes.swift#L667) | +| `serverTestResult` | `user, testServer, testFailure` | Server test result | [L668](../Shared/Model/AppAPITypes.swift#L668) | +| `userServersValidation` | `user, serverErrors: [UserServersError], serverWarnings: [UserServersWarning]` | Server validation result with errors and warnings | [L671](../Shared/Model/AppAPITypes.swift#L671) | +| `networkConfig` | `networkConfig: NetCfg` | Current network config | [L674](../Shared/Model/AppAPITypes.swift#L674) | +| `contactInfo` | `user, contact, connectionStats, customUserProfile` | Contact details | [L675](../Shared/Model/AppAPITypes.swift#L675) | +| `groupMemberInfo` | `user, groupInfo, member, connectionStats` | Member details | [L676](../Shared/Model/AppAPITypes.swift#L676) | +| `connectionVerified` | `verified, expectedCode` | Verification result | [L686](../Shared/Model/AppAPITypes.swift#L686) | +| `tagsUpdated` | `user, userTags, chatTags` | Tags changed | [L687](../Shared/Model/AppAPITypes.swift#L687) | + +### ChatResponse1 + +Contact, message, and profile responses ([`AppAPITypes.swift` L779](../Shared/Model/AppAPITypes.swift#L779)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `invitation` | `user, connLinkInvitation, connection` | Created invitation link | [L780](../Shared/Model/AppAPITypes.swift#L780) | +| `connectionPlan` | `user, connLink, connectionPlan` | Connection plan preview | [L783](../Shared/Model/AppAPITypes.swift#L783) | +| `newPreparedChat` | `user, chat: ChatData` | Prepared contact/group | [L784](../Shared/Model/AppAPITypes.swift#L784) | +| `startedConnectionToGroup` | `user, groupInfo, relayResults: [RelayConnectionResult]` | Group/channel join initiated; relay results indicate per-relay connection success/failure | [L790](../Shared/Model/AppAPITypes.swift#L790) | +| `contactDeleted` | `user, contact` | Contact deleted | [L793](../Shared/Model/AppAPITypes.swift#L793) | +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages sent/received | [L811](../Shared/Model/AppAPITypes.swift#L811) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited | [L814](../Shared/Model/AppAPITypes.swift#L814) | +| `chatItemReaction` | `user, added, reaction` | Reaction change | [L816](../Shared/Model/AppAPITypes.swift#L816) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L818](../Shared/Model/AppAPITypes.swift#L818) | +| `contactsList` | `user, contacts: [Contact]` | All contacts list | [L819](../Shared/Model/AppAPITypes.swift#L819) | +| `userProfileUpdated` | `user, fromProfile, toProfile` | Profile changed | [L799](../Shared/Model/AppAPITypes.swift#L799) | +| `userContactLinkCreated` | `user, connLinkContact` | Address created | [L807](../Shared/Model/AppAPITypes.swift#L807) | +| `forwardPlan` | `user, chatItemIds, forwardConfirmation` | Forward plan result | [L813](../Shared/Model/AppAPITypes.swift#L813) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L812](../Shared/Model/AppAPITypes.swift#L812) | + +### ChatResponse2 + +Group, file, call, notification, and misc responses ([`AppAPITypes.swift` L919](../Shared/Model/AppAPITypes.swift#L919)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `groupCreated` | `user, groupInfo` | New group created | [L921](../Shared/Model/AppAPITypes.swift#L921) | +| `publicGroupCreated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | New public group (channel) created with relay info | [L922](../Shared/Model/AppAPITypes.swift#L922) | +| `groupRelays` | `user, groupInfo, groupRelays: [GroupRelay]` | Group relay list (owner API response) | [L923](../Shared/Model/AppAPITypes.swift#L923) | +| `sentGroupInvitation` | `user, groupInfo, contact, member` | Group invitation sent | [L924](../Shared/Model/AppAPITypes.swift#L924) | +| `groupMembers` | `user, group: Group` | Group member list | [L928](../Shared/Model/AppAPITypes.swift#L928) | +| `membersRoleUser` | `user, groupInfo, members, toRole` | Role changed | [L932](../Shared/Model/AppAPITypes.swift#L932) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile updated | [L934](../Shared/Model/AppAPITypes.swift#L934) | +| `groupLinkCreated` | `user, groupInfo, groupLink` | Group link created | [L935](../Shared/Model/AppAPITypes.swift#L935) | +| `rcvFileAccepted` | `user, chatItem` | File download started | [L942](../Shared/Model/AppAPITypes.swift#L942) | +| `callInvitations` | `callInvitations: [RcvCallInvitation]` | Pending calls | [L951](../Shared/Model/AppAPITypes.swift#L951) | +| `ntfToken` | `token, status, ntfMode, ntfServer` | Notification token info | [L954](../Shared/Model/AppAPITypes.swift#L954) | +| `versionInfo` | `versionInfo, chatMigrations, agentMigrations` | Core version | [L962](../Shared/Model/AppAPITypes.swift#L962) | +| `cmdOk` | `user_` | Generic success | [L963](../Shared/Model/AppAPITypes.swift#L963) | +| `archiveExported` | `archiveErrors: [ArchiveError]` | Export result | [L967](../Shared/Model/AppAPITypes.swift#L967) | +| `archiveImported` | `archiveErrors: [ArchiveError]` | Import result | [L968](../Shared/Model/AppAPITypes.swift#L968) | +| `appSettings` | `appSettings: AppSettings` | Retrieved settings | [L969](../Shared/Model/AppAPITypes.swift#L969) | + +--- + +## 4. Event Types + +The `ChatEvent` enum ([`AppAPITypes.swift` L1069](../Shared/Model/AppAPITypes.swift#L1069)) represents async events from the Haskell core. These arrive via `chat_recv_msg_wait` polling, not as responses to commands. + +Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2282) in `SimpleXAPI.swift`. + +### Connection Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `contactConnected` | `user, contact, userCustomProfile` | Contact connection established | [L1076](../Shared/Model/AppAPITypes.swift#L1076) | +| `contactConnecting` | `user, contact` | Contact connecting in progress | [L1077](../Shared/Model/AppAPITypes.swift#L1077) | +| `contactSndReady` | `user, contact` | Ready to send to contact | [L1078](../Shared/Model/AppAPITypes.swift#L1078) | +| `contactDeletedByContact` | `user, contact` | Contact deleted by other party | [L1075](../Shared/Model/AppAPITypes.swift#L1075) | +| `contactUpdated` | `user, toContact` | Contact profile updated | [L1080](../Shared/Model/AppAPITypes.swift#L1080) | +| `receivedContactRequest` | `user, contactRequest, chat_` | Incoming contact request | [L1079](../Shared/Model/AppAPITypes.swift#L1079) | +| `subscriptionStatus` | `subscriptionStatus, connections` | Connection subscription change | [L1082](../Shared/Model/AppAPITypes.swift#L1082) | + +### Message Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages received | [L1084](../Shared/Model/AppAPITypes.swift#L1084) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited remotely | [L1086](../Shared/Model/AppAPITypes.swift#L1086) | +| `chatItemReaction` | `user, added, reaction: ACIReaction` | Reaction added/removed | [L1087](../Shared/Model/AppAPITypes.swift#L1087) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L1088](../Shared/Model/AppAPITypes.swift#L1088) | +| `chatItemsStatusesUpdated` | `user, chatItems: [AChatItem]` | Delivery status changed | [L1085](../Shared/Model/AppAPITypes.swift#L1085) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L1090](../Shared/Model/AppAPITypes.swift#L1090) | +| `chatInfoUpdated` | `user, chatInfo` | Chat metadata changed | [L1083](../Shared/Model/AppAPITypes.swift#L1083) | + +### Group Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `receivedGroupInvitation` | `user, groupInfo, contact, memberRole` | Group invitation received | [L1091](../Shared/Model/AppAPITypes.swift#L1091) | +| `userAcceptedGroupSent` | `user, groupInfo, hostContact` | Joined group | [L1092](../Shared/Model/AppAPITypes.swift#L1092) | +| `groupLinkConnecting` | `user, groupInfo, hostMember` | Connecting via group link | [L1093](../Shared/Model/AppAPITypes.swift#L1093) | +| `joinedGroupMemberConnecting` | `user, groupInfo, hostMember, member` | Member joining | [L1095](../Shared/Model/AppAPITypes.swift#L1095) | +| `memberRole` | `user, groupInfo, byMember, member, fromRole, toRole` | Role changed | [L1097](../Shared/Model/AppAPITypes.swift#L1097) | +| `memberBlockedForAll` | `user, groupInfo, byMember, member, blocked` | Member blocked | [L1098](../Shared/Model/AppAPITypes.swift#L1098) | +| `deletedMemberUser` | `user, groupInfo, member, withMessages` | Current user removed | [L1099](../Shared/Model/AppAPITypes.swift#L1099) | +| `deletedMember` | `user, groupInfo, byMember, deletedMember` | Member removed | [L1100](../Shared/Model/AppAPITypes.swift#L1100) | +| `leftMember` | `user, groupInfo, member` | Member left | [L1101](../Shared/Model/AppAPITypes.swift#L1101) | +| `groupDeleted` | `user, groupInfo, member` | Group deleted | [L1102](../Shared/Model/AppAPITypes.swift#L1102) | +| `userJoinedGroup` | `user, groupInfo, hostMember` | Successfully joined; `hostMember` is upserted into group members | [L1103](../Shared/Model/AppAPITypes.swift#L1103) | +| `joinedGroupMember` | `user, groupInfo, member` | New member joined | [L1104](../Shared/Model/AppAPITypes.swift#L1104) | +| `connectedToGroupMember` | `user, groupInfo, member, memberContact` | E2E session established with member | [L1105](../Shared/Model/AppAPITypes.swift#L1105) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) | +| `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) | +| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) | +| `groupRelayUpdated` | `user, groupInfo, member, groupRelay` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = .rsRejected` and `member.memberStatus = .memRejected` — final until cleared by the relay operator's `/group allow ` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) | + +### File Transfer Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `rcvFileStart` | `user, chatItem` | Download started | [L1112](../Shared/Model/AppAPITypes.swift#L1112) | +| `rcvFileProgressXFTP` | `user, chatItem_, receivedSize, totalSize` | Download progress | [L1113](../Shared/Model/AppAPITypes.swift#L1113) | +| `rcvFileComplete` | `user, chatItem` | Download complete | [L1114](../Shared/Model/AppAPITypes.swift#L1114) | +| `rcvFileSndCancelled` | `user, chatItem, rcvFileTransfer` | Sender cancelled | [L1116](../Shared/Model/AppAPITypes.swift#L1116) | +| `rcvFileError` | `user, chatItem_, agentError, rcvFileTransfer` | Download error | [L1117](../Shared/Model/AppAPITypes.swift#L1117) | +| `sndFileStart` | `user, chatItem, sndFileTransfer` | Upload started | [L1120](../Shared/Model/AppAPITypes.swift#L1120) | +| `sndFileComplete` | `user, chatItem, sndFileTransfer` | Upload complete (inline) | [L1121](../Shared/Model/AppAPITypes.swift#L1121) | +| `sndFileProgressXFTP` | `user, chatItem_, fileTransferMeta, sentSize, totalSize` | Upload progress | [L1123](../Shared/Model/AppAPITypes.swift#L1123) | +| `sndFileCompleteXFTP` | `user, chatItem, fileTransferMeta` | XFTP upload complete | [L1125](../Shared/Model/AppAPITypes.swift#L1125) | +| `sndFileError` | `user, chatItem_, fileTransferMeta, errorMessage` | Upload error | [L1127](../Shared/Model/AppAPITypes.swift#L1127) | + +### Call Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `callInvitation` | `callInvitation: RcvCallInvitation` | Incoming call | [L1130](../Shared/Model/AppAPITypes.swift#L1130) | +| `callOffer` | `user, contact, callType, offer, sharedKey, askConfirmation` | SDP offer received | [L1131](../Shared/Model/AppAPITypes.swift#L1131) | +| `callAnswer` | `user, contact, answer` | SDP answer received | [L1132](../Shared/Model/AppAPITypes.swift#L1132) | +| `callExtraInfo` | `user, contact, extraInfo` | ICE candidates received | [L1133](../Shared/Model/AppAPITypes.swift#L1133) | +| `callEnded` | `user, contact` | Call ended by remote | [L1134](../Shared/Model/AppAPITypes.swift#L1134) | + +### Connection Security Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `contactSwitch` | `user, contact, switchProgress` | Key rotation progress | [L1071](../Shared/Model/AppAPITypes.swift#L1071) | +| `groupMemberSwitch` | `user, groupInfo, member, switchProgress` | Member key rotation | [L1072](../Shared/Model/AppAPITypes.swift#L1072) | +| `contactRatchetSync` | `user, contact, ratchetSyncProgress` | Ratchet sync progress | [L1073](../Shared/Model/AppAPITypes.swift#L1073) | +| `groupMemberRatchetSync` | `user, groupInfo, member, ratchetSyncProgress` | Member ratchet sync | [L1074](../Shared/Model/AppAPITypes.swift#L1074) | + +### System Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `chatSuspended` | -- | Core suspended | [L1070](../Shared/Model/AppAPITypes.swift#L1070) | + +--- + +## 5. Error Types + +Defined in [`SimpleXChat/APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699): + +```swift +public enum ChatError: Decodable, Hashable, Error { + case error(errorType: ChatErrorType) + case errorAgent(agentError: AgentErrorType) + case errorStore(storeError: StoreError) + case errorDatabase(databaseError: DatabaseError) + case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) + case invalidJSON(json: Data?) + case unexpectedResult(type: String) +} +``` + +### Error Categories + +| Category | Enum | Description | Source | +|----------|------|-------------|--------| +| Chat logic | `ChatErrorType` | Business logic errors (e.g., invalid state, permission denied, `chatRelayExists`) | [`APITypes.swift` L722](../SimpleXChat/APITypes.swift#L722) | +| SMP Agent | `AgentErrorType` | Protocol/network errors from the SMP agent layer | [`APITypes.swift` L884](../SimpleXChat/APITypes.swift#L884) | +| Database store | `StoreError` | SQLite query/constraint errors (includes relay-related: `relayUserNotFound`, `duplicateMemberId`, `userChatRelayNotFound`, `groupRelayNotFound`, `groupRelayNotFoundByMemberId`) | [`APITypes.swift` L802](../SimpleXChat/APITypes.swift#L802) | +| Database engine | `DatabaseError` | DB open/migration/encryption errors | [`APITypes.swift` L871](../SimpleXChat/APITypes.swift#L871) | +| Remote control | `RemoteCtrlError` | Remote desktop session errors | [`APITypes.swift` L1054](../SimpleXChat/APITypes.swift#L1054) | +| Parse failure | `invalidJSON` | Failed to decode response JSON | [`APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699) | +| Unexpected | `unexpectedResult` | Response type does not match expected | [`APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699) | + +--- + +## 6. FFI Bridge Functions + +Defined in [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift): + +### Synchronous (blocking current thread) + +```swift +// Throws on error, returns typed result +func chatSendCmdSync( // SimpleXAPI.swift L93 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + log: Bool = true +) throws -> R + +// Returns APIResult (caller handles error) +func chatApiSendCmdSync( // SimpleXAPI.swift L99 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + retryNum: Int32 = 0, + log: Bool = true +) -> APIResult +``` + +### Asynchronous (Swift concurrency) + +```swift +// Throws on error, returns typed result +func chatSendCmd( // SimpleXAPI.swift L121 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + log: Bool = true +) async throws -> R + +// Returns APIResult with optional retry on network errors +func chatApiSendCmdWithRetry( // SimpleXAPI.swift L127 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + inProgress: BoxedValue? = nil, + retryNum: Int32 = 0 +) async -> APIResult? +``` + +### Low-Level FFI + +```swift +// Direct C FFI call -- serializes cmd.cmdString, calls chat_send_cmd_retry, decodes response +public func sendSimpleXCmd( // API.swift L115 + _ cmd: ChatCmdProtocol, + _ ctrl: chat_ctrl?, + retryNum: Int32 = 0 +) -> APIResult +``` + +### Event Receiver + +```swift +// Polls for async events from the Haskell core +func chatRecvMsg( // SimpleXAPI.swift L237 + _ ctrl: chat_ctrl? = nil +) async -> APIResult? + +// Processes a received event and updates app state +func processReceivedMsg( // SimpleXAPI.swift L2282 + _ res: ChatEvent +) async +``` + +--- + +## 7. Result Type + +Defined in [`SimpleXChat/APITypes.swift` L27](../SimpleXChat/APITypes.swift#L27): + +```swift +public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { + case result(R) // Successful response + case error(ChatError) // Error response from core + case invalid(type: String, json: Data) // Undecodable response + + public var responseType: String { ... } + public var unexpected: ChatError { ... } +} + +public protocol ChatAPIResult: Decodable { // APITypes.swift L65 + var responseType: String { get } + var details: String { get } + static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? +} +``` + +The `decodeAPIResult` function ([`APITypes.swift` L86](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic: +1. Try standard `JSONDecoder.decode(APIResult.self, from: data)` +2. If that fails, try manual JSON parsing via `JSONSerialization` +3. Check for `"error"` key -- return `.error` +4. Check for `"result"` key -- try `R.fallbackResult` or return `.invalid` +5. Last resort: return `.invalid(type: "invalid", json: ...)` + +--- + +## Source Files + +| File | Path | +|------|------| +| ChatCommand enum | [`Shared/Model/AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.swift#L15) | +| ChatResponse0/1/2 enums | [`Shared/Model/AppAPITypes.swift` L657, L779, L919](../Shared/Model/AppAPITypes.swift#L657) | +| ChatEvent enum | [`Shared/Model/AppAPITypes.swift` L1069](../Shared/Model/AppAPITypes.swift#L1069) | +| APIResult, ChatError | [`SimpleXChat/APITypes.swift` L27, L699](../SimpleXChat/APITypes.swift#L27) | +| FFI bridge functions | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | +| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) | +| Data types | `SimpleXChat/ChatTypes.swift` | +| C header | `SimpleXChat/SimpleX.h` | +| Haskell controller | `../../src/Simplex/Chat/Controller.hs` | diff --git a/apps/ios/spec/architecture.md b/apps/ios/spec/architecture.md new file mode 100644 index 0000000000..9ab3eb1fd2 --- /dev/null +++ b/apps/ios/spec/architecture.md @@ -0,0 +1,347 @@ +# SimpleX Chat iOS -- System Architecture + +> Technical specification for the iOS app's layered architecture, FFI bridge, event system, and extension model. +> +> Related specs: [README](README.md) | [API Reference](api.md) | [State Management](state.md) | [Database](database.md) +> Related product: [Product Overview](../product/README.md) + +**Source:** [`SimpleXApp.swift`](../Shared/SimpleXApp.swift#L1-L183) | [`AppDelegate.swift`](../Shared/AppDelegate.swift#L1-L209) | [`ContentView.swift`](../Shared/ContentView.swift#L1-L513) | [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1373) | [`SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L1-L2915) | [`AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L1-L2357) | [`APITypes.swift`](../SimpleXChat/APITypes.swift#L1-L1071) | [`API.swift`](../SimpleXChat/API.swift#L1-L388) + +--- + +## Table of Contents + +1. [Layered Architecture](#1-layered-architecture) +2. [FFI Bridge](#2-ffi-bridge) +3. [Event Streaming](#3-event-streaming) +4. [Database Architecture](#4-database-architecture) +5. [App Lifecycle](#5-app-lifecycle) +6. [Extension Architecture](#6-extension-architecture) +7. [Remote Desktop Control](#7-remote-desktop-control) + +--- + +## [1. Layered Architecture](../Shared/SimpleXApp.swift#L17-L184) + +The app follows a strict layered model where each layer communicates only with its immediate neighbor: + +``` +┌─────────────────────────────────────────┐ +│ SwiftUI Views │ Rendering, user interaction +│ (ChatListView, ChatView, ComposeView) │ +├─────────────────────────────────────────┤ +│ ChatModel (ObservableObject) │ App state, @Published properties +│ ItemsModel, Chat, ChatTagsModel │ Per-chat state, tag filtering +├─────────────────────────────────────────┤ +│ SimpleXAPI (FFI Bridge) │ chatSendCmd/chatApiSendCmd +│ AppAPITypes (ChatCommand/Response) │ JSON serialization/deserialization +├─────────────────────────────────────────┤ +│ C FFI Layer │ chat_send_cmd_retry, chat_recv_msg_wait +│ (SimpleX.h, libsimplex.a) │ Compiled Haskell via GHC cross-compiler +├─────────────────────────────────────────┤ +│ Haskell Core (chat_ctrl) │ Chat logic, chat protocol (x-events), +│ (Simplex.Chat.Controller) │ database operations, file management +├─────────────────────────────────────────┤ +│ simplexmq library (external) │ SMP/XFTP protocols, SMP Agent, +│ (github.com/simplex-chat/simplexmq) │ double-ratchet (PQDR), transport (TLS) +└─────────────────────────────────────────┘ +``` + +**Key invariant**: No SwiftUI view directly calls FFI functions. All communication flows through `ChatModel` or dedicated API functions in `SimpleXAPI.swift`. + +### Source Files + +| Layer | File | Role | Line | +|-------|------|------|------| +| Views | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift) | Chat list rendering | | +| Views | [`Shared/Views/Chat/ChatView.swift`](../Shared/Views/Chat/ChatView.swift) | Conversation rendering | | +| State | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | `ChatModel`, `ItemsModel`, `Chat` classes | L337, L74, L1271 | +| API | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | FFI bridge functions | L93 | +| API | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | `ChatCommand`, `ChatResponse`, `ChatEvent` enums | L15, L649, L1055 | +| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | C header declaring Haskell exports | | +| FFI | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | `APIResult`, `ChatError`, `ChatCmdProtocol` | L27, L699, L17 | +| Core | `../../src/Simplex/Chat/Controller.hs` | Haskell command processor — see `processCommand` in `Controller.hs` | | + +--- + +## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L49) + +### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L49) + +The Haskell core exposes these C functions, declared in `SimpleXChat/SimpleX.h`: + +```c +typedef void* chat_ctrl; + +// Initialize database, apply migrations, return controller +char *chat_migrate_init_key(char *path, char *key, int keepKey, char *confirm, + int backgroundMode, chat_ctrl *ctrl); + +// Send command string, return JSON response string +char *chat_send_cmd_retry(chat_ctrl ctl, char *cmd, int retryNum); + +// Block until next async event arrives (or timeout) +char *chat_recv_msg_wait(chat_ctrl ctl, int wait); + +// Close/reopen database store +char *chat_close_store(chat_ctrl ctl); +char *chat_reopen_store(chat_ctrl ctl); + +// Utility: markdown parsing, server validation, password hashing +char *chat_parse_markdown(char *str); +char *chat_parse_server(char *str); +char *chat_password_hash(char *pwd, char *salt); + +// File encryption/decryption +char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len); +char *chat_read_file(char *path, char *key, char *nonce); +char *chat_encrypt_file(chat_ctrl ctl, char *fromPath, char *toPath); +char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); +``` + +### [Swift Bridge Functions (SimpleXAPI.swift)](../Shared/Model/SimpleXAPI.swift#L93-L221) + +```swift +// Synchronous send -- blocks calling thread +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, + bgDelay: Double? = nil, ctrl: chat_ctrl? = nil) throws -> R // L91 + +// Async send -- dispatches to background +func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, + bgDelay: Double? = nil, ctrl: chat_ctrl? = nil) async -> APIResult // L215 + +// Low-level FFI call -- serializes command to string, calls chat_send_cmd_retry, decodes JSON +func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl?, + retryNum: Int32 = 0) -> APIResult // SimpleXChat/API.swift L114 +``` + +### Data Flow + +1. Swift constructs a `ChatCommand` enum value (e.g., `.apiSendMessages(type:id:scope:live:ttl:composedMessages:)`) +2. [`ChatCommand.cmdString`](../Shared/Model/AppAPITypes.swift#L15) serializes it to a command string (e.g., `"/_send @1 json {...}"`) +3. [`sendSimpleXCmd`](../SimpleXChat/API.swift#L115) passes the string to `chat_send_cmd_retry` via C FFI +4. Haskell core processes the command, returns JSON response string +5. Swift decodes JSON into [`APIResult`](../SimpleXChat/APITypes.swift#L27) where `R: ChatAPIResult` +6. Result is either `.result(R)`, `.error(ChatError)`, or `.invalid(type, json)` + +### [Background Task Protection](../Shared/Model/SimpleXAPI.swift#L54-L79) + +All FFI calls are wrapped in [`beginBGTask()`](../Shared/Model/SimpleXAPI.swift#L54) / `endBackgroundTask()` to prevent iOS from killing the app mid-operation. The `maxTaskDuration` is 15 seconds. + +--- + +## [3. Event Streaming](../Shared/Model/SimpleXAPI.swift#L2220-L2916) + +The Haskell core emits async events (new messages, connection status changes, file progress, etc.) that are not direct responses to commands. These are received via polling: + +``` +Haskell Core --[chat_recv_msg_wait]--> Swift event loop --> ChatModel update --> SwiftUI re-render +``` + +The event loop is implemented in [`ChatReceiver`](../Shared/Model/SimpleXAPI.swift#L2220-L2263), and events are dispatched by [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2266). + +### [Event Types (ChatEvent enum)](../Shared/Model/AppAPITypes.swift#L1055-L1129) + +Key async events delivered from core to UI: + +| Event | Description | Line | +|-------|-------------|------| +| `newChatItems` | New messages received | [L1070](../Shared/Model/AppAPITypes.swift#L1070) | +| `chatItemUpdated` | Message edited by sender | [L1072](../Shared/Model/AppAPITypes.swift#L1072) | +| `chatItemsDeleted` | Messages deleted | [L1074](../Shared/Model/AppAPITypes.swift#L1074) | +| `chatItemReaction` | Reaction added/removed | [L1073](../Shared/Model/AppAPITypes.swift#L1073) | +| `contactConnected` | New contact connected | [L1062](../Shared/Model/AppAPITypes.swift#L1062) | +| `contactUpdated` | Contact profile changed | [L1066](../Shared/Model/AppAPITypes.swift#L1066) | +| `receivedGroupInvitation` | Group invitation received | [L1077](../Shared/Model/AppAPITypes.swift#L1077) | +| `groupMemberUpdated` | Group member info changed | [L1067](../Shared/Model/AppAPITypes.swift#L1067) | +| `callInvitation` | Incoming call | [L1115](../Shared/Model/AppAPITypes.swift#L1115) | +| `chatSuspended` | Core suspended (background) | [L1056](../Shared/Model/AppAPITypes.swift#L1056) | +| `rcvFileComplete` | File download finished | [L1099](../Shared/Model/AppAPITypes.swift#L1099) | +| `sndFileCompleteXFTP` | File upload finished | [L1110](../Shared/Model/AppAPITypes.swift#L1110) | + +Events are decoded as [`ChatEvent`](../Shared/Model/AppAPITypes.swift#L1055) enum in `Shared/Model/AppAPITypes.swift` and dispatched to update `ChatModel` / `ItemsModel` properties, triggering SwiftUI view re-renders via `@Published` property observation. + +--- + +## [4. Database Architecture](../SimpleXChat/FileUtils.swift#L70-L294) + +Two SQLite databases in the app group container (shared with NSE): + +| Database | File | Contents | +|----------|------|----------| +| Chat DB | `simplex_v1_chat.db` | Messages, contacts, groups, profiles, files, tags, preferences | +| Agent DB | `simplex_v1_agent.db` | SMP connections, keys, queues, server info | + +Both databases use the `DB_FILE_PREFIX = "simplex_v1"` prefix. The database path is resolved via [`getAppDatabasePath()`](../SimpleXChat/FileUtils.swift#L70) in `SimpleXChat/FileUtils.swift`, which checks `dbContainerGroupDefault` to determine whether to use the app group container or legacy documents directory. + +See [Database & Storage specification](database.md) for full details. + +--- + +## [5. App Lifecycle](../Shared/SimpleXApp.swift#L17-L184) + +### [Initialization Sequence (SimpleXApp.swift)](../Shared/SimpleXApp.swift#L17-L38) + +```swift +// SimpleXApp.init() +1. haskell_init() // Initialize Haskell RTS (background queue, sync) +2. UserDefaults.register(defaults:) // Register app preference defaults +3. setGroupDefaults() // Sync preferences to app group container +4. setDbContainer() // Set database path L122 +5. BGManager.shared.register() // Register background task handlers +6. NtfManager.shared.registerCategories() // Register notification action categories +``` + +### State Transitions + +``` + ┌──────────┐ + │ Launched │ + └─────┬─────┘ + │ initChatAndMigrate() + v + ┌──────────┐ + │ DB Setup │ chat_migrate_init_key() + └─────┬─────┘ + │ startChat() SimpleXAPI.swift L2098 + v + ┌──────────┐ + │ Active │ apiActivateChat() SimpleXAPI.swift L358 + └─────┬─────┘ + │ scenePhase == .background + v + ┌──────────┐ + │Background │ apiSuspendChat(timeoutMicroseconds:) SimpleXAPI.swift L368 + └─────┬─────┘ + │ scenePhase == .active + v + ┌──────────┐ + │ Active │ startChatAndActivate() + └──────────┘ +``` + +### [Scene Phase Handling (SimpleXApp.swift)](../Shared/SimpleXApp.swift#L38-L123) + +- **`.active`**: Calls `startChatAndActivate()`, processes pending notification responses, refreshes chat list and call invitations +- **`.background`**: Records authentication timestamp, calls `suspendChat()` (unless CallKit call active), schedules `BGManager` background refresh, updates badge count +- **`.inactive`**: No explicit handling (transitional state) + +### CallKit Exception + +When a CallKit call is active during backgrounding, chat suspension is deferred (`CallController.shared.shouldSuspendChat = true`) until the call ends, to maintain the WebRTC session. + +--- + +## [6. Extension Architecture](../SimpleX%20NSE/NotificationService.swift#L1-L1228) + +### [Notification Service Extension (NSE)](../SimpleX%20NSE/NotificationService.swift#L1-L1228) + +The NSE ([`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228)) is a separate process that: + +1. Receives encrypted push notification payload from APNs +2. Initializes its own Haskell core instance (`chat_ctrl`) with shared database access +3. Decrypts the push payload using stored keys +4. Generates a visible `UNMutableNotificationContent` with the decrypted message preview +5. Delivers the notification to the user + +**Database sharing**: Both main app and NSE access the same database files in the app group container (`APP_GROUP_NAME`). Coordination uses file locks to prevent concurrent write conflicts. + +**Lifecycle**: The NSE has a ~30-second execution window per notification. It must initialize Haskell RTS, open the database, decrypt, and deliver within this window. + +### Share Extension (SE) + +The Share Extension (`SimpleX SE/`) allows sharing content (text, images, files) from other apps into SimpleX conversations. + +--- + +## [7. Remote Desktop Control](../Shared/Views/RemoteAccess/ConnectDesktopView.swift#L1-L545) + +Optional desktop pairing allows controlling the mobile app from a desktop client: + +- **Pairing**: Encrypted QR code scanned by desktop client establishes a session +- **Commands**: [`connectRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1613), [`findKnownRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1620), [`confirmRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1624), [`verifyRemoteCtrlSession`](../Shared/Model/SimpleXAPI.swift#L1630), [`listRemoteCtrls`](../Shared/Model/SimpleXAPI.swift#L1636), [`stopRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1642), [`deleteRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1646) +- **State**: [`ChatModel.remoteCtrlSession`](../Shared/Model/ChatModel.swift#L395)`: RemoteCtrlSession?` tracks the active session +- **Transport**: Encrypted reverse HTTP transport between mobile and desktop +- **Source**: [`Shared/Views/RemoteAccess/ConnectDesktopView.swift`](../Shared/Views/RemoteAccess/ConnectDesktopView.swift#L1-L545), see `Remote.hs` in `../../src/Simplex/Chat/` + +--- + +## 8. Chat Relay Management + +### Overview + +Chat relays are SMP servers that forward messages to channel subscribers. They are configured in the Network & Servers settings and selected during channel creation. + +### Data Model + +| Type | Location | Description | +|------|----------|-------------| +| `UserChatRelay` | `ChatTypes.swift` | Relay server config: chatRelayId, address, name, domains, preset, tested, enabled, deleted | +| `UserOperatorServers.chatRelays` | `AppAPITypes.swift` | Array of `UserChatRelay` per operator | +| `UserServersWarning` | `AppAPITypes.swift` | Enum with `.noChatRelays(user:)` case | +| `ServerSettings.serverWarnings` | `ChatListView.swift` | `[UserServersWarning]` field on `ServerSettings` struct (exposed via `SaveableSettings.servers`) | + +### Relay Management Views + +| View | File | Description | +|------|------|-------------| +| `ChatRelayView` | `ChatRelayView.swift` | Edit/view relay: name, address, test, enable toggle, delete | +| `ChatRelayViewLink` | `ChatRelayView.swift` | NavigationLink row showing relay status icon + display name | +| `NewChatRelayView` | `ChatRelayView.swift` | Form to add new relay (name + address + test + enable toggle) | +| `ServersWarningView` | `NetworkAndServers.swift` | Orange exclamation triangle + warning text | + +### Key Functions + +| Function | File | Description | +|----------|------|-------------| +| `addChatRelay(...)` | `ChatRelayView.swift` | Validates name/address, appends to `userServers[nil operator].chatRelays`, calls `validateServers_` | +| `deleteChatRelay(...)` | `ProtocolServersView.swift` | Marks relay as deleted or removes if no `chatRelayId` | +| `validRelayName(_:)` | `ChatRelayView.swift` | Non-empty + valid display name check | +| `validRelayAddress(_:)` | `ChatRelayView.swift` | Parses via `parseSimpleXMarkdown`, validates `.simplexLink(_, .relay, _, _)` | +| `showRelayTestStatus(relay:)` | `ChatRelayView.swift` | ViewBuilder returning checkmark/multiply/clear icons | +| `validateServers_` | `NetworkAndServers.swift` | Extended signature: now accepts optional `Binding<[UserServersWarning]>?`; calls `validateServers` which returns `([UserServersError], [UserServersWarning])` tuple | +| `globalServersWarning(_:)` | `NetworkAndServers.swift` | Extracts `.noChatRelays` warning text for display | +| `bindingForChatRelays(_:_:)` | `NetworkAndServers.swift` | Creates binding for `chatRelays` at operator index | + +### Relay Sections in Settings + +"Chat relays" sections appear in: +- `OperatorView`: lists relays for the operator, with header and footer +- `YourServersView` (in `ProtocolServersView`): lists relays for non-operator servers, with delete support and "Add server" -> "Chat relay" option + +### serverWarnings Plumbing + +`Binding<[UserServersWarning]>` is threaded through: `NetworkAndServers` -> `OperatorView` -> `ProtocolServersView` -> `ProtocolServerView` / `NewServerView` / `ScanProtocolServer`. All `validateServers_` calls pass the warnings binding. + +--- + +## Source Files + +| File | Path | Line | +|------|------|------| +| App entry point | [`Shared/SimpleXApp.swift`](../Shared/SimpleXApp.swift#L17) | L17 | +| App delegate | [`Shared/AppDelegate.swift`](../Shared/AppDelegate.swift#L15) | L15 | +| Root view | [`Shared/ContentView.swift`](../Shared/ContentView.swift#L24) | L24 | +| FFI bridge | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | L93 | +| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift#L115) | L115 | +| App state | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | L337 | +| API types | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | L15 | +| Shared types | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | L27 | +| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | | +| NSE | [`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228) | | +| Haskell core | `../../src/Simplex/Chat/Controller.hs` — see `processCommand` in `Controller.hs` | | +| Chat protocol (x-events, message envelopes) | `../../src/Simplex/Chat/Protocol.hs` | | + +### External: simplexmq Library + +The lower-level protocol and encryption layers are in the separate [simplexmq](https://github.com/simplex-chat/simplexmq) library: + +| Component | Spec | Implementation | +|-----------|------|----------------| +| SMP protocol | `simplexmq/protocol/simplex-messaging.md` | `simplexmq/src/Simplex/Messaging/Protocol.hs` | +| XFTP protocol | `simplexmq/protocol/xftp.md` | `simplexmq/src/Simplex/FileTransfer/Protocol.hs` | +| SMP Agent (duplex connections) | `simplexmq/protocol/agent-protocol.md` | `simplexmq/src/Simplex/Messaging/Agent.hs` | +| Double ratchet (PQDR) | `simplexmq/protocol/pqdr.md` | `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs` | +| Post-quantum KEM (sntrup761) | `simplexmq/protocol/pqdr.md` | `simplexmq/src/Simplex/Messaging/Crypto/SNTRUP761.hs` | +| TLS transport | — | `simplexmq/src/Simplex/Messaging/Transport.hs` | +| File encryption | — | `simplexmq/src/Simplex/Messaging/Crypto/File.hs` | diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md new file mode 100644 index 0000000000..d35de1f80a --- /dev/null +++ b/apps/ios/spec/client/chat-list.md @@ -0,0 +1,296 @@ +# SimpleX Chat iOS -- Chat List Module + +> Technical specification for the conversation list, filtering, search, swipe actions, and user picker. +> +> Related specs: [Chat View](chat-view.md) | [Navigation](navigation.md) | [State Management](../state.md) | [README](../README.md) +> Related product: [Chat List View](../../product/views/chat-list.md) + +**Source:** [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatListView](#2-chatlistview) +3. [ChatPreviewView](#3-chatpreviewview) +4. [ChatListNavLink](#4-chatlistnavlink) +5. [Filtering & Tags](#5-filtering--tags) +6. [Search](#6-search) +7. [Swipe Actions](#7-swipe-actions) +8. [UserPicker](#8-userpicker) +9. [Floating Action Button](#9-floating-action-button) + +--- + +## 1. Overview + +The chat list is the main screen of the app, displaying all conversations for the current user. It provides: + +- Conversation previews with unread badges +- Filter tabs (All, Unread, Favorites, Groups, Contacts, Business, user-defined tags) +- Search across chat names and message content +- Swipe actions for quick operations +- User profile switcher +- Floating action button for new conversations + +``` +ChatListView +├── Navigation Bar +│ ├── User avatar (tap → UserPicker) +│ └── Filter tabs (TagListView) +├── Search bar (on pull-down or tap) +├── Chat List (List/LazyVStack) +│ └── ChatListNavLink (per conversation) +│ └── ChatPreviewView +│ ├── Avatar +│ ├── Chat name + last message preview +│ ├── Timestamp +│ └── Unread badge +├── FAB (New Chat button) +└── Pending connection cards +``` + +--- + +## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L142) {#2-chatlistview} + +**File**: `Shared/Views/ChatList/ChatListView.swift` + +The root list view. Key responsibilities: + +### Data Source +- Reads `ChatModel.shared.chats` (all conversations) +- Applies active filter from `ChatTagsModel.shared.activeFilter` +- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) +- Sorts by last activity (most recent first), with pinned chats at top + +### Layout +- Uses SwiftUI `List` with `ForEach` over filtered chats +- Each row is a `ChatListNavLink` wrapping a `ChatPreviewView` +- Pull-to-refresh triggers `updateChats()` API call +- Empty state: `ChatHelp` view with getting-started guidance + +### Connection Cards +- Pending contact connections (`ChatInfo.contactConnection`) shown as cards +- Contact requests (`ChatInfo.contactRequest`) shown with accept/reject UI via `ContactRequestView` + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L168) | 163 | Main view body | +| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) | 472 | Applies active filter and search to chat list | +| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) | 514 | Normalizes search text for comparison | +| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L454) | 448 | Renders unread count circle badge | +| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L474) | 467 | Stops any playing voice message | + +--- + +## 3. [`ChatPreviewView`](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) {#3-chatpreviewview} + +**File**: `Shared/Views/ChatList/ChatPreviewView.swift` + +Renders a single row in the chat list. Shows: + +| Element | Source | Description | +|---------|--------|-------------| +| Avatar | `chatInfo.image` | Profile image or default icon | +| Chat name | `chatInfo.displayName` | Contact name, group name, or connection label | +| Last message | `chat.chatItems.last` | Preview text of most recent message | +| Timestamp | `chat.chatItems.last?.timestampText` | Relative time of last message | +| Unread badge | `chat.chatStats.unreadCount` | Circular badge with unread count | +| Mute icon | `chatInfo.chatSettings?.enableNtfs` | Bell-slash icon if notifications muted | +| Pin icon | -- | Pin indicator for pinned chats | +| Incognito icon | Contact.contactConnIncognito | Incognito mode indicator | +| Delivery status | Last sent item's `meta.itemStatus` | Check marks for delivery confirmation | + +### Preview Text Rendering +- Text messages: first line of message content +- Images: camera icon + caption (if any) +- Files: paperclip icon + filename +- Voice: microphone icon + duration +- Calls: phone icon + call status +- Group events: system event description +- Encrypted/deleted: placeholder text + +--- + +## 4. [`ChatListNavLink`](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) {#4-chatlistnavlink} + +**File**: `Shared/Views/ChatList/ChatListNavLink.swift` + +Wraps `ChatPreviewView` in a navigation link with tap and swipe behavior: + +### Tap Behavior +- Direct chat: navigates to `ChatView` via `ItemsModel.loadOpenChat(chatId)` -- [`contactNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L95) L93 +- Group chat: navigates to `ChatView` -- [`groupNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L217) L214 +- Contact request: shows `ContactRequestView` with accept/reject -- [`contactRequestNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L495) L486 +- Contact connection: shows `ContactConnectionInfo` -- [`contactConnectionNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L530) L520 +- Notes folder: navigates to `ChatView` -- [`noteFolderNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L302) L298 + +### Navigation +- Uses `NavigationLink` (iOS 15) or programmatic navigation (iOS 16+) +- Sets `ChatModel.chatId` to trigger navigation +- `ItemsModel.loadOpenChat()` loads messages with a 250ms navigation delay for smooth animation + +### Channel Adaptations in ChatListNavLink + +When `groupInfo.useRelays == true`: + +| Change | Behavior | +|--------|----------| +| Swipe "Leave" | Hidden when `useRelays && isOwner` | +| Context menu "Leave" | Hidden under same condition | +| `deleteGroupAlert` label | "Delete channel?" | +| `leaveGroupAlert` title | "Leave channel?" | +| `leaveGroupAlert` message | "You will stop receiving messages from this channel. Chat history will be preserved." | + +### ServerSettings + +`ServerSettings` struct (defined in `ChatListView.swift`) includes `serverWarnings: [UserServersWarning]` field, initialized to `[]`. This field stores validation warnings from `validateServers` and is consumed by NetworkAndServers views. + +--- + +## 5. Filtering & Tags + +### Filter Tabs ([`TagListView`](../../Shared/Views/ChatList/TagListView.swift#L20)) + +**File**: `Shared/Views/ChatList/TagListView.swift` + +Horizontal scrolling tab bar below the navigation bar. Tabs: + +| Tab | Filter | Shows | +|-----|--------|-------| +| All | `nil` | All conversations | +| Unread | `.unread` | Conversations with unread messages | +| Favorites | `.presetTag(.favorites)` | Favorited conversations | +| Groups | `.presetTag(.groups)` | Group conversations | +| Contacts | `.presetTag(.contacts)` | Direct conversations | +| Business | `.presetTag(.business)` | Business conversations | +| Group Reports | `.presetTag(.groupReports)` | Groups with pending reports | +| User tags | `.userTag(ChatTag)` | User-defined custom tags | + +Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L910) (L910) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L705) struct (L705). + +### ChatTagsModel State + +Filtering state is managed by [`ChatTagsModel`](../../Shared/Model/ChatModel.swift#L189) (`ChatModel.swift` L183): + +```swift +class ChatTagsModel: ObservableObject { + @Published var userTags: [ChatTag] = [] + @Published var activeFilter: ActiveFilter? = nil + @Published var presetTags: [PresetTag: Int] = [:] // count per preset tag + @Published var unreadTags: [Int64: Int] = [:] // unread count per user tag +} +``` + +- `presetTags` counts are updated whenever `chats` changes via [`updateChatTags()`](../../Shared/Model/ChatModel.swift#L197) (L197) +- Tags with zero matching chats are auto-hidden +- Active filter is auto-cleared when its tag has no matching chats + +### Supporting Types + +| Type | File | Line | Description | +|------|------|------|-------------| +| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 34 | Enum of built-in filter categories | +| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L52) | ChatListView.swift | 49 | Enum wrapping preset, user-tag, or unread filter | +| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L889) | ChatListView.swift | 878 | Applies a filter and persists selection | + +### Tag Management Commands +- `apiCreateChatTag(tag: ChatTagData)` -- create tag +- `apiSetChatTags(type:, id:, tagIds:)` -- assign tags to a chat +- `apiDeleteChatTag(tagId:)` -- delete tag +- `apiUpdateChatTag(tagId:, tagData:)` -- rename tag +- `apiReorderChatTags(tagIds:)` -- reorder tags + +--- + +## 6. Search + +Search is available via pull-down gesture or search button in the navigation bar. + +**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L587) (ChatListView.swift L578) + +### Filtering Logic +- Filters `ChatModel.chats` by matching search text against: + - `chatInfo.displayName` (contact/group name) + - `chatInfo.localAlias` (local alias) + - `chatInfo.fullName` (full name) +- For deeper message content search, uses `apiGetChat(chatId:, search:)` parameter +- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) (L480) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) (L523) + +### Search Results +- Matching chats are displayed in the same list format +- Results update as the user types (debounced) +- Clearing search restores the full filtered list + +--- + +## 7. Swipe Actions + +`ChatListNavLink` provides swipe actions on each row: + +### Leading Swipe (left-to-right) + +| Action | Icon | Handler | Line | API | Condition | +|--------|------|---------|------|-----|-----------| +| Pin / Unpin | pin | [`toggleFavoriteButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L353) | 347 | `apiSetChatSettings` (favorite) | Always | +| Read / Unread | envelope | [`markReadButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L333) | 328 | `apiChatRead` / `apiChatUnread` | Always | + +### Trailing Swipe (right-to-left) + +| Action | Icon | Handler | Line | API | Condition | +|--------|------|---------|------|-----|-----------| +| Mute / Unmute | bell.slash | [`toggleNtfsButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L372) | 365 | `apiSetChatSettings` (enableNtfs) | Always | +| Clear | trash | [`clearChatButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L393) | 385 | `apiClearChat` | Has messages | +| Delete | trash.fill | -- | -- | `apiDeleteChat` | Not active chat | +| Tag | tag | -- | -- | `apiSetChatTags` | Always | + +--- + +## 8. [`UserPicker`](../../Shared/Views/ChatList/UserPicker.swift#L10) {#8-userpicker} + +**File**: `Shared/Views/ChatList/UserPicker.swift` + +Triggered by tapping the user avatar in the navigation bar. Presented as a sheet with: + +| Section | Contents | +|---------|----------| +| User list | All non-hidden users with unread counts | +| Active user | Highlighted with checkmark | +| Actions | Settings, Your SimpleX address, User profiles | + +### User Switching +- Tapping a different user calls `apiSetActiveUser(userId:)` +- Triggers `apiGetChats` for the new user +- `ChatModel.currentUser` updates, causing full UI refresh +- Hidden users are not shown (require password entry via settings) + +--- + +## 9. Floating Action Button + +The FAB (floating action button) in the bottom-right corner opens the new chat flow: + +- Tap: opens `NewChatView` sheet for creating a new contact connection or group +- Shows options: Create link, Scan QR code, Paste link, Create group + +--- + +## Source Files + +| File | Path | Key struct | Line | +|------|------|------------|------| +| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [138](../../Shared/Views/ChatList/ChatListView.swift#L142) | +| Chat preview row | [`ChatPreviewView.swift`](../../Shared/Views/ChatList/ChatPreviewView.swift) | `ChatPreviewView` | [12](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) | +| Navigation link wrapper | [`ChatListNavLink.swift`](../../Shared/Views/ChatList/ChatListNavLink.swift) | `ChatListNavLink` | [43](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) | +| Tag filter tabs | [`TagListView.swift`](../../Shared/Views/ChatList/TagListView.swift) | `TagListView` | [19](../../Shared/Views/ChatList/TagListView.swift#L20) | +| User picker sheet | [`UserPicker.swift`](../../Shared/Views/ChatList/UserPicker.swift) | `UserPicker` | [9](../../Shared/Views/ChatList/UserPicker.swift#L10) | +| Getting started help | [`ChatHelp.swift`](../../Shared/Views/ChatList/ChatHelp.swift) | | | +| Contact request view | [`ContactRequestView.swift`](../../Shared/Views/ChatList/ContactRequestView.swift) | | | +| Contact connection info | [`ContactConnectionInfo.swift`](../../Shared/Views/ChatList/ContactConnectionInfo.swift) | | | +| Contact connection view | [`ContactConnectionView.swift`](../../Shared/Views/ChatList/ContactConnectionView.swift) | | | +| Server summary | [`ServersSummaryView.swift`](../../Shared/Views/ChatList/ServersSummaryView.swift) | | | +| One-hand UI card | [`OneHandUICard.swift`](../../Shared/Views/ChatList/OneHandUICard.swift) | | | diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md new file mode 100644 index 0000000000..afe656ed04 --- /dev/null +++ b/apps/ios/spec/client/chat-view.md @@ -0,0 +1,396 @@ +# SimpleX Chat iOS -- Chat View Module + +> Technical specification for the message rendering, chat item types, and context menu actions in the conversation view. +> +> Related specs: [Compose Module](compose.md) | [State Management](../state.md) | [API Reference](../api.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [`ChatInfoView.swift`](../../Shared/Views/Chat/ChatInfoView.swift) | [`GroupChatInfoView.swift`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift) | [`ChannelMembersView.swift`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) | [`ChannelRelaysView.swift`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatView](#2-chatview) +3. [ChatItemView -- Message Routing](#3-chatitemview) +4. [Message Renderers](#4-message-renderers) +5. [Media Views](#5-media-views) +6. [Metadata & Info](#6-metadata--info) +7. [Context Menu Actions](#7-context-menu-actions) +8. [Selection Mode](#8-selection-mode) + +--- + +## 1. Overview + +The chat view module renders individual conversations. It consists of: + +- **ChatView** -- The main conversation screen with message list, compose bar, and navigation +- **ChatItemView** -- Router that dispatches each chat item to the appropriate renderer +- **Specialized renderers** -- FramedItemView (standard messages), EmojiItemView (emoji-only), CICallItemView (calls), event views, etc. +- **Media views** -- CIImageView, CIVideoView, CIVoiceView, CIFileView for attachments + +``` +ChatView +├── Message List (ScrollView / LazyVStack) +│ ├── ChatItemView (per message) +│ │ ├── FramedItemView (text/media bubbles) +│ │ │ ├── MsgContentView (text with markdown) +│ │ │ ├── CIImageView / CIVideoView / CIVoiceView +│ │ │ └── CIMetaView (timestamp, status) +│ │ ├── EmojiItemView (emoji-only messages) +│ │ ├── CICallItemView (call events) +│ │ ├── CIEventView (system events) +│ │ ├── CIGroupInvitationView (group invitations) +│ │ ├── DeletedItemView / MarkedDeletedItemView +│ │ └── CIInvalidJSONView (decode errors) +│ └── ... (more items) +├── ComposeView (message input) +└── Navigation bar (contact/group info) +``` + +--- + +## [2. ChatView](../../Shared/Views/Chat/ChatView.swift#L18-L3210) + +**File**: [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) + +The main conversation view. Key responsibilities: + +### State +- Uses `ItemsModel.shared.reversedChatItems` for the primary message list +- `ChatModel.shared.chatId` identifies the active conversation +- Manages compose state, scroll position, keyboard visibility +- Tracks selection mode for multi-message actions + +### Message List +- Renders messages in a `ScrollViewReader` with `LazyVStack` +- Items are in reverse chronological order (newest at bottom) +- Supports infinite scroll: preloads older messages when scrolling up via `ItemsModel.preloadState` +- Handles pagination splits (`chatState.splits`) for non-contiguous loaded ranges + +### Navigation Bar +- Title: contact name / group name with connection status indicator +- Trailing button: navigates to [`ChatInfoView`](../../Shared/Views/Chat/ChatInfoView.swift#L93) (direct) or [`GroupChatInfoView`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L16) (group) +- Search button: toggles in-chat message search + +### Scroll Behavior +- Auto-scrolls to bottom on new sent/received messages (if already near bottom) +- "Scroll to bottom" floating button when scrolled up +- `openAroundItemId` support: scrolls to a specific message (e.g., from search or notification) + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/Chat/ChatView.swift#L75) | L75 | Main view body | +| [`initChatView()`](../../Shared/Views/Chat/ChatView.swift#L660) | L660 | Initializes chat view state on appear | +| [`chatItemsList()`](../../Shared/Views/Chat/ChatView.swift#L817) | L817 | Builds the scrollable message list | +| [`scrollToItem(_:)`](../../Shared/Views/Chat/ChatView.swift#L731) | L731 | Scrolls to a specific message by ID | +| [`searchToolbar()`](../../Shared/Views/Chat/ChatView.swift#L765) | L765 | In-chat search toolbar UI | +| [`searchTextChanged(_:)`](../../Shared/Views/Chat/ChatView.swift#L1095) | L1095 | Handles search query changes | +| [`loadChatItems(_:_:)`](../../Shared/Views/Chat/ChatView.swift#L1531) | L1531 | Loads chat items with pagination | +| [`filtered(_:)`](../../Shared/Views/Chat/ChatView.swift#L803) | L803 | Filters items by content type | +| [`callButton(_:_:imageName:)`](../../Shared/Views/Chat/ChatView.swift#L1273) | L1273 | Audio/video call toolbar button | +| [`searchButton()`](../../Shared/Views/Chat/ChatView.swift#L1293) | L1293 | Search toggle toolbar button | +| [`addMembersButton()`](../../Shared/Views/Chat/ChatView.swift#L1361) | L1361 | Group add-members toolbar button | +| [`forwardSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1420) | L1420 | Forwards batch-selected messages | +| [`deletedSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1411) | L1411 | Deletes batch-selected messages | +| [`onChatItemsUpdated()`](../../Shared/Views/Chat/ChatView.swift#L1572) | L1572 | Reacts to chat items model changes | +| [`contentFilterMenu(withLabel:)`](../../Shared/Views/Chat/ChatView.swift#L1301) | L1301 | Content filter dropdown menu | + +### Supporting Types + +| Type | Line | Description | +|------|------|-------------| +| [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) | L1600 | Wraps each chat item with context menu | +| [`FloatingButtonModel`](../../Shared/Views/Chat/ChatView.swift#L2787) | L2787 | Manages scroll-to-bottom button state | +| [`ReactionContextMenu`](../../Shared/Views/Chat/ChatView.swift#L2974) | L2974 | Reaction picker context menu | +| [`ToggleNtfsButton`](../../Shared/Views/Chat/ChatView.swift#L3072) | L3072 | Mute/unmute notifications button | +| [`ContentFilter`](../../Shared/Views/Chat/ChatView.swift#L3124) | L3124 | Enum for message content filter types | +| [`deleteMessages()`](../../Shared/Views/Chat/ChatView.swift#L2870) | L2870 | Deletes messages with confirmation | +| [`archiveReports()`](../../Shared/Views/Chat/ChatView.swift#L2917) | L2917 | Archives report messages | + +--- + +## [3. ChatItemView](../../Shared/Views/Chat/ChatItemView.swift#L42) + +**File**: [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) + +Routes each `ChatItem` to the appropriate renderer based on its `CIContent` type: + +### Content Types (CIContent enum) + +| Content Type | Renderer | Line | Description | +|-------------|----------|------|-------------| +| `sndMsgContent` / `rcvMsgContent` | [`FramedItemView`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | L14 | Standard sent/received text+media message | +| `sndDeleted` / `rcvDeleted` | [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | L14 | Locally deleted message placeholder | +| `sndCall` / `rcvCall` | [`CICallItemView`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | L13 | Call event (missed, ended, duration) | +| `rcvIntegrityError` | [`IntegrityErrorItemView`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | L14 | Message integrity error | +| `rcvDecryptionError` | [`CIRcvDecryptionError`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | L16 | Decryption failure | +| `sndGroupInvitation` / `rcvGroupInvitation` | [`CIGroupInvitationView`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | L14 | Group invite | +| `sndGroupEvent` / `rcvGroupEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L14 | Group system event | +| `rcvConnEvent` / `sndConnEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L14 | Connection event | +| `rcvChatFeature` / `sndChatFeature` | [`CIChatFeatureView`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | L14 | Feature toggle event | +| `rcvChatPreference` / `sndChatPreference` | [`CIFeaturePreferenceView`](../../Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift#L14) | L14 | Preference change | +| `invalidJSON` | [`CIInvalidJSONView`](../../Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift#L14) | L14 | Failed to decode | + +### Bubble Direction +- Sent messages: aligned right, sender-colored bubble +- Received messages: aligned left, receiver-colored bubble +- Events/system messages: centered, no bubble + +### Appearance Dependencies +Each [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) may depend on the previous and next items for visual decisions: +- Whether to show the sender name (group messages, different sender than previous) +- Whether to show the tail on the bubble (last consecutive message from same sender) +- Date separator between messages on different days + +`ChatItemDummyModel.shared.sendUpdate()` forces a re-render of all items when global appearance changes. + +### Channel Message Rendering (`.channelRcv`) + +Channel messages (`CIDirection.channelRcv`) are rendered with the group avatar and group name as sender, with "channel" as the role label. This mirrors the `.groupRcv` path's `showGroupAsSender` visual but uses a dedicated code branch in [`chatItemListView()`](../../Shared/Views/Chat/ChatView.swift#L1846). + +Key differences from `.groupRcv`: +- No `prevMember`/`memCount` logic — channels have no per-member identity +- Always shows group avatar (via `ProfileImage` with `groupInfo.image` / `groupInfo.chatIconName`) +- Tapping avatar opens `showChatInfoSheet` (not member info) +- [`shouldShowAvatar()`](../../Shared/Views/Chat/ChatView.swift#L1670) treats consecutive `.channelRcv` items as same sender +- [`getItemSeparation()`](../../Shared/Views/Chat/ChatView.swift#L1649) treats consecutive `.channelRcv` items as `sameMemberAndDirection` +- [`showMemberImage()`](../../Shared/Views/Chat/ChatView.swift#L2116) returns `true` when previous item is `.channelRcv` (different sender type) +- [`memberToModerate()`](../../SimpleXChat/ChatTypes.swift#L3297) returns `nil` for `.channelRcv` (no per-member moderation) + +--- + +## 4. Message Renderers + +### [FramedItemView](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) + +The standard message bubble. Renders: +- Quote/reply preview (if replying to another message) +- Forwarded indicator +- Sender name (in groups) +- Message content (`MsgContentView` with markdown) +- Attached media (image, video, voice, file, link preview) +- Reaction summary bar +- Metadata line (`CIMetaView`) + +### [EmojiItemView](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) + +Renders emoji-only messages (messages containing only emoji characters) in a larger font without a bubble background. + +### [MsgContentView](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) + +**File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) + +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). + +### DeletedItemView / MarkedDeletedItemView + +**Files**: [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) + +- [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14): Placeholder for locally deleted messages +- [`MarkedDeletedItemView`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14): Shows "message deleted" with optional moderation info (who deleted, when) + +### [CIEventView](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) + +Centered system event text for group events (member joined, left, role changed) and connection events. + +### [CIGroupInvitationView](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) + +Renders group invitation with accept/reject buttons. + +--- + +## 5. Media Views + +### [CIImageView](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) + +Renders inline images. Tapping opens `FullScreenMediaView` for zooming/panning. Images are compressed to `MAX_IMAGE_SIZE` (255KB) before sending. + +### [CIVideoView](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) + +**File**: [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) + +Renders video thumbnails with play button. Tapping opens video player. Videos above auto-receive threshold require manual download. + +### CIVoiceView / FramedCIVoiceView + +**Files**: [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) + +Renders voice messages with waveform visualization, play/pause control, and duration. [`FramedCIVoiceView`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) is the version inside a message bubble with additional context. + +### [CIFileView](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) + +Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download. + +### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) + +Renders link preview cards with OpenGraph metadata (title, description, image). + +### [AnimatedImageView](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) + +**File**: [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) + +Renders animated GIF images. + +### [FullScreenMediaView](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) + +**File**: [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) + +Full-screen media viewer with zoom, pan, and share actions. Supports images and videos. + +--- + +## 6. Metadata & Info + +### [CIMetaView](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) + +Displays message metadata inline at the bottom of the bubble: +- Timestamp (sent time) +- Delivery status icon (sending, sent, delivered, read, error) +- Edit indicator (pencil icon if message was edited) +- Disappearing message timer (if timed message) + +### [ChatItemInfoView](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) + +**File**: [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) + +Detailed message information sheet (accessed via long-press menu "Info"): +- Full delivery history (per-member delivery status in groups) +- Edit history (all previous versions of edited messages) +- Forward chain info +- Message timestamps (created, updated, deleted) + +--- + +## 7. Context Menu Actions + +Long-pressing a message shows a context menu with actions based on message type and ownership: + +| Action | Available For | API Command | +|--------|--------------|-------------| +| Reply | All messages | Sets compose state to `.replying` | +| Forward | Sent/received content messages | `apiForwardChatItems` | +| Copy | Text messages | Copies to clipboard | +| Edit | Own sent messages (within edit window) | `apiUpdateChatItem` | +| Delete for me | All messages | `apiDeleteChatItem(mode: .cidmInternal)` | +| Delete for everyone | Own sent messages | `apiDeleteChatItem(mode: .cidmBroadcast)` | +| Moderate | Group admin/owner for others' messages | `apiDeleteMemberChatItem` | +| React | Content messages (if reactions enabled) | `apiChatItemReaction` | +| Select | All messages | Enters multi-select mode | +| Info | All messages | Opens [`ChatItemInfoView`](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| Save | Media messages | Saves to photo library / files | +| Share | Content messages | iOS share sheet | + +--- + +## 8. Selection Mode + +Multi-selection mode allows batch operations on messages: + +- Enter via long-press "Select" action +- Toggle individual messages with tap +- Toolbar appears with batch actions: Delete, Forward +- Exit via cancel button or completing batch action + +--- + +## GroupChatInfoView — Channel Adaptations + +When `groupInfo.useRelays == true`, [`GroupChatInfoView`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L16) adapts its sections: + +### Section Structure (Channel) + +| Section | Owner | Subscriber | +|---------|-------|-----------| +| 1. Links & Members | Channel link (manage via GroupLinkView), Owners & subscribers | Channel link (read-only QR from `groupProfile.publicGroup?.groupLink`), Owners | +| 2. Profile & Welcome | Edit channel profile, Welcome message | Welcome message (if exists) | +| 3. Theme & TTL | Chat theme, Delete messages after | Chat theme, Delete messages after | +| 4. Actions | Chat relays, Clear chat, Delete channel | Chat relays, Clear chat, Leave channel | + +**Hidden for channels:** Member support, group reports, user support chat, send receipts, inline members list, group preferences. + +### Label Replacements + +All "group" labels are replaced with "channel" equivalents via `groupInfo.useRelays ? "Channel..." :` ternary prepended before existing `businessChat` ternary. Affected: delete/leave buttons, delete/leave alerts, remove member alert, edit profile button, group link nav title. Channel link button uses a separate `channelLinkButton()` with hardcoded "Channel link" label. + +### [`channelMembersButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L627) → [`ChannelMembersView`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) + +Navigates to a dedicated members view with two sections: +- **Owners**: current user (if owner) + members with `memberRole >= .owner` +- **Subscribers** (admin+ only): members with `memberRole < .owner` + +Member rows show profile image, display name (with verified shield), connection status, and role badge. Non-user rows link to `GroupMemberInfoView`. + +### Channel Link + +Owner sees [`channelLinkButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L605) (navigates to `GroupLinkView` for full link management), guarded by `groupInfo.isOwner && groupLink != nil` — channel links can only be created during channel creation, not from the info view. A TODO marks the need for protocol changes to allow other owners to manage the same channel link. Non-owner sees read-only QR code displaying `groupProfile.publicGroup?.groupLink` via `SimpleXLinkQRCode`. `apiGetGroupLink` is skipped in `onAppear` for non-owner channels. + +Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L593) which supports both "Create group link" and "Group link" labels. + +### [`channelRelaysButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L639) → [`ChannelRelaysView`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) + +Navigates to relay list view with role-based branches: +- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). When `relayStatus == .rsRejected` the indicator dot is red and the text reads "rejected", matching the `connFailed`/`removed` rendering. +- **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data). +- **Add relay sheet**: `existingRelayIds` excludes every `chatRelayId` present in `groupRelays` regardless of `relayStatus`, so an already-listed relay (including `.rsInactive` and `.rsRejected`) cannot be re-added from the sheet. This mirrors the backend gate at `APIAddGroupRelays` (`existingRelayIds`), which rejects duplicate `chatRelayId`s; operator must remove the relay first via the swipe action. + +### Relay Rejection Surface + +When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `.memLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`.memRejected` is reserved for the knocking-admission flow). The transition surfaces through `CEvtGroupRelayUpdated`. In `GroupMemberInfoView`, an additional `Status: rejected by relay operator` info row appears when `groupRelay?.relayStatus == .rsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #`, which has no owner-facing event. + +### Leave Button Logic + +Sole channel owner cannot leave (only delete). Guard: `members.filter({ $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }).count > 0`. + +--- + +## Source Files + +| File | Path | Line | +|------|------|------| +| Chat view | [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [L18](../../Shared/Views/Chat/ChatView.swift#L18) | +| Item router | [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) | [L42](../../Shared/Views/Chat/ChatItemView.swift#L42) | +| Framed bubble | [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | +| Emoji message | [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) | +| Image view | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) | +| Video view | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | [L16](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) | +| Voice view | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift#L14) | +| File view | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) | +| Link preview | [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) | +| Call event | [`Shared/Views/Chat/ChatItem/CICallItemView.swift`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | +| Metadata | [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) | +| Message info | [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) | [L13](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| System event | [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | +| Deleted placeholder | [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | +| Moderated placeholder | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14) | +| Text content | [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) | [L28](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) | +| Group invitation | [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | +| Feature event | [`Shared/Views/Chat/ChatItem/CIChatFeatureView.swift`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | +| Decryption error | [`Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift) | [L16](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | +| Integrity error | [`Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | +| Full-screen media | [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) | [L16](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) | +| Animated image | [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) | [L11](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) | +| Framed voice | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) | [L16](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) | +| Member contact | [`Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift`](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift#L14) | +| Channel members | [`Shared/Views/Chat/Group/ChannelMembersView.swift`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) | [L12](../../Shared/Views/Chat/Group/ChannelMembersView.swift#L12) | +| Channel relays | [`Shared/Views/Chat/Group/ChannelRelaysView.swift`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) | [L12](../../Shared/Views/Chat/Group/ChannelRelaysView.swift#L12) | diff --git a/apps/ios/spec/client/compose.md b/apps/ios/spec/client/compose.md new file mode 100644 index 0000000000..f86e323ade --- /dev/null +++ b/apps/ios/spec/client/compose.md @@ -0,0 +1,372 @@ +# SimpleX Chat iOS -- Message Composition Module + +> Technical specification for the compose bar, attachment types, reply/edit/forward modes, voice recording, and mentions. +> +> Related specs: [Chat View](chat-view.md) | [File Transfer](../services/files.md) | [API Reference](../api.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ComposeView](#2-composeview) +3. [ComposeState Machine](#3-composestate-machine) +4. [Attachment Types](#4-attachment-types) +5. [Reply Mode](#5-reply-mode) +6. [Edit Mode](#6-edit-mode) +7. [Forward Mode](#7-forward-mode) +8. [Live Messages](#8-live-messages) +9. [Voice Recording](#9-voice-recording) +10. [Link Previews](#10-link-previews) +11. [Mentions](#11-mentions) + +--- + +## 1. Overview + +The compose module handles all message creation, editing, and forwarding. It sits at the bottom of `ChatView` and adapts its UI based on the current compose state. + +``` +ComposeView +├── Context banner (reply quote / edit indicator / forward indicator) +├── Attachment preview (image / video / file / voice waveform) +├── Text input (NativeTextEditor with markdown support) +├── Action buttons +│ ├── Attachment menu (camera, photo library, file picker) +│ ├── Voice record button (hold or toggle) +│ └── Send button (or live message indicator) +└── Link preview (auto-generated when URL detected) +``` + +--- + +## 2. [ComposeView](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) (`struct ComposeView: View`) + +**File**: `Shared/Views/Chat/ComposeMessage/ComposeView.swift` + +### Layout +- Fixed at the bottom of ChatView +- Expands vertically as text input grows (up to a maximum height) +- Context banner appears above the text field when in reply/edit/forward mode +- Attachment preview appears between context banner and text field + +### Key Properties +- Reads `ChatModel.shared.draft` / `draftChatId` for persisted drafts +- Manages its own internal compose state +- Coordinates with `ChatView` for scroll-to-bottom behavior on send + +### Send Flow +1. User taps send button +2. ComposeView constructs `[ComposedMessage]` from current state +3. Calls `apiSendMessages(type:, id:, scope:, live:, ttl:, composedMessages:)` +4. On success: clears compose state, scrolls to bottom +5. On failure: shows error alert, preserves compose state + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L371) | L371 | Main view body | +| [`sendMessageView()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L870) | L870 | Builds the send-message UI | +| [`sendMessage(ttl:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1286) | L1286 | Entry point: initiates send | +| [`sendMessageAsync()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1295) | L1295 | Async send implementation | +| [`clearState(live:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1649) | L1649 | Resets compose state after send | +| [`addMediaContent()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1073) | L1073 | Adds media attachment | +| [`connectCheckLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1046) | L1046 | Checks link preview before connect | +| [`commandsButton()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L931) | L931 | Builds commands menu button | + +### Draft Persistence + +| Function | Line | Description | +|----------|------|-------------| +| [`saveCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1663) | L1663 | Saves compose state to `ChatModel.draft` | +| [`clearCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1669) | L1669 | Clears persisted draft | + +- When navigating away from a chat, compose state is saved to `ChatModel.draft` / `ChatModel.draftChatId` +- When returning to the same chat, draft is restored +- Drafts are not persisted across app restarts + +--- + +## 3. [ComposeState](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) Machine (`struct ComposeState`) + +The compose bar operates as a state machine with these primary states: + +``` + ┌──────────┐ + │ .empty │ ← initial / after send + └─────┬────┘ + │ user types / attaches / quotes + v + ┌─────────────────────────────────────┐ + │ │ + ┌────▼────┐ ┌──────────────┐ ┌──────────▼───┐ + │ .text │ │ .mediaPending │ │ .voiceRecording │ + └─────────┘ └──────────────┘ └───────────────┘ + │ │ + │ long-press reply│ tap edit + v v + ┌──────────┐ ┌──────────┐ ┌───────────┐ + │ .replying │ │ .editing │ │ .forwarding│ + └──────────┘ └──────────┘ └───────────┘ +``` + +### Supporting Types + +| Type | Line | Description | +|------|------|-------------| +| [`enum ComposePreview`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L11) | L11 | Preview variants (image, voice, file, etc.) | +| [`enum ComposeContextItem`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L20) | L20 | Context item for reply/quote | +| [`enum VoiceMessageRecordingState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L29) | L29 | Recording state enum | +| [`struct ComposeState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) | L45 | Full compose state struct | +| [`copy()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L98) | L98 | Copy compose state with overrides | +| [`mentionMemberName()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L118) | L118 | Format mention display name | +| [`chatItemPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L266) | L266 | Build preview from chat item | +| [`enum UploadContent`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L287) | L287 | Upload content variants | + +### States + +| State | Description | UI | +|-------|-------------|-----| +| `.empty` | No input, no attachments | Placeholder text, attachment button | +| `.text` | Text entered, no attachments | Send button visible | +| `.mediaPending` | Media/file selected, optionally with text | Preview visible, send button | +| `.voiceRecording` | Voice recording in progress | Waveform animation, stop/send | +| `.replying` | Replying to a specific message | Quote banner above input | +| `.editing` | Editing a previously sent message | Edit banner, pre-filled text | +| `.forwarding` | Forwarding selected messages | Forward banner, item previews | + +### Transitions + +| From | Trigger | To | +|------|---------|-----| +| `.empty` | User types text | `.text` | +| `.empty` | User selects media | `.mediaPending` | +| `.empty` | User holds voice button | `.voiceRecording` | +| `.empty` | User long-presses message "Reply" | `.replying` | +| `.empty` | User long-presses message "Edit" | `.editing` | +| `.empty` | User selects "Forward" | `.forwarding` | +| Any | User taps send | `.empty` | +| Any | User taps cancel (X) | `.empty` | + +--- + +## 4. Attachment Types + +### [ComposeImageView](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) + +**File**: [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) (struct at L12) + +Preview of selected image(s) before sending. Shows thumbnail with remove button. Images are compressed to `MAX_IMAGE_SIZE` (255KB) before sending. + +### [ComposeFileView](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) + +**File**: [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) (struct at L11) + +Preview of selected file or video. Shows filename, size, and remove button. Videos show a thumbnail frame. + +### [ComposeVoiceView](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) + +**File**: [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) (struct at L26) + +Voice message recording/playback preview. Shows waveform visualization, duration, and play/delete buttons. + +### Attachment Menu Options + +| Option | Picker | Max Size | Transfer Method | +|--------|--------|----------|-----------------| +| Camera photo | UIImagePickerController | Compressed to 255KB | Inline in SMP message | +| Photo library | PHPickerViewController | Compressed to 255KB | Inline or XFTP | +| Video | PHPickerViewController | Up to 1GB | XFTP | +| File | UIDocumentPickerViewController | Up to 1GB | XFTP | + +--- + +## 5. Reply Mode + +Activated via long-press context menu "Reply" on any message. + +### UI +- Quote banner above text input showing original message preview +- X button to cancel reply +- Original message reference stored in compose state + +### API +- Reply is sent as part of `ComposedMessage` with `quotedItemId` parameter +- `apiSendMessages(composedMessages: [ComposedMessage(quotedItemId: originalItem.id, ...)])` + +--- + +## 6. Edit Mode + +Activated via long-press context menu "Edit" on own sent messages (within the edit window). + +### UI +- Edit banner above text input with pencil icon +- Text field pre-filled with original message content +- Send button changes to "Save" / checkmark + +### API +- `apiUpdateChatItem(type:, id:, scope:, itemId:, updatedMessage:, live:)` +- Response: `ChatResponse1.chatItemUpdated(user:, chatItem:)` + +### Constraints +- Only own sent messages can be edited +- Edit is available within a server-defined time window +- Edited messages show a pencil indicator in `CIMetaView` +- Edit history is visible in `ChatItemInfoView` + +--- + +## 7. Forward Mode + +Activated via long-press context menu "Forward" or via multi-select toolbar. + +### Flow +1. User selects "Forward" on message(s) +2. `apiPlanForwardChatItems(fromChatType:, fromChatId:, fromScope:, itemIds:)` is called to plan +3. Response: `ChatResponse1.forwardPlan(user:, chatItemIds:, forwardConfirmation:)` +4. User selects destination chat +5. `apiForwardChatItems(toChatType:, toChatId:, toScope:, fromChatType:, fromChatId:, fromScope:, itemIds:, ttl:)` executes the forward +6. Forwarded messages appear with a forwarded indicator + +### ForwardConfirmation +The plan response may include a `forwardConfirmation` requiring user confirmation (e.g., forwarding to a less secure chat). + +--- + +## 8. [Live Messages](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L36) (`struct LiveMessage`) + +Optional feature where the recipient sees typing in real-time. + +### How It Works +- User enables live message mode (lightning icon) +- As user types, `apiSendMessages(live: true)` is called repeatedly +- Each call sends the current text as an update to the same message +- Recipient sees the message being composed in real-time +- Final send marks the message as complete + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`sendLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1102) | L1102 | Initiates a live message | +| [`updateLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1120) | L1120 | Sends incremental live update | +| [`liveMessageToSend()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1139) | L1139 | Determines text diff to send | +| [`truncateToWords()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1144) | L1144 | Truncates text at word boundary | + +### API +- Initial: `apiSendMessages(live: true, composedMessages: [...])` -- creates live message +- Updates: `apiUpdateChatItem(live: true)` -- updates content as user types +- Final: `apiUpdateChatItem(live: false)` -- marks as complete + +--- + +## 9. Voice Recording + +### Recording Flow +1. User taps (or holds) the microphone button +2. `AVAudioRecorder` starts recording in compressed format +3. Waveform visualization shows real-time audio levels +4. User taps stop (or releases hold) to finish recording +5. Preview with playback shown in compose area +6. User taps send to deliver + +### Voice Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`startVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1564) | L1564 | Begins audio recording | +| [`finishVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1605) | L1605 | Stops recording, shows preview | +| [`allowVoiceMessagesToContact()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1616) | L1616 | Enables voice messages for contact | +| [`updateComposeVMRFinished()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1623) | L1623 | Updates state after recording finishes | +| [`cancelCurrentVoiceRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1635) | L1635 | Cancels in-progress recording | +| [`cancelVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1642) | L1642 | Cancels and cleans up recording file | + +### Constraints +- Maximum duration: `MAX_VOICE_MESSAGE_LENGTH = 300` seconds (5 minutes) +- Auto-receive threshold: `MAX_VOICE_SIZE_AUTO_RCV = 522,240` bytes (510KB) +- Compressed audio format for small file sizes + +### Audio Management +- [`AudioRecorder`](../../Shared/Model/AudioRecPlay.swift#L14) (`Shared/Model/AudioRecPlay.swift` L14) manages recording and playback +- `ChatModel.stopPreviousRecPlay` coordinates exclusive audio playback (only one audio source plays at a time) + +--- + +## 10. [Link Previews](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift#L13) (`ComposeLinkView`) + +**File**: [`ComposeLinkView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift) (struct at L13) + +### Auto-Detection +- As user types, URLs in the text are detected +- When a URL is found, `ComposeLinkView` fetches OpenGraph metadata +- Preview card shows title, description, and thumbnail image + +### Link Preview Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`showLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1677) | L1677 | Triggers link preview loading | +| [`getMessageLinks()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1697) | L1697 | Extracts URLs from formatted text | +| [`isSimplexLink()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1708) | L1708 | Checks if URL is a SimpleX link | +| [`cancelLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1712) | L1712 | Cancels pending preview | +| [`loadLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1724) | L1724 | Fetches OpenGraph metadata | +| [`resetLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1741) | L1741 | Resets preview state | + +### Behavior +- Only the first URL in the message generates a preview +- Preview can be dismissed by the user +- Link preview data is included in the `ComposedMessage` sent to the core +- Toggle in privacy settings to disable auto-preview generation + +--- + +## 11. Mentions + +In group chats, typing `@` triggers member name autocomplete: + +### Flow +1. User types `@` in the text field +2. Autocomplete dropdown appears with matching group members +3. User selects a member +4. `@displayName` is inserted into the text +5. Mention is rendered with special formatting in the sent message + +### Data +- Group members loaded from `ChatModel.groupMembers` +- Mention metadata included in `ComposedMessage` + +--- + +## Channel Compose Behavior + +When `chat.chatInfo.groupInfo?.useRelays == true` (channel mode), compose behaves differently: + +### Owner/Admin Compose +- [`send()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1498) passes `sendAsGroup: true` to `apiSendMessages` when `useRelays && memberRole >= .owner` +- [`forwardItems()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1526) passes `sendAsGroup: true` to `apiForwardChatItems` under same condition +- Placeholder text shows "Broadcast" instead of "Message" (via `sendMessageView()` `placeholder:` parameter) +- Share Extension ([`ShareAPI.swift`](../../SimpleX%20SE/ShareAPI.swift#L71)) uses the same `sendAsGroup` expression + +### Subscriber Compose +- [`userCantSendReason`](../../SimpleXChat/ChatTypes.swift#L1566) returns `("you are subscriber", nil)` when `useRelays && memberRole == .observer` +- This check is evaluated after `memberPending` (which takes priority) but replaces the `observer` message +- Compose field is disabled; tapping shows "You can't send messages!" alert with no body text + +--- + +## Source Files + +| File | Path | Struct/Class | Line | +|------|------|--------------|------| +| Compose view | [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) | `ComposeView` | [L329](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) | +| Send message UI | [`SendMessageView.swift`](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift) | `SendMessageView` | [L15](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift#L15) | +| Image preview | [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | `ComposeImageView` | [L12](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) | +| File preview | [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | `ComposeFileView` | [L11](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) | +| Voice preview | [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | `ComposeVoiceView` | [L26](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) | +| Link preview | [`ComposeLinkView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift) | `ComposeLinkView` | [L13](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift#L13) | +| Audio recording | [`AudioRecPlay.swift`](../../Shared/Model/AudioRecPlay.swift) | `AudioRecorder` | [L14](../../Shared/Model/AudioRecPlay.swift#L14) | diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md new file mode 100644 index 0000000000..22985c6fe1 --- /dev/null +++ b/apps/ios/spec/client/navigation.md @@ -0,0 +1,380 @@ +# SimpleX Chat iOS -- Navigation Architecture + +> Technical specification for the navigation stack, deep linking, sheet presentation, and call overlay. +> +> Related specs: [Chat List](chat-list.md) | [Chat View](chat-view.md) | [State Management](../state.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`ContentView.swift`](../../Shared/ContentView.swift) | [`NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | [`SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | [`OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | [`UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Root View -- ContentView](#2-root-view) +3. [Navigation Stack](#3-navigation-stack) +4. [Sheet Presentation](#4-sheet-presentation) +5. [Deep Linking](#5-deep-linking) +6. [Call Overlay](#6-call-overlay) +7. [Authentication Gate](#7-authentication-gate) +8. [Onboarding Flow](#8-onboarding-flow) + +--- + +## 1. Overview + +The app's navigation follows a hierarchical model with a single navigation stack rooted in `ContentView`. Modal sheets and full-screen overlays augment the primary navigation path. + +``` +SimpleXApp +└── ContentView (root) + ├── Authentication gate (LocalAuthView / SetAppPasscodeView) + ├── Onboarding flow (if first launch / migration) + ├── Main content + │ └── NavigationStack / NavigationView + │ ├── ChatListView (root of stack) + │ │ ├── ChatView (pushed) + │ │ │ ├── ChatInfoView / GroupChatInfoView (pushed) + │ │ │ └── ChatItemInfoView (pushed) + │ │ └── ContactConnectionInfo (pushed) + │ └── Settings views (pushed) + ├── Sheets (modal) + │ ├── UserPicker + │ ├── NewChatView + │ ├── WhatsNew / Notices + │ └── Settings sub-views + └── Overlays (always on top) + ├── Active call banner (when call active) + └── ActiveCallView (full-screen call) +``` + +--- + +## 2. Root View -- [`ContentView`](../../Shared/ContentView.swift#L24) + +**File**: [`Shared/ContentView.swift`](../../Shared/ContentView.swift) + +`ContentView` is the root view injected by `SimpleXApp`. It manages: + +### [Environment](../../Shared/ContentView.swift#L25-L37) +- `@EnvironmentObject var chatModel: ChatModel` +- `@EnvironmentObject var theme: AppTheme` +- `@Environment(\.scenePhase) var scenePhase` + +### [Key State](../../Shared/ContentView.swift#L35-L52) +| Property | Type | Purpose | +|----------|------|---------| +| [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) | `Bool` | Passed at init to avoid re-render timing issues | +| [`automaticAuthenticationAttempted`](../../Shared/ContentView.swift#L38) | `Bool` | Whether biometric auth was auto-attempted | +| [`waitingForOrPassedAuth`](../../Shared/ContentView.swift#L51) | `Bool` | Whether auth gate should show | +| [`chatListUserPickerSheet`](../../Shared/ContentView.swift#L52) | `UserPickerSheet?` | Active user picker sheet | + +### [View Selection Logic](../../Shared/ContentView.swift#L60-L80) + +```swift +// Simplified decision tree in ContentView.body: +if !prefPerformLA || accessAuthenticated { + contentView() // Main app content +} else { + lockButton() // Authentication required +} +``` + +The [`contentView()`](../../Shared/ContentView.swift#L169) function further decides: +- If `chatModel.onboardingStage != .onboardingComplete`: show [onboarding](../../Shared/ContentView.swift#L174) +- If `chatModel.migrationState != nil`: show migration UI +- Otherwise: show `ChatListView` in a navigation container + +--- + +## 3. Navigation Stack + +### iOS Version Compatibility + +**File**: [`Shared/Views/Helpers/NavStackCompat.swift`](../../Shared/Views/Helpers/NavStackCompat.swift) + +The app supports iOS 15+ and uses a compatibility wrapper ([`NavStackCompat`](../../Shared/Views/Helpers/NavStackCompat.swift#L11)): + +```swift +// NavStackCompat provides: +// - NavigationStack (iOS 16+): programmatic navigation via NavigationPath +// - NavigationView (iOS 15): classic NavigationLink-based navigation +``` + +### Primary Navigation Path + +``` +ChatListView + │ + ├─[tap chat]─→ ChatView + │ │ + │ ├─[tap info]─→ ChatInfoView (direct) + │ │ └─→ VerifyCodeView, etc. + │ │ + │ ├─[tap info]─→ GroupChatInfoView (group) + │ │ ├─→ GroupMemberInfoView + │ │ ├─→ GroupProfileView + │ │ └─→ GroupLinkView + │ │ + │ └─[tap message info]─→ ChatItemInfoView + │ + ├─[tap connection]─→ ContactConnectionInfo + │ + └─[settings]─→ SettingsView + ├─→ NotificationsView + ├─→ NetworkAndServers + ├─→ AppearanceSettings + ├─→ PrivacySettings + ├─→ DatabaseView + └─→ UserProfilesView +``` + +### Navigation Trigger + +Chat navigation is triggered by setting `ChatModel.chatId`: + +```swift +// In ChatListNavLink: +ItemsModel.shared.loadOpenChat(chatId) { + // This sets ChatModel.chatId = chatId after a 250ms delay + // allowing navigation animation to start smoothly +} +``` + +--- + +## 4. Sheet Presentation + +Sheets are presented modally on top of the navigation stack: + +| Sheet | Trigger | Content | +|-------|---------|---------| +| UserPicker | Tap user avatar in nav bar | User list, settings shortcuts | +| [`NewChatView`](../../Shared/Views/NewChat/NewChatView.swift#L78) | Tap FAB / "+" button | Create link, scan QR, paste link, new group | +| WhatsNew | App update detected | Release notes | +| AddGroupView | "New Group" action | Group creation wizard | +| ConnectDesktopView | Settings > Desktop | Remote desktop pairing | +| MigrateFromDevice | Settings > Migration | Device export | +| MigrateToDevice | Onboarding migration | Device import | +| [LocalAuthView](../../Shared/ContentView.swift#L95) | App foreground after background | Biometric/passcode auth | + +### Sheet Management + +Sheets use SwiftUI `.sheet(item:)` or `.sheet(isPresented:)` modifiers on `ContentView` and `ChatListView`. Some sheets use the centralized [`AppSheetState.shared`](../../Shared/ContentView.swift#L29) observable for coordination: + +```swift +class AppSheetState: ObservableObject { + static let shared = AppSheetState() + var scenePhaseActive: Bool = false + // ... sheet state coordination +} +``` + +--- + +## 5. Deep Linking + +### Notification Deep Link + +When the user taps a notification: + +1. `NtfManager.processNotificationResponse()` extracts the `chatId` from notification payload +2. If a different user: calls `changeActiveUser(userId:)` +3. Sets `ChatModel.chatId = chatId` to navigate to the conversation +4. If the app was in background: the notification response is stored in `ChatModel.notificationResponse` and processed when the app becomes active + +### [URL Deep Link](../../Shared/ContentView.swift#L281) + +SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Shared/ContentView.swift#L439): + +```swift +.onOpenURL { url in + if AppChatState.shared.value == .active { + chatModel.appOpenUrl = url // Process immediately + } else { + chatModel.appOpenUrlLater = url // Process when active + } +} +``` + +URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181). + +### Call Deep Link + +Call invitations from notifications: +1. `NtfManager` detects `ntfActionAcceptCall` action +2. Sets `ChatModel.ntfCallInvitationAction = (chatId, .accept)` +3. `ContentView` picks up the pending action and initiates the call + +--- + +## 6. Call Overlay + +The call UI overlays the entire app when a call is active: + +### [Call Banner](../../Shared/ContentView.swift#L203) + +When `ChatModel.activeCall != nil` and call is in connecting/active state: +- A banner appears at the top of ContentView (height: [`callTopPadding = 40`](../../Shared/ContentView.swift#L54)) +- Shows contact name, call duration, tap to return to full-screen call +- Main content is padded down to accommodate the banner + +### [Full-Screen Call View](../../Shared/ContentView.swift#L185) + +When `ChatModel.showCallView == true`: +- `ActiveCallView` covers the entire screen as a ZStack overlay +- Contains local/remote video, controls (mute, camera, speaker, end) +- PiP mode: `ChatModel.activeCallViewIsCollapsed` collapses to mini view +- Call view is always rendered on top of navigation and sheets + +```swift +// In ContentView.allViews(): +ZStack { + contentView() + .padding(.top, showCallArea ? callTopPadding : 0) + + if showCallArea, let call = chatModel.activeCall { + VStack { + activeCallInteractiveArea(call) + Spacer() + } + } + + if chatModel.showCallView, let call = chatModel.activeCall { + callView(call) // Full screen overlay + } +} +``` + +--- + +## 7. Authentication Gate + +### [Local Authentication](../../Shared/ContentView.swift#L359) + +When [`DEFAULT_PERFORM_LA`](../../Shared/ContentView.swift#L44) is enabled: + +1. App enters background: `chatModel.contentViewAccessAuthenticated = false` +2. App returns to foreground: `ContentView` shows [`lockButton()`](../../Shared/ContentView.swift#L238) instead of content +3. User taps lock button: [`LocalAuthView`](../../Shared/ContentView.swift#L95) presented +4. On successful auth: `chatModel.contentViewAccessAuthenticated = true`, content revealed + +### Authentication Methods +- Face ID / Touch ID (via `LocalAuthentication` framework) +- Custom numeric passcode +- Custom alphanumeric passcode + +### [Extended Authentication](../../Shared/ContentView.swift#L351) +- After successful auth, a grace period prevents re-auth for brief background/foreground cycles ([`unlockedRecently()`](../../Shared/ContentView.swift#L351)) +- [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) is computed at `ContentView.init` to avoid render-time race conditions +- The `enteredBackgroundAuthenticated` timestamp tracks when the app was last authenticated in background + +--- + +## 8. [Onboarding Flow](../../Shared/Views/Onboarding/OnboardingView.swift#L13) + +First-launch experience controlled by [`ChatModel.onboardingStage`](../../Shared/Views/Onboarding/OnboardingView.swift#L46): + +```swift +enum OnboardingStage: String, Identifiable { + case step1_SimpleXInfo // Welcome screen + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators // Choose server operators + case step4_SetNotificationsMode // Set notification preferences + case onboardingComplete // Normal operation +} +``` + +Each stage is a dedicated view presented in place of `ChatListView` within [`ContentView`](../../Shared/ContentView.swift#L174). + +Migration state (`ChatModel.migrationState != nil`) takes precedence over onboarding. + +--- + +## 9. Channel Creation Flow (`AddChannelView`) + +**Source:** [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift) + +### Entry Point + +`NewChatMenuButton` includes a NavigationLink "Create channel (BETA)" with antenna icon, navigating to `AddChannelView`. + +### Three-Step Wizard + +| Step | Function | Description | +|------|----------|-------------| +| 1. Profile | `profileStepView()` | Channel name input (`channelNameTextField()`), profile image picker. "Configure relays" link to `NetworkAndServers`. Validates via `canCreateProfile()` (non-empty + valid display name) and `checkHasRelays()`. | +| 2. Progress | `progressStepView(_:)` | Relay connection progress with `RelayProgressIndicator` (circular active/total or spinner). Expandable relay list with `relayStatusIndicator(_:)` (green/red/orange dots). Cancel via `cancelChannelCreation(_:)` which calls `apiDeleteChat`. | +| 3. Link | `linkStepView(_:)` | Wraps `GroupLinkView(isChannel: true)` for channel link sharing. | + +### Key Functions + +| Function | Scope | Description | +|----------|-------|-------------| +| `createChannel()` | private | Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)`, sets `ChannelRelaysModel` | +| `getEnabledRelays()` | private | Filters enabled/non-deleted relays, selects random 3 | +| `checkHasRelays()` | private | Validates at least one relay exists | +| `relayDisplayName(_:)` | module | name > domain > link host > fallback | +| `relayStatusIndicator(_:)` | module | Green/red/orange dot + status text | +| `RelayProgressIndicator` | module | Circular progress (active/total) or spinner | + +## 10. Relay URL Interception + +**Source:** [`Shared/ContentView.swift`](../../Shared/ContentView.swift#L454) + +In `connectViaUrl_()`, relay address links (URL path `/r`) are intercepted before processing: + +```swift +if path == "/r" { + showAlert(NSLocalizedString("Relay address", ...), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", ...)) + return +} +``` + +Similarly, in `planAndConnect()` (`NewChatView.swift`), `.simplexLink(_, .relay, _, _)` patterns trigger the same alert and block connection. + +## 11. Channel-Specific NewChatView Behavior + +**Source:** [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) + +### Prepared Group Alert (`showPrepareGroupAlert`) + +When `groupShortLinkInfo?.direct == false` (channel relay link), the prepare alert uses: +- Channel icon: `antenna.radiowaves.left.and.right.circle.fill` +- Title: "Open new channel" +- Error: "Error opening channel" +- `apiPrepareGroup` call passes `directLink: false` +- Stores `groupShortLinkInfo.groupRelays` in `ChatModel.shared.channelRelayHostnames` + +### Own Link Confirmation (`showOwnGroupLinkConfirmConnectSheet`) + +For channels: shows "This is your link for channel" with only "Open channel" + "Cancel" buttons. No incognito or profile selection options. + +### Known Group Alert (`showOpenKnownGroupAlert`) + +For channels (`groupInfo.useRelays`): titles become "Open channel" / "Open new channel". + +--- + +## Source Files + +| File | Path | +|------|------| +| Root view | [`Shared/ContentView.swift`](../../Shared/ContentView.swift) | +| App entry point | `Shared/SimpleXApp.swift` | +| Navigation compat | [`Shared/Views/Helpers/NavStackCompat.swift`](../../Shared/Views/Helpers/NavStackCompat.swift) | +| Chat list (nav root) | `Shared/Views/ChatList/ChatListView.swift` | +| Nav link wrapper | `Shared/Views/ChatList/ChatListNavLink.swift` | +| User picker | `Shared/Views/ChatList/UserPicker.swift` | +| New chat view | [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | +| Channel creation | [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift) | +| New chat menu | [`Shared/Views/NewChat/NewChatMenuButton.swift`](../../Shared/Views/NewChat/NewChatMenuButton.swift) | +| Settings view | [`Shared/Views/UserSettings/SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | +| User profiles | [`Shared/Views/UserSettings/UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) | +| Onboarding view | [`Shared/Views/Onboarding/OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | +| Active call view | `Shared/Views/Call/ActiveCallView.swift` | +| Local auth view | `Shared/Views/LocalAuth/LocalAuthView.swift` | +| Notification manager | `Shared/Model/NtfManager.swift` | diff --git a/apps/ios/spec/database.md b/apps/ios/spec/database.md new file mode 100644 index 0000000000..9e5adfcb64 --- /dev/null +++ b/apps/ios/spec/database.md @@ -0,0 +1,298 @@ +# SimpleX Chat iOS -- Database & Storage + +**Source:** [`FileUtils.swift`](../SimpleXChat/FileUtils.swift) + +> Technical specification for the database architecture, encryption, file storage, and export/import functionality. +> +> Related specs: [Architecture](architecture.md) | [State Management](state.md) | [README](README.md) +> Related product: [Product Overview](../product/README.md) + +--- + +## Table of Contents + +1. [Database Overview](#1-database-overview) +2. [Database Files & Paths](#2-database-files--paths) +3. [Haskell Store Modules](#3-haskell-store-modules) +4. [Migrations](#4-migrations) +5. [Database Encryption](#5-database-encryption) +6. [File Storage](#6-file-storage) +7. [Export & Import](#7-export--import) +8. [App Group Sharing](#8-app-group-sharing) + +--- + +## 1. Database Overview + +SimpleX Chat uses two SQLite databases managed entirely by the Haskell core. The iOS Swift layer never reads or writes directly to the databases -- all data access goes through the FFI command/response API. + +| Database | Suffix | Contents | +|----------|--------|----------| +| Chat DB | `_chat.db` | Messages, contacts, groups, user profiles, files, tags, preferences, call history | +| Agent DB | `_agent.db` | SMP agent connections, cryptographic keys, message queues, server state, XFTP chunks | + +Both databases are initialized and migrated via the C FFI function `chat_migrate_init_key()`, which applies pending migrations and returns a `chat_ctrl` pointer. + +--- + +## 2. Database Files & Paths + +### [Path Resolution](../SimpleXChat/FileUtils.swift#L63-L73) (FileUtils.swift) + +```swift +let DB_FILE_PREFIX = "simplex_v1" + +// Database path depends on container preference +func getAppDatabasePath() -> URL { + dbContainerGroupDefault.get() == .group + ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX) + : getLegacyDatabasePath() +} + +// Full database file paths: +// Chat: {container}/simplex_v1_chat.db +// Agent: {container}/simplex_v1_agent.db +``` + +### [File Constants](../SimpleXChat/FileUtils.swift#L38-L44) + +```swift +let CHAT_DB: String = "_chat.db" +let AGENT_DB: String = "_agent.db" +private let CHAT_DB_BAK: String = "_chat.db.bak" +private let AGENT_DB_BAK: String = "_agent.db.bak" +``` + +### Container Locations + +See [`getDocumentsDirectory()`](../SimpleXChat/FileUtils.swift#L47) and [`getGroupContainerDirectory()`](../SimpleXChat/FileUtils.swift#L52). + +| Container | Path | Used When | +|-----------|------|-----------| +| App Group | `FileManager.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)` | Default (shared with NSE) | +| Documents | `FileManager.urls(for: .documentDirectory)` | Legacy installations | + +The container choice is stored in `dbContainerGroupDefault` (`GroupDefaults`). + +--- + +## 3. Haskell Store Modules + +All database operations are implemented in Haskell. Key store modules (paths relative to repo root): + +| Module | Path | Size | Description | +|--------|------|------|-------------| +| Messages | `src/Simplex/Chat/Store/Messages.hs` | ~178KB | Message CRUD, pagination, search, reactions, delivery receipts | +| Groups | `src/Simplex/Chat/Store/Groups.hs` | ~126KB | Group CRUD, member management, roles, links, invitations | +| Direct | `src/Simplex/Chat/Store/Direct.hs` | ~52KB | Direct contact connections, contact requests. See `createDirectChat` in `Store/Direct.hs` | +| Files | `src/Simplex/Chat/Store/Files.hs` | ~43KB | File transfer state, XFTP chunks, inline files | +| Profiles | `src/Simplex/Chat/Store/Profiles.hs` | ~42KB | User profiles, contact profiles, incognito profiles | +| Connections | `src/Simplex/Chat/Store/Connections.hs` | ~17KB | Connection lifecycle, queue management | + +### Data Model (key tables) + +``` +users -- User profiles (userId, displayName, fullName, image, ...) +contacts -- Contact records (contactId, userId, localDisplayName, ...) +groups -- Group records (groupId, userId, groupProfile, ...) +group_members -- Group membership (groupMemberId, groupId, memberId, role, ...) +messages -- Message records (messageId, chatItemId, msgBody, ...) +chat_items -- Chat items (chatItemId, chatType, chatId, content, ...) +files -- File transfer records (fileId, chatItemId, fileName, fileSize, ...) +connections -- SMP connections (connId, agentConnId, ...) +chat_tags -- User-defined chat tags +chat_tags_chats -- Tag-to-chat assignments +``` + +--- + +## 4. Migrations + +Database migrations are managed by the Haskell core. Migration files are located in: + +``` +src/Simplex/Chat/Store/SQLite/Migrations/ +``` + +Migrations are numbered sequentially starting from `M20220101` through `M20260122` (200+ migrations). Each migration is a Haskell module containing SQL statements for schema changes. + +The migration process: +1. `chat_migrate_init_key()` is called with the database path +2. Haskell reads the current schema version from the database +3. Pending migrations are applied in order +4. If migration fails, the function returns an error string (not a `chat_ctrl`) +5. On success, a `chat_ctrl` pointer is returned + +Migration results are decoded in Swift as `DBMigrationResult`: +- `.ok` -- migrations applied successfully +- `.invalidConfirmation` -- migration requires user confirmation +- `.errorNotADatabase(dbFile:)` -- file is not a valid SQLite database +- `.errorMigration(dbFile:, migrationError:)` -- migration failed +- `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration +- `.errorKeychain` -- keychain access failed +- `.unknown(json:)` -- unrecognized response + +--- + +## 5. Database Encryption + +### Encryption Configuration + +Database encryption uses SQLCipher (AES-256) and is managed through the API: + +```swift +// Set or change encryption +ChatCommand.apiStorageEncryption(config: DBEncryptionConfig) + +// Test if a key is correct +ChatCommand.testStorageEncryption(key: String) +``` + +`DBEncryptionConfig` contains: +- `currentKey: String` -- current encryption key (empty if unencrypted) +- `newKey: String` -- new encryption key (empty to decrypt) + +### Key Storage + +The encryption key is stored in the iOS Keychain via `kcDatabasePassword`: +- On first launch with encryption, the key is generated and stored +- The `storeDBPassphraseGroupDefault` flag controls whether the key is auto-stored +- If the user opts out of auto-storage, they must enter the key on each launch + +### UI + +- [`DatabaseEncryptionView.swift`](../Shared/Views/Database/DatabaseEncryptionView.swift) -- Encryption settings UI +- [`DatabaseView.swift`](../Shared/Views/Database/DatabaseView.swift) -- Database management UI (size, export, import, encryption) + +--- + +## 6. File Storage + +### Directory Structure + +``` +{App Container}/ +├── Documents/ +│ ├── app_files/ -- Downloaded and sent files +│ ├── temp_files/ -- Temporary files during transfer +│ └── assets/wallpapers/ -- Custom wallpaper images +├── {App Group Container}/ +│ ├── simplex_v1_chat.db -- Chat database +│ ├── simplex_v1_agent.db -- Agent database +│ └── ... +``` + +### [File Size Constants](../SimpleXChat/FileUtils.swift#L18-L36) (FileUtils.swift) + +```swift +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255 KB -- inline image compression target +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB -- auto-receive images +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB -- auto-receive voice +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023 KB -- auto-receive video +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1 GB -- max XFTP transfer +public let MAX_FILE_SIZE_SMP: Int64 = 8_000_000 // ~7.6 MB -- max SMP inline +public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max // No limit for local files +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) // 5 minutes +``` + +### CryptoFile (Encrypted File Storage) + +When `apiSetEncryptLocalFiles(enable: true)` is set, files stored on device are AES-encrypted: + +- Encryption/decryption uses `chat_encrypt_file` / `chat_decrypt_file` C FFI functions +- Each file gets a unique key and nonce stored alongside the file reference +- The `CryptoFile` type wraps `(filePath: String, cryptoArgs: CryptoFileArgs?)` where `CryptoFileArgs` contains `(fileKey: String, fileNonce: String)` + +### [File Path Helpers](../SimpleXChat/FileUtils.swift#L219-L221) + +```swift +public func getDocumentsDirectory() -> URL // Standard documents dir +public func getGroupContainerDirectory() -> URL // App group container +func getAppFilesDirectory() -> URL // {appDir}/app_files/ +func getTempFilesDirectory() -> URL // {appDir}/temp_files/ +func getWallpaperDirectory() -> URL // {appDir}/assets/wallpapers/ +``` + +See also [`saveFile()`](../SimpleXChat/FileUtils.swift#L226), [`removeFile()`](../SimpleXChat/FileUtils.swift#L243), and [`getMaxFileSize()`](../SimpleXChat/FileUtils.swift#L276). + +### [Cleanup](../SimpleXChat/FileUtils.swift#L86-L116) + +- Files are deleted when their associated `ChatItem` is deleted. See [`cleanupFile()`](../SimpleXChat/FileUtils.swift#L267) and [`cleanupDirectFile()`](../SimpleXChat/FileUtils.swift#L260). +- Timed message expiry triggers file deletion +- [`deleteAppDatabaseAndFiles()`](../SimpleXChat/FileUtils.swift#L86) removes all databases, files, temp files, and wallpapers +- [`deleteAppFiles()`](../SimpleXChat/FileUtils.swift#L108) removes only the files directory (preserving databases) + +--- + +## 7. Export & Import + +### Export + +```swift +ChatCommand.apiExportArchive(config: ArchiveConfig) +// Response: ChatResponse2.archiveExported(archiveErrors: [ArchiveError]) +``` + +`ArchiveConfig` specifies: +- `archivePath: String` -- destination path for the archive +- `disableCompression: Bool?` -- optional flag to skip compression + +The archive contains both databases and optionally files. The Haskell core handles the actual export, creating a ZIP archive. + +### Import + +```swift +ChatCommand.apiImportArchive(config: ArchiveConfig) +// Response: ChatResponse2.archiveImported(archiveErrors: [ArchiveError]) +``` + +Import replaces the current databases with the archive contents. The app must be restarted after import. + +### Archive Errors + +`ArchiveError` is an array returned with both export and import results, listing any non-fatal issues encountered (e.g., missing files, corrupt entries). + +--- + +## 8. App Group Sharing + +### Shared Access Model + +The main app and NSE share database access through the iOS App Group container: + +``` +Main App ──┐ + ├── {App Group}/simplex_v1_chat.db + ├── {App Group}/simplex_v1_agent.db +NSE ────────┘ +``` + +### Coordination + +- Both processes can initialize their own `chat_ctrl` instance pointing to the same database files +- SQLite WAL mode allows concurrent reads +- Write coordination uses `chat_close_store` / `chat_reopen_store` to manage database locks +- The main app suspends its chat controller when entering background, allowing NSE to access the database +- NSE is short-lived (~30 seconds per notification) and releases its lock quickly + +### App State Communication + +The `appStateGroupDefault` in `GroupDefaults` communicates app state between main app and NSE: +- `.active` -- main app is in foreground +- `.suspended` -- main app is in background +- `.stopped` -- main app is terminated + +The NSE checks this flag to determine whether to process notifications (it avoids processing if the main app is active). + +--- + +## Source Files + +| File | Path | +|------|------| +| File utilities & constants | [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) | +| Database management UI | [`Shared/Views/Database/DatabaseView.swift`](../Shared/Views/Database/DatabaseView.swift) | +| Encryption settings UI | [`Shared/Views/Database/DatabaseEncryptionView.swift`](../Shared/Views/Database/DatabaseEncryptionView.swift) | +| C FFI (migration, file ops) | `SimpleXChat/SimpleX.h` | +| Haskell store root | `../../src/Simplex/Chat/Store/` | +| Haskell migrations | `../../src/Simplex/Chat/Store/SQLite/Migrations/` | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md new file mode 100644 index 0000000000..74acec789e --- /dev/null +++ b/apps/ios/spec/impact.md @@ -0,0 +1,119 @@ +# SimpleX Chat iOS -- Impact Graph + +> Source file → product concept mapping. Use this to identify which product documents must be updated when a source file changes. +> +> Derived from [CODE.md](../CODE.md) Document Map and [product/concepts.md](../product/concepts.md). + +--- + +## Product Concept Legend + +| ID | Concept | +|----|---------| +| PC1 | Chat List | +| PC2 | Direct Chat | +| PC3 | Group Chat | +| PC4 | Message Composition | +| PC5 | Message Reactions | +| PC6 | Message Editing | +| PC7 | Message Deletion | +| PC8 | Timed Messages | +| PC9 | Voice Messages | +| PC10 | File Transfer | +| PC11 | Link Previews | +| PC12 | Contact Connection | +| PC13 | Contact Verification | +| PC14 | Group Management | +| PC15 | Group Links | +| PC16 | Member Roles | +| PC17 | Audio/Video Calls | +| PC18 | Push Notifications | +| PC19 | User Profiles | +| PC20 | Incognito Mode | +| PC21 | Hidden Profiles | +| PC22 | Local Authentication | +| PC23 | Database Encryption | +| PC24 | Theme System | +| PC25 | Network Configuration | +| PC26 | Device Migration | +| PC27 | Remote Desktop | +| PC28 | Chat Tags | +| PC29 | User Address | +| PC30 | Member Support Chat | +| PC31 | Channels (Relays) | + +--- + +## 1. Swift Source Impact + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| Shared/ContentView.swift | PC1, PC2, PC3 | High | Root navigation — affects all chat access | +| Shared/SimpleXApp.swift | PC1 through PC31 | High | App entry point — initialization affects everything | +| Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | +| Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | +| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup | +| Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | +| Shared/Views/Chat/ChatInfoView.swift | PC2, PC13, PC20 | Medium | Contact details and verification | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | PC3, PC14, PC15, PC16, PC30, PC31 | High | Group management hub, channel info adaptations | +| Shared/Views/Chat/Group/ChannelMembersView.swift | PC31 | Medium | Channel owners/subscribers list | +| Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list | +| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow | +| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; rejected-by-operator status row for relay members | +| Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels | +| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility | +| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering | +| Shared/Views/Call/CallController.swift | PC17 | High | CallKit integration — call lifecycle | +| Shared/Views/Call/WebRTCClient.swift | PC17 | High | WebRTC session management | +| Shared/Views/UserSettings/SettingsView.swift | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | +| Shared/Views/UserSettings/AppearanceSettings.swift | PC24 | Low | Theme customization UI | +| Shared/Views/UserSettings/NetworkAndServers/ | PC25, PC31 | High | Server configuration — affects connectivity and relay validation | +| Shared/Views/UserSettings/UserProfilesView.swift | PC19, PC21 | Medium | Profile management | +| Shared/Views/Onboarding/ | PC1 | Medium | First-time setup — affects initial state | +| Shared/Views/LocalAuth/ | PC22 | Medium | App lock functionality | +| Shared/Views/Database/ | PC23, PC26 | High | Database encryption and export | +| Shared/Views/Migration/ | PC26 | High | Device migration — data portability | +| Shared/Model/ChatModel.swift | PC1 through PC31 | High | Central state — all features depend on it | +| Shared/Model/SimpleXAPI.swift | PC1 through PC31 | High | FFI bridge — all commands flow through here | +| Shared/Model/AppAPITypes.swift | PC1 through PC31 | High | Command/response types — all API communication | +| Shared/Model/NtfManager.swift | PC18 | High | Notification delivery | +| Shared/Model/BGManager.swift | PC18 | Medium | Background fetch scheduling | +| Shared/Theme/ThemeManager.swift | PC24 | Medium | Theme resolution engine | +| SimpleXChat/ChatTypes.swift | PC1 through PC31 | High | Core data types — all features use them | +| SimpleXChat/APITypes.swift | PC1 through PC31 | High | API result types and error handling | +| SimpleXChat/CallTypes.swift | PC17 | Medium | Call-specific data types | +| SimpleXChat/FileUtils.swift | PC10, PC23, PC26 | Medium | File paths and encryption utilities | +| SimpleXChat/Notifications.swift | PC18 | Medium | Notification type definitions | +| SimpleX NSE/NotificationService.swift | PC18 | High | Push notification decryption and display | +| Shared/Views/Chat/ChatItemsMerger.swift | PC2, PC3, PC31 | Low | Chat item merge categories — added channelRcv hash | +| SimpleX SE/ShareAPI.swift | PC4, PC31 | Medium | Share extension API — sendAsGroup support | + +--- + +## 2. Haskell Core Impact + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| src/Simplex/Chat/Controller.hs | PC1 through PC31 | High | Command processor — all API commands | +| src/Simplex/Chat/Types.hs | PC1 through PC31 | High | Core data types shared across all features | +| src/Simplex/Chat/Core.hs | PC1 through PC31 | High | Chat engine lifecycle | +| src/Simplex/Chat/Protocol.hs | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| src/Simplex/Chat/Messages.hs | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | +| src/Simplex/Chat/Messages/CIContent.hs | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | +| src/Simplex/Chat/Call.hs | PC17 | Medium | Call signaling types | +| src/Simplex/Chat/Files.hs | PC10 | Medium | File transfer orchestration | +| src/Simplex/Chat/Store/Messages.hs | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | +| src/Simplex/Chat/Store/Groups.hs | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | +| src/Simplex/Chat/Store/Direct.hs | PC2, PC12, PC13 | High | Contact persistence | +| src/Simplex/Chat/Store/Files.hs | PC10 | Medium | File transfer persistence | +| src/Simplex/Chat/Store/Profiles.hs | PC19, PC21 | Medium | User profile persistence | +| src/Simplex/Chat/Store/Connections.hs | PC2, PC12 | High | Connection persistence and entity resolution | +| src/Simplex/Chat/Archive.hs | PC26 | Medium | Database export/import for migration | +| src/Simplex/Chat/ProfileGenerator.hs | PC20 | Low | Random profile generation for incognito | +| src/Simplex/Chat/Remote.hs | PC27 | Medium | Remote desktop protocol handler | +| src/Simplex/Chat/Remote/Types.hs | PC27 | Low | Remote desktop data types | +| src/Simplex/Chat/Types/UITheme.hs | PC24 | Low | Theme data types for UI customization | +| src/Simplex/Chat/Types/Preferences.hs | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | +| src/Simplex/Chat/Types/Shared.hs | PC3, PC16 | Medium | Shared types including GroupMemberRole | diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md new file mode 100644 index 0000000000..6a1d89f6a3 --- /dev/null +++ b/apps/ios/spec/services/calls.md @@ -0,0 +1,383 @@ +# SimpleX Chat iOS -- WebRTC Calling Service + +> Technical specification for the calling system: CallController, WebRTCClient, CallKit integration, and signaling via SMP. +> +> Related specs: [Architecture](../architecture.md) | [API Reference](../api.md) | [Notifications](notifications.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`CallController.swift`](../../Shared/Views/Call/CallController.swift) | [`WebRTCClient.swift`](../../Shared/Views/Call/WebRTCClient.swift) | [`ActiveCallView.swift`](../../Shared/Views/Call/ActiveCallView.swift) | [`CallTypes.swift`](../../SimpleXChat/CallTypes.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [CallController](#2-callcontroller) +3. [WebRTCClient](#3-webrtcclient) +4. [Call Flow via SMP](#4-call-flow-via-smp) +5. [CallKit Integration](#5-callkit-integration) +6. [CallKit-Free Mode](#6-callkit-free-mode) +7. [Audio Routing](#7-audio-routing) +8. [Key Types](#8-key-types) +9. [ActiveCallView](#9-activecallview) + +--- + +## 1. Overview + +SimpleX Chat provides end-to-end encrypted audio and video calls using WebRTC. The unique aspect is that all call signaling (SDP offers/answers, ICE candidates) is transmitted through the same encrypted SMP messaging channels used for chat, eliminating the need for a separate signaling server. + +``` +Caller SMP Relay Callee + │ │ │ + ├─ apiSendCallInvitation ──────→│──── push/event ──────→│ + │ │ │ + │ │←── apiSendCallOffer ──┤ + │←── ChatEvent.callOffer ───────│ │ + │ │ │ + ├─ apiSendCallAnswer ──────────→│──── callAnswer ──────→│ + │ │ │ + │←── callExtraInfo (ICE) ───────│←── apiSendCallExtraInfo│ + ├─ apiSendCallExtraInfo ───────→│──── callExtraInfo ───→│ + │ │ │ + │◄══════════ WebRTC P2P Media Stream ═══════════════════►│ + │ │ │ + ├─ apiEndCall ─────────────────→│──── callEnded ───────→│ +``` + +--- + +## [2. CallController](../../Shared/Views/Call/CallController.swift#L19-L449) + +**File**: `Shared/Views/Call/CallController.swift` + +Central call coordinator that bridges SimpleX call protocol with iOS CallKit (or non-CallKit fallback). + +### [Class Definition](../../Shared/Views/Call/CallController.swift#L19-L48) + +```swift +class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject { + static let shared = CallController() + static let isInChina = SKStorefront().countryCode == "CHN" + static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } + + private let provider: CXProvider // CallKit provider + private let controller: CXCallController // CallKit controller + private let callManager: CallManager // Internal call state + private let registry: PKPushRegistry // VoIP push registration + + @Published var activeCallInvitation: RcvCallInvitation? + var shouldSuspendChat: Bool = false + var fulfillOnConnect: CXAnswerCallAction? = nil +} +``` + +### Key Responsibilities + +| Method | Purpose | Line | +|--------|---------|------| +| [`reportNewIncomingCall()`](../../Shared/Views/Call/CallController.swift#L287) | Reports incoming call to CallKit for native UI | L287 | +| [`reportOutgoingCall()`](../../Shared/Views/Call/CallController.swift#L328) | Reports outgoing call to CallKit | L328 | +| [`provider(_:perform: CXAnswerCallAction)`](../../Shared/Views/Call/CallController.swift#L66) | Handles user answering via CallKit UI | L66 | +| [`provider(_:perform: CXEndCallAction)`](../../Shared/Views/Call/CallController.swift#L96) | Handles user ending via CallKit UI | L96 | +| [`provider(_:perform: CXStartCallAction)`](../../Shared/Views/Call/CallController.swift#L55) | Handles outgoing call start | L55 | +| [`pushRegistry(_:didReceiveIncomingPushWith:)`](../../Shared/Views/Call/CallController.swift#L202) | Handles VoIP push tokens | L202 | +| [`hasActiveCalls()`](../../Shared/Views/Call/CallController.swift#L435) | Checks if any calls are active | L435 | + +### Call Manager (internal) + +`CallManager` tracks call state internally: +- Maps call UUIDs to `Call` objects +- Handles call state transitions +- Coordinates between CallKit actions and SimpleX API calls + +--- + +## [3. WebRTCClient](../../Shared/Views/Call/WebRTCClient.swift#L13-L676) + +**File**: `Shared/Views/Call/WebRTCClient.swift` (~49KB) + +Manages the WebRTC peer connection, media streams, and data channels. + +### Responsibilities + +- Creates and configures `RTCPeerConnection` +- Manages local audio/video capture (`RTCCameraVideoCapturer`, `RTCAudioTrack`) +- Handles SDP offer/answer creation and application +- Processes ICE candidates +- Manages media stream encryption + +### Key Operations + +| Operation | Description | Line | +|-----------|-------------|------| +| [`initializeCall`](../../Shared/Views/Call/WebRTCClient.swift#L93) | Sets up peer connection, tracks, encryption | L93 | +| [`createPeerConnection`](../../Shared/Views/Call/WebRTCClient.swift#L139) | Creates and configures RTCPeerConnection | L139 | +| [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 | +| [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 | +| [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 | +| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 | +| [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 | +| [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 | +| [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 | +| [`endCall`](../../Shared/Views/Call/WebRTCClient.swift#L645) | Tears down connection and tracks | L645 | +| [`setupEncryptionForLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L503) | Sets up frame encryption for local media tracks | L503 | + +### [Additional Encryption](../../Shared/Views/Call/WebRTCClient.swift#L513-L546) + +Beyond WebRTC's built-in SRTP encryption, SimpleX adds an extra encryption layer: +- A shared key from the E2E SMP channel is used +- Applied via `chat_encrypt_media` / `chat_decrypt_media` C FFI functions +- Each media frame is encrypted/decrypted with this additional key +- Provides defense-in-depth even if SRTP is compromised + +--- + +## 4. Call Flow via SMP + +All call signaling travels through the same encrypted SMP message channels used for chat. No separate signaling server is needed. + +### Outgoing Call (Caller Side) + +``` +1. User initiates call + └── apiSendCallInvitation(contact:, callType:) + └── Sends CallInvitation via SMP to contact + +2. Callee accepts, sends SDP offer + └── ChatEvent.callOffer received + └── WebRTCClient creates answer + └── apiSendCallAnswer(contact:, answer:) + +3. ICE candidates exchanged + └── ChatEvent.callExtraInfo received → WebRTCClient.addIceCandidate() + └── WebRTCClient generates candidates → apiSendCallExtraInfo(contact:, extraInfo:) + +4. P2P connection established + └── Media streams flowing + +5. End call + └── apiEndCall(contact:) +``` + +### Incoming Call (Callee Side) + +``` +1. ChatEvent.callInvitation received (or push notification) + └── CallController reports to CallKit (or shows in-app notification) + +2. User accepts + └── WebRTCClient creates SDP offer (callee creates offer in SimpleX protocol) + └── apiSendCallOffer(contact:, callOffer:) + +3. Caller sends answer + └── ChatEvent.callAnswer received + └── WebRTCClient.setRemoteDescription(answer) + +4. ICE candidates exchanged (same as above) + +5. P2P connection established +``` + +### API Commands + +| Command | Direction | Purpose | +|---------|-----------|---------| +| `apiSendCallInvitation(contact:, callType:)` | Caller -> Callee | Initiate call | +| `apiRejectCall(contact:)` | Callee -> Caller | Reject call | +| `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer | +| `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer | +| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates | +| `apiEndCall(contact:)` | Either | End call | +| `apiGetCallInvitations` | -- | Get pending invitations | +| `apiCallStatus(contact:, callStatus:)` | -- | Report status change | + +--- + +## [5. CallKit Integration](../../Shared/Views/Call/CallController.swift#L24-L155) + +CallKit provides the native iOS incoming call experience (lock screen UI, call history, system call handling). + +### [CXProvider Configuration](../../Shared/Views/Call/CallController.swift#L24-L37) + +```swift +let configuration = CXProviderConfiguration() +configuration.supportsVideo = true +configuration.supportedHandleTypes = [.generic] +configuration.includesCallsInRecents = UserDefaults.standard.bool( + forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS +) +configuration.maximumCallGroups = 1 +configuration.maximumCallsPerCallGroup = 1 +configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() +``` + +### [VoIP Push (PKPushRegistry)](../../Shared/Views/Call/CallController.swift#L207-L284) + +CallKit requires VoIP push for incoming calls on locked device: +- `PKPushRegistry` registers for `.voIP` push type +- VoIP push token is separate from regular APNs token +- When VoIP push received, **must** report an incoming call to CallKit within the callback (iOS requirement) + +### CallKit Actions + +| CXAction | Handler | Description | Line | +|----------|---------|-------------|------| +| `CXStartCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L55) | User starts outgoing call | L55 | +| `CXAnswerCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L66) | User answers incoming call from CallKit UI | L66 | +| `CXEndCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L96) | User ends call from CallKit UI | L96 | +| `CXSetMutedCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L112) | User mutes from CallKit UI | L112 | + +### [Lock Screen Answer](../../Shared/Views/Call/CallController.swift#L66-L94) + +When answering from the lock screen: +1. `CXAnswerCallAction` fires +2. CallController waits for chat to be ready ([`waitUntilChatStarted(timeoutMs: 30_000)`](../../Shared/Views/Call/CallController.swift#L183)) +3. WebRTC connection established +4. `fulfillOnConnect` action is fulfilled only when WebRTC reaches connected state (required for audio to work on lock screen) + +--- + +## [6. CallKit-Free Mode](../../Shared/Views/Call/CallController.swift#L21-L22) + +In regions where CallKit is unavailable (e.g., China, determined by `SKStorefront.countryCode == "CHN"`), the app falls back to in-app notifications: + +```swift +static let isInChina = SKStorefront().countryCode == "CHN" +static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } +``` + +### Non-CallKit Behavior +- Incoming calls shown as in-app banners (via `CallController.activeCallInvitation`) +- No lock screen call UI +- No system call integration +- User can also manually disable CallKit via settings (`callKitEnabledGroupDefault`) + +--- + +## [7. Audio Routing](../../Shared/Views/Call/WebRTCClient.swift#L907-L1005) + +### [AVAudioSession Management](../../Shared/Views/Call/WebRTCClient.swift#L907-L950) + +Audio routing is managed through `AVAudioSession`: +- **Receiver**: Default for audio-only calls (ear speaker) +- **Speaker**: For video calls or when user toggles speaker +- **Bluetooth**: Detected and used when available +- **Headphones**: Detected and used when connected + +### Route Change Handling + +The `WebRTCClient` observes `AVAudioSession.routeChangeNotification` to handle: +- Bluetooth device connection/disconnection +- Headphone plug/unplug +- Speaker/receiver toggle + +--- + +## [8. Key Types](../../SimpleXChat/CallTypes.swift#L1-L115) + +### [RcvCallInvitation](../../SimpleXChat/CallTypes.swift#L45-L71) + +```swift +struct RcvCallInvitation { + var user: User + var contact: Contact + var callType: CallType + var sharedKey: String? // Optional E2E encryption key + var callUUID: String? + var callTs: Date +} +``` + +### [CallType](../../SimpleXChat/CallTypes.swift#L74-L82) + +```swift +struct CallType { + var media: CallMediaType // .audio or .video + var capabilities: CallCapabilities +} + +enum CallMediaType: String { + case audio + case video +} +``` + +### [WebRTCCallOffer](../../SimpleXChat/CallTypes.swift#L14-L22) / [WebRTCSession](../../SimpleXChat/CallTypes.swift#L25-L33) + +```swift +struct WebRTCCallOffer { + var callType: CallType + var rtcSession: WebRTCSession +} + +struct WebRTCSession { + var rtcSession: String // SDP string + var rtcIceCandidates: String // ICE candidates JSON +} +``` + +### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42) + +```swift +struct WebRTCExtraInfo { + var rtcIceCandidates: String // Additional ICE candidates +} +``` + +### Call (Active Call State) + +Stored in `ChatModel.activeCall`: +- Contact reference +- Call UUID +- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.) +- Media type +- WebRTCClient reference + +--- + +## [9. ActiveCallView](../../Shared/Views/Call/ActiveCallView.swift#L16-L285) + +**File**: `Shared/Views/Call/ActiveCallView.swift` + +Full-screen call UI when `ChatModel.showCallView == true`: + +### UI Elements +- Remote video (full screen background) +- Local video (PiP corner, draggable) +- Contact name and call duration +- Control buttons: mute, camera toggle, speaker toggle, camera flip, end call +- Minimize button (collapses to banner) + +### [ActiveCallOverlay](../../Shared/Views/Call/ActiveCallView.swift#L288-L522) + +| Control | Method | Line | +|---------|--------|------| +| Audio call info | [`audioCallInfoView`](../../Shared/Views/Call/ActiveCallView.swift#L357) | L357 | +| Video call info | [`videoCallInfoView`](../../Shared/Views/Call/ActiveCallView.swift#L377) | L377 | +| End call | [`endCallButton`](../../Shared/Views/Call/ActiveCallView.swift#L407) | L407 | +| Mute toggle | [`toggleMicButton`](../../Shared/Views/Call/ActiveCallView.swift#L418) | L418 | +| Audio device | [`audioDeviceButton`](../../Shared/Views/Call/ActiveCallView.swift#L428) | L428 | +| Speaker toggle | [`toggleSpeakerButton`](../../Shared/Views/Call/ActiveCallView.swift#L452) | L452 | +| Camera toggle | [`toggleCameraButton`](../../Shared/Views/Call/ActiveCallView.swift#L464) | L464 | +| Flip camera | [`flipCameraButton`](../../Shared/Views/Call/ActiveCallView.swift#L475) | L475 | + +### PiP (Picture-in-Picture) + +When `ChatModel.activeCallViewIsCollapsed == true`: +- Call view collapses to a small floating overlay +- User can return to full-screen by tapping the banner +- Navigation continues normally underneath + +--- + +## Source Files + +| File | Path | Lines | +|------|------|-------| +| [Call controller](../../Shared/Views/Call/CallController.swift) | `Shared/Views/Call/CallController.swift` | 449 | +| [WebRTC client](../../Shared/Views/Call/WebRTCClient.swift) | `Shared/Views/Call/WebRTCClient.swift` | 1139 | +| [Active call UI](../../Shared/Views/Call/ActiveCallView.swift) | `Shared/Views/Call/ActiveCallView.swift` | 528 | +| WebRTC helpers | `Shared/Views/Call/WebRTC.swift` | | +| [Call types (Swift)](../../SimpleXChat/CallTypes.swift) | `SimpleXChat/CallTypes.swift` | 115 | +| Call types (Haskell) | `../../src/Simplex/Chat/Call.hs` | | diff --git a/apps/ios/spec/services/files.md b/apps/ios/spec/services/files.md new file mode 100644 index 0000000000..7e1f8a2ad1 --- /dev/null +++ b/apps/ios/spec/services/files.md @@ -0,0 +1,368 @@ +# SimpleX Chat iOS -- File Transfer Service + +> Technical specification for file transfer: inline/XFTP protocols, auto-receive thresholds, CryptoFile encryption, and file constants. +> +> Related specs: [Compose Module](../client/compose.md) | [Chat View](../client/chat-view.md) | [API Reference](../api.md) | [Database](../database.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`FileUtils.swift`](../../SimpleXChat/FileUtils.swift) | [`CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift) | [`ChatTypes.swift`](../../SimpleXChat/ChatTypes.swift) | [`AppAPITypes.swift`](../../Shared/Model/AppAPITypes.swift) | [`SimpleXAPI.swift`](../../Shared/Model/SimpleXAPI.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Transfer Methods](#2-transfer-methods) +3. [Auto-Receive Thresholds](#3-auto-receive-thresholds) +4. [File Size Constants](#4-file-size-constants) +5. [Image Handling](#5-image-handling) +6. [Voice Messages](#6-voice-messages) +7. [CryptoFile -- At-Rest Encryption](#7-cryptofile) +8. [File Storage Paths](#8-file-storage-paths) +9. [File Lifecycle](#9-file-lifecycle) +10. [API Commands](#10-api-commands) + +--- + +## 1. Overview + +SimpleX Chat supports two file transfer methods depending on file size: + +``` +File ≤ 255KB (inline) +├── Base64 encoded directly in SMP message +├── Single message delivery +└── No extra server infrastructure needed + +File > 255KB up to 1GB (XFTP) +├── Encrypted and chunked +├── Uploaded to XFTP relay servers +├── Recipient downloads chunks from relays +└── Files auto-deleted from relays after download or expiry +``` + +All files are end-to-end encrypted. The XFTP protocol adds a second encryption layer on top of the SMP channel encryption. + +--- + +## 2. Transfer Methods + +### Inline Transfer + +- Files up to [`MAX_IMAGE_SIZE`](../../SimpleXChat/FileUtils.swift#L18) (255KB) are base64-encoded and embedded directly in the SMP message body +- No additional protocol or server needed +- Delivered with the same reliability guarantees as regular messages +- Used primarily for compressed images + +### XFTP Transfer + +For files exceeding the inline threshold (up to [`MAX_FILE_SIZE_XFTP`](../../SimpleXChat/FileUtils.swift#L30) = 1GB): + +1. **Sender side**: + - File is AES-encrypted with a random key + - Encrypted file is split into chunks + - Chunks are uploaded to one or more XFTP relay servers + - File metadata (key, chunk locations) sent to recipient via SMP message + +2. **Recipient side**: + - Receives file metadata via SMP + - Downloads chunks from XFTP relays + - Reassembles and decrypts the file + +3. **Cleanup**: + - XFTP relays delete chunks after download or after expiry period + - No persistent storage on relays + +### SMP Transfer (legacy) + +[`MAX_FILE_SIZE_SMP`](../../SimpleXChat/FileUtils.swift#L34) (8MB) exists as a constant for larger inline transfers through SMP, used in specific scenarios. + +--- + +## 3. Auto-Receive Thresholds + +Files below certain size thresholds are automatically accepted and downloaded without user confirmation: + +| Media Type | Auto-Receive Threshold | Constant | Line | +|------------|----------------------|----------|------| +| Images | 510 KB | [`MAX_IMAGE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L21) | [L21](../../SimpleXChat/FileUtils.swift#L21) | +| Voice messages | 510 KB | [`MAX_VOICE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L24) | [L24](../../SimpleXChat/FileUtils.swift#L24) | +| Video | 1023 KB | [`MAX_VIDEO_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L27) | [L27](../../SimpleXChat/FileUtils.swift#L27) | +| Other files | Not auto-received | Requires manual acceptance | -- | + +### Behavior + +- When a message with a file attachment arrives: + 1. Check if file size is below the auto-receive threshold for its type + 2. If below: automatically call [`setFileToReceive(fileId:, userApprovedRelays:, encrypted:)`](../../Shared/Model/AppAPITypes.swift#L168) followed by download + 3. If above: show download button in chat item, wait for user action + 4. User manually triggers download via [`receiveFile(fileId:, userApprovedRelays:, encrypted:, inline:)`](../../Shared/Model/AppAPITypes.swift#L167) + +### Relay Approval + +`userApprovedRelays` parameter: when the file is hosted on relays not in the user's configured server list, the user is asked for confirmation before connecting to unknown relays. + +--- + +## [4. File Size Constants](../../SimpleXChat/FileUtils.swift#L18) + +Defined in [`SimpleXChat/FileUtils.swift`](../../SimpleXChat/FileUtils.swift): + +| Constant | Value | Line | +|----------|-------|------| +| `MAX_IMAGE_SIZE` | 261,120 (255 KB) | [L18](../../SimpleXChat/FileUtils.swift#L18) | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 (510 KB) | [L21](../../SimpleXChat/FileUtils.swift#L21) | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 (510 KB) | [L24](../../SimpleXChat/FileUtils.swift#L24) | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 (1023 KB) | [L27](../../SimpleXChat/FileUtils.swift#L27) | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 (1 GB) | [L30](../../SimpleXChat/FileUtils.swift#L30) | +| `MAX_FILE_SIZE_LOCAL` | Int64.max (no limit) | [L32](../../SimpleXChat/FileUtils.swift#L32) | +| `MAX_FILE_SIZE_SMP` | 8,000,000 (~7.6 MB) | [L34](../../SimpleXChat/FileUtils.swift#L34) | +| `MAX_VOICE_MESSAGE_LENGTH` | 300 s (5 min) | [L36](../../SimpleXChat/FileUtils.swift#L36) | + +```swift +// Image compression target for inline transfer +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255 KB + +// Auto-receive thresholds +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB (2 * MAX_IMAGE_SIZE) +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB (2 * MAX_IMAGE_SIZE) +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023 KB + +// Transfer method limits +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1 GB +public let MAX_FILE_SIZE_SMP: Int64 = 8_000_000 // ~7.6 MB +public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max // No limit (local notes) + +// Voice message constraints +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) // 5 minutes (300 seconds) +``` + +--- + +## 5. Image Handling + +### Compression Pipeline + +1. User selects image (camera or photo library) +2. Image is compressed to fit within [`MAX_IMAGE_SIZE`](../../SimpleXChat/FileUtils.swift#L18) (255KB): + - Progressive JPEG compression with decreasing quality + - Resize if dimensions are too large +3. Compressed image is base64-encoded into the message content +4. For larger images that cannot compress to 255KB: sent via XFTP + +### Display + +- `CIImageView` renders images in chat bubbles with aspect-fit sizing +- Tapping opens `FullScreenMediaView` with zoom/pan/share capabilities +- Thumbnail is displayed immediately; full-size loaded on demand for XFTP images + +### Animated Images + +- GIFs are handled by `AnimatedImageView` +- Displayed inline with animation support + +--- + +## 6. Voice Messages + +### Recording + +1. `ComposeVoiceView` manages the recording UI +2. `AudioRecPlay` handles `AVAudioRecorder` lifecycle +3. Recorded in compressed audio format +4. Maximum duration: [`MAX_VOICE_MESSAGE_LENGTH`](../../SimpleXChat/FileUtils.swift#L36) = 300 seconds (5 minutes) +5. Waveform data extracted for visualization + +### Transfer + +- Voice files up to [`MAX_VOICE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L24) (510KB) are auto-received +- Larger voice files follow standard file transfer flow +- Voice messages include waveform metadata for UI rendering + +### Playback + +- `CIVoiceView` / `FramedCIVoiceView` render voice messages +- Shows waveform visualization and play/pause control +- `ChatModel.stopPreviousRecPlay` ensures only one audio source plays at a time +- Playback position and progress tracked + +--- + +## [7. CryptoFile -- At-Rest Encryption](../../SimpleXChat/ChatTypes.swift#L4241) + +When [`apiSetEncryptLocalFiles(enable: true)`](../../Shared/Model/SimpleXAPI.swift#L384) is configured, files stored on the device are AES-encrypted. + +### [`CryptoFile`](../../SimpleXChat/ChatTypes.swift#L4241) Type + +```swift +struct CryptoFile { + var filePath: String + var cryptoArgs: CryptoFileArgs? // nil = unencrypted +} + +struct CryptoFileArgs { + var fileKey: String // AES encryption key + var fileNonce: String // AES nonce/IV +} +``` + +> Defined in [`ChatTypes.swift` L4241](../../SimpleXChat/ChatTypes.swift#L4241) (`CryptoFile`) and [L4289](../../SimpleXChat/ChatTypes.swift#L4289) (`CryptoFileArgs`). + +### Encryption Operations (C FFI) + +Implemented in [`CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift): + +| Function | Purpose | Line | +|----------|---------|------| +| [`writeCryptoFile`](../../SimpleXChat/CryptoFile.swift#L18) | Write encrypted file, returns `CryptoFileArgs` | [L18](../../SimpleXChat/CryptoFile.swift#L18) | +| [`readCryptoFile`](../../SimpleXChat/CryptoFile.swift#L31) | Read and decrypt file, returns `Data` | [L31](../../SimpleXChat/CryptoFile.swift#L31) | +| [`encryptCryptoFile`](../../SimpleXChat/CryptoFile.swift#L54) | Encrypt existing file to new path | [L54](../../SimpleXChat/CryptoFile.swift#L54) | +| [`decryptCryptoFile`](../../SimpleXChat/CryptoFile.swift#L66) | Decrypt file to new path | [L66](../../SimpleXChat/CryptoFile.swift#L66) | + +### Storage + +- Encrypted files stored alongside unencrypted files in `Documents/files/` +- The `CryptoFileArgs` (key + nonce) are stored in the Haskell database, not on the filesystem +- Toggle via privacy settings: [`apiSetEncryptLocalFiles(enable:)`](../../Shared/Model/SimpleXAPI.swift#L384) + +--- + +## [8. File Storage Paths](../../SimpleXChat/FileUtils.swift#L199) + +### Directory Structure + +| Function | Path | Line | +|----------|------|------| +| [`getAppFilesDirectory()`](../../SimpleXChat/FileUtils.swift#L208) | `Documents/files/` | [L208](../../SimpleXChat/FileUtils.swift#L208) | +| [`getTempFilesDirectory()`](../../SimpleXChat/FileUtils.swift#L199) | `Documents/temp_files/` | [L199](../../SimpleXChat/FileUtils.swift#L199) | +| [`getWallpaperDirectory()`](../../SimpleXChat/FileUtils.swift#L217) | `Documents/wallpapers/` | [L217](../../SimpleXChat/FileUtils.swift#L217) | +| [`getAppFilePath(_:)`](../../SimpleXChat/FileUtils.swift#L212) | `Documents/files/{filename}` | [L212](../../SimpleXChat/FileUtils.swift#L212) | +| [`getWallpaperFilePath(_:)`](../../SimpleXChat/FileUtils.swift#L221) | `Documents/wallpapers/{filename}` | [L221](../../SimpleXChat/FileUtils.swift#L221) | + +```swift +func getAppFilesDirectory() -> URL // Documents/files/ +func getTempFilesDirectory() -> URL // Documents/temp_files/ +func getWallpaperDirectory() -> URL // Documents/wallpapers/ +``` + +### Path Management + +- Downloaded files: `Documents/files/{filename}` +- Temporary files during transfer: `Documents/temp_files/` +- Wallpaper images: `Documents/wallpapers/` +- File paths are set via [`apiSetAppFilePaths(filesFolder:, tempFolder:, assetsFolder:)`](../../Shared/Model/SimpleXAPI.swift#L377) at startup + +--- + +## 9. File Lifecycle + +### Sending + +``` +1. User selects file/image/video in compose +2. ComposeView creates ComposedMessage with file reference +3. apiSendMessages() → Haskell core processes: + a. File ≤ inline threshold: base64 encode into message + b. File > inline threshold: start XFTP upload +4. Upload events: + - ChatEvent.sndFileStart + - ChatEvent.sndFileProgressXFTP (periodic progress) + - ChatEvent.sndFileCompleteXFTP (upload done) + - ChatEvent.sndFileError (on failure) +``` + +### Receiving + +``` +1. Message with file attachment arrives +2. Auto-receive check: + a. Below threshold: automatic download starts + b. Above threshold: user sees download button +3. User triggers download (or auto-triggered): + - receiveFile(fileId:, userApprovedRelays:, encrypted:, inline:) +4. Download events: + - ChatEvent.rcvFileStart + - ChatEvent.rcvFileProgressXFTP (periodic progress) + - ChatEvent.rcvFileComplete (download done) + - ChatEvent.rcvFileError (on failure) + - ChatEvent.rcvFileSndCancelled (sender cancelled) +``` + +### Cancellation + +```swift +ChatCommand.cancelFile(fileId: Int64) +``` + +Cancels an in-progress upload or download. For XFTP transfers, also requests chunk deletion from relays. + +### Cleanup + +| Function | Purpose | Line | +|----------|---------|------| +| [`cleanupFile(_:)`](../../SimpleXChat/FileUtils.swift#L267) | Remove file associated with a chat item | [L267](../../SimpleXChat/FileUtils.swift#L267) | +| [`cleanupDirectFile(_:)`](../../SimpleXChat/FileUtils.swift#L260) | Remove file only for direct chats | [L260](../../SimpleXChat/FileUtils.swift#L260) | +| [`removeFile(_:)`](../../SimpleXChat/FileUtils.swift#L243) | Delete file at URL | [L243](../../SimpleXChat/FileUtils.swift#L243) | +| [`removeFile(_:)`](../../SimpleXChat/FileUtils.swift#L251) | Delete file by name | [L251](../../SimpleXChat/FileUtils.swift#L251) | +| [`deleteAppFiles()`](../../SimpleXChat/FileUtils.swift#L108) | Remove all app files (preserving databases) | [L108](../../SimpleXChat/FileUtils.swift#L108) | +| [`deleteAppDatabaseAndFiles()`](../../SimpleXChat/FileUtils.swift#L86) | Remove everything | [L86](../../SimpleXChat/FileUtils.swift#L86) | + +- When a `ChatItem` is deleted, its associated file is deleted from disk +- When a timed message expires, its file is deleted +- `ChatModel.filesToDelete` queues files for deferred deletion +- [`deleteAppFiles()`](../../SimpleXChat/FileUtils.swift#L108) removes all files (preserving databases) +- [`deleteAppDatabaseAndFiles()`](../../SimpleXChat/FileUtils.swift#L86) removes everything + +--- + +## [10. API Commands](../../Shared/Model/AppAPITypes.swift#L167) + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| [`receiveFile`](../../Shared/Model/AppAPITypes.swift#L167) | `fileId, userApprovedRelays, encrypted, inline` | Accept and start downloading a file | [L167](../../Shared/Model/AppAPITypes.swift#L167) | +| [`setFileToReceive`](../../Shared/Model/AppAPITypes.swift#L168) | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive (no immediate download) | [L168](../../Shared/Model/AppAPITypes.swift#L168) | +| [`cancelFile`](../../Shared/Model/AppAPITypes.swift#L169) | `fileId` | Cancel in-progress transfer | [L169](../../Shared/Model/AppAPITypes.swift#L169) | +| [`apiUploadStandaloneFile`](../../Shared/Model/AppAPITypes.swift#L179) | `userId, file: CryptoFile` | Upload file to XFTP without a chat context | [L179](../../Shared/Model/AppAPITypes.swift#L179) | +| [`apiDownloadStandaloneFile`](../../Shared/Model/AppAPITypes.swift#L180) | `userId, url, file: CryptoFile` | Download from XFTP URL | [L180](../../Shared/Model/AppAPITypes.swift#L180) | +| [`apiStandaloneFileInfo`](../../Shared/Model/AppAPITypes.swift#L181) | `url` | Get metadata for an XFTP URL | [L181](../../Shared/Model/AppAPITypes.swift#L181) | + +### File Transfer Events + +| Event | Description | Line | +|-------|-------------|------| +| [`rcvFileAccepted`](../../Shared/Model/AppAPITypes.swift#L1095) | Download request accepted | [L1095](../../Shared/Model/AppAPITypes.swift#L1095) | +| [`rcvFileStart`](../../Shared/Model/AppAPITypes.swift#L1097) | Download started | [L1097](../../Shared/Model/AppAPITypes.swift#L1097) | +| [`rcvFileProgressXFTP`](../../Shared/Model/AppAPITypes.swift#L1098) | Download progress (receivedSize, totalSize) | [L1098](../../Shared/Model/AppAPITypes.swift#L1098) | +| [`rcvFileComplete`](../../Shared/Model/AppAPITypes.swift#L1099) | Download complete | [L1099](../../Shared/Model/AppAPITypes.swift#L1099) | +| [`rcvFileSndCancelled`](../../Shared/Model/AppAPITypes.swift#L1101) | Sender cancelled the transfer | [L1101](../../Shared/Model/AppAPITypes.swift#L1101) | +| [`rcvFileError`](../../Shared/Model/AppAPITypes.swift#L1102) | Download failed | [L1102](../../Shared/Model/AppAPITypes.swift#L1102) | +| [`rcvFileWarning`](../../Shared/Model/AppAPITypes.swift#L1103) | Download warning (non-fatal) | [L1103](../../Shared/Model/AppAPITypes.swift#L1103) | +| [`sndFileStart`](../../Shared/Model/AppAPITypes.swift#L1105) | Upload started | [L1105](../../Shared/Model/AppAPITypes.swift#L1105) | +| [`sndFileComplete`](../../Shared/Model/AppAPITypes.swift#L1106) | Inline upload complete | [L1106](../../Shared/Model/AppAPITypes.swift#L1106) | +| [`sndFileProgressXFTP`](../../Shared/Model/AppAPITypes.swift#L1108) | XFTP upload progress (sentSize, totalSize) | [L1108](../../Shared/Model/AppAPITypes.swift#L1108) | +| [`sndFileCompleteXFTP`](../../Shared/Model/AppAPITypes.swift#L1110) | XFTP upload complete | [L1110](../../Shared/Model/AppAPITypes.swift#L1110) | +| [`sndFileRcvCancelled`](../../Shared/Model/AppAPITypes.swift#L1107) | Receiver cancelled | [L1107](../../Shared/Model/AppAPITypes.swift#L1107) | +| [`sndFileError`](../../Shared/Model/AppAPITypes.swift#L1112) | Upload failed | [L1112](../../Shared/Model/AppAPITypes.swift#L1112) | +| [`sndFileWarning`](../../Shared/Model/AppAPITypes.swift#L1113) | Upload warning (non-fatal) | [L1113](../../Shared/Model/AppAPITypes.swift#L1113) | + +--- + +## Source Files + +| File | Path | Key Definitions | +|------|------|-----------------| +| File utilities & constants | [`SimpleXChat/FileUtils.swift`](../../SimpleXChat/FileUtils.swift) | `MAX_IMAGE_SIZE`, `saveFile`, `removeFile`, `getMaxFileSize` | +| CryptoFile FFI operations | [`SimpleXChat/CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift) | `writeCryptoFile`, `readCryptoFile`, `encryptCryptoFile`, `decryptCryptoFile` | +| CryptoFile / CryptoFileArgs types | [`SimpleXChat/ChatTypes.swift`](../../SimpleXChat/ChatTypes.swift) | `CryptoFile` (L4241), `CryptoFileArgs` (L4289) | +| API command definitions | [`Shared/Model/AppAPITypes.swift`](../../Shared/Model/AppAPITypes.swift) | `receiveFile`, `cancelFile`, `ChatEvent` file events | +| API implementations | [`Shared/Model/SimpleXAPI.swift`](../../Shared/Model/SimpleXAPI.swift) | `receiveFile` (L1471), `cancelFile` (L1590) | +| File view (chat item) | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | | +| Image view (chat item) | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | | +| Video view (chat item) | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | | +| Voice view (chat item) | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | | +| Compose file preview | [`Shared/Views/Chat/ComposeMessage/ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | | +| Compose image preview | [`Shared/Views/Chat/ComposeMessage/ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | | +| Compose voice preview | [`Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | | +| C FFI (file encryption) | [`SimpleXChat/SimpleX.h`](../../SimpleXChat/SimpleX.h) | `chat_write_file`, `chat_read_file`, `chat_encrypt_file`, `chat_decrypt_file` | +| Haskell file logic | `../../src/Simplex/Chat/Files.hs` | -- | +| Haskell file store | `../../src/Simplex/Chat/Store/Files.hs` | -- | diff --git a/apps/ios/spec/services/notifications.md b/apps/ios/spec/services/notifications.md new file mode 100644 index 0000000000..1062833f9c --- /dev/null +++ b/apps/ios/spec/services/notifications.md @@ -0,0 +1,390 @@ +# SimpleX Chat iOS -- Push Notification Service + +> Technical specification for the notification system: NtfManager, Notification Service Extension (NSE), notification modes, and token lifecycle. +> +> Related specs: [Architecture](../architecture.md) | [API Reference](../api.md) | [Navigation](../client/navigation.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`NtfManager.swift`](../../Shared/Model/NtfManager.swift) | [`BGManager.swift`](../../Shared/Model/BGManager.swift) | [`Notifications.swift`](../../SimpleXChat/Notifications.swift) | [`NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Notification Modes](#2-notification-modes) +3. [NtfManager](#3-ntfmanager) +4. [Notification Service Extension (NSE)](#4-notification-service-extension) +5. [Token Lifecycle](#5-token-lifecycle) +6. [Notification Categories & Actions](#6-notification-categories--actions) +7. [Badge Management](#7-badge-management) +8. [Background Tasks (BGManager)](#8-background-tasks) + +--- + +## 1. Overview + +SimpleX Chat uses a privacy-preserving notification architecture. Because messages are end-to-end encrypted and the notification server never sees message content, the app uses a Notification Service Extension (NSE) to decrypt push payloads on-device before displaying notifications. + +``` +APNs Push → NSE receives encrypted payload + → NSE starts Haskell core (own chat_ctrl) + → NSE decrypts message using stored keys + → NSE creates UNNotificationContent with decrypted preview + → iOS displays notification to user +``` + +The notification system has three modes of operation, allowing users to choose their privacy/convenience tradeoff. + +--- + +## 2. Notification Modes + +| Mode | Description | Mechanism | +|------|-------------|-----------| +| **Instant** | Real-time notifications via Apple Push | APNs push triggers NSE, which decrypts and displays | +| **Periodic** | Background fetch every ~20 minutes | `BGAppRefreshTask` wakes app, checks for new messages | +| **Off** | No notifications | User must open app to see messages | + +### Configuration + +Notification mode is set via: +```swift +ChatCommand.apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) +``` + +`NotificationsMode` enum: `.instant`, `.periodic`, `.off` + +The mode is stored in `ChatModel.notificationMode` and persisted in `GroupDefaults`. + +--- + +## 3. NtfManager + +**File**: [`Shared/Model/NtfManager.swift`](../../Shared/Model/NtfManager.swift) + +Central notification coordinator. Singleton: `NtfManager.shared`. + +### [Class Definition](../../Shared/Model/NtfManager.swift#L27) + +```swift +class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { + static let shared = NtfManager() + public var navigatingToChat = false + private var granted = false + private var prevNtfTime: Dictionary = [:] +} +``` + +### Key Responsibilities + +| Method | Purpose | Line | +|--------|---------|------| +| [`registerCategories()`](../../Shared/Model/NtfManager.swift#L156) | Registers notification action categories with iOS | [156](../../Shared/Model/NtfManager.swift#L156) | +| [`requestAuthorization()`](../../Shared/Model/NtfManager.swift#L215) | Requests notification permission from user | [215](../../Shared/Model/NtfManager.swift#L215) | +| [`setNtfBadgeCount(_:)`](../../Shared/Model/NtfManager.swift#L264) | Updates app icon badge | [264](../../Shared/Model/NtfManager.swift#L264) | +| [`processNotificationResponse(_:)`](../../Shared/Model/NtfManager.swift#L54) | Handles user interaction with notification | [54](../../Shared/Model/NtfManager.swift#L54) | +| [`notifyContactRequest(_:)`](../../Shared/Model/NtfManager.swift#L239) | Shows contact request notification | [239](../../Shared/Model/NtfManager.swift#L239) | +| [`notifyCallInvitation(_:)`](../../Shared/Model/NtfManager.swift#L258) | Shows incoming call notification | [258](../../Shared/Model/NtfManager.swift#L258) | +| [`notifyMessageReceived(_:)`](../../Shared/Model/NtfManager.swift#L250) | Shows message received notification | [250](../../Shared/Model/NtfManager.swift#L250) | + +### [Notification Response Processing](../../Shared/Model/NtfManager.swift#L40) + +When user taps a notification: + +1. `userNotificationCenter(didReceive:)` delegate method fires +2. If app is active: calls `processNotificationResponse()` immediately +3. If app is inactive: stores in `ChatModel.notificationResponse` for later processing +4. [`processNotificationResponse()`](../../Shared/Model/NtfManager.swift#L54): + - Extracts `userId` from `userInfo` -- switches user if needed + - Extracts `chatId` -- navigates to the conversation + - Handles action identifiers (accept contact, accept/reject call) + +### [Rate Limiting](../../Shared/Model/NtfManager.swift#L144) + +`prevNtfTime` dictionary prevents notification flooding: +- Each chat has a timestamp of its last notification +- New notifications are suppressed if within `ntfTimeInterval` (1 second) of the previous one for the same chat + +--- + +## 4. Notification Service Extension (NSE) + +**File**: [`SimpleX NSE/NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) + +### Architecture + +The NSE is a separate process that iOS launches when a push notification arrives. It has: +- Its own Haskell runtime instance (`chat_ctrl`) +- Shared database access (via app group container) +- ~30 second execution window per notification +- No access to main app's in-memory state + +### [Processing Flow](../../SimpleX NSE/NotificationService.swift#L300) + +``` +1. didReceive(request:, withContentHandler:) L300 + ├── 2. Initialize Haskell core (if not already running) + │ └── chat_migrate_init_key() with shared DB path L861 + ├── 3. Decode encrypted notification payload + │ └── apiGetNtfConns(nonce:, encNtfInfo:) L1123 + ├── 4. Fetch and decrypt messages + │ └── apiGetConnNtfMessages(connMsgReqs:) L1140 + ├── 5. Create notification content + │ ├── Contact name as title + │ ├── Decrypted message preview as body + │ └── Thread identifier for grouping + └── 6. Deliver to content handler +``` + +### NSE Commands + +The NSE uses a subset of the chat API: + +| Command | Purpose | Line | +|---------|---------|------| +| [`apiGetNtfConns(nonce:, encNtfInfo:)`](../../SimpleX NSE/NotificationService.swift#L1123) | Decrypt notification connection info | [1123](../../SimpleX NSE/NotificationService.swift#L1123) | +| [`apiGetConnNtfMessages(connMsgReqs:)`](../../SimpleX NSE/NotificationService.swift#L1140) | Fetch messages for notification connections | [1140](../../SimpleX NSE/NotificationService.swift#L1140) | + +### Database Coordination + +- NSE checks `appStateGroupDefault` before processing +- If main app is `.active`, NSE may skip processing (main app handles notifications directly) +- NSE uses `chat_close_store` / `chat_reopen_store` for safe concurrent access + +### [Preview Modes](../../SimpleXChat/APITypes.swift#L664) + +`NotificationPreviewMode` controls what the NSE shows: + +| Mode | Title | Body | +|------|-------|------| +| `.message` | Contact name | Message text | +| `.contact` | Contact name | "New message" | +| `.hidden` | "SimpleX" | "New message" | + +### Key Internal Types + +| Type | Purpose | Line | +|------|---------|------| +| [`NSENotificationData`](../../SimpleX NSE/NotificationService.swift#L27) | Enum of possible notification payloads | [27](../../SimpleX NSE/NotificationService.swift#L27) | +| [`NSEThreads`](../../SimpleX NSE/NotificationService.swift#L82) | Concurrency coordinator for multiple NSE instances | [82](../../SimpleX NSE/NotificationService.swift#L82) | +| [`NotificationEntity`](../../SimpleX NSE/NotificationService.swift#L245) | Per-connection processing state | [245](../../SimpleX NSE/NotificationService.swift#L245) | +| [`NotificationService`](../../SimpleX NSE/NotificationService.swift#L287) | Main NSE class (`UNNotificationServiceExtension`) | [287](../../SimpleX NSE/NotificationService.swift#L287) | +| [`NSEChatState`](../../SimpleX NSE/NotificationService.swift#L781) | Singleton managing NSE lifecycle state | [781](../../SimpleX NSE/NotificationService.swift#L781) | + +### Key Internal Functions + +| Function | Purpose | Line | +|----------|---------|------| +| [`startChat()`](../../SimpleX NSE/NotificationService.swift#L836) | Initializes Haskell core for NSE | [836](../../SimpleX NSE/NotificationService.swift#L836) | +| [`doStartChat()`](../../SimpleX NSE/NotificationService.swift#L861) | Performs actual chat initialization (migration, config) | [861](../../SimpleX NSE/NotificationService.swift#L861) | +| [`activateChat()`](../../SimpleX NSE/NotificationService.swift#L907) | Reactivates suspended chat controller | [907](../../SimpleX NSE/NotificationService.swift#L907) | +| [`suspendChat(_:)`](../../SimpleX NSE/NotificationService.swift#L921) | Suspends chat controller with timeout | [921](../../SimpleX NSE/NotificationService.swift#L921) | +| [`receiveMessages()`](../../SimpleX NSE/NotificationService.swift#L954) | Main message-receive loop | [954](../../SimpleX NSE/NotificationService.swift#L954) | +| [`receivedMsgNtf(_:)`](../../SimpleX NSE/NotificationService.swift#L1003) | Maps chat events to notification data | [1003](../../SimpleX NSE/NotificationService.swift#L1003) | +| [`receiveNtfMessages(_:)`](../../SimpleX NSE/NotificationService.swift#L403) | Orchestrates notification message fetch and delivery | [403](../../SimpleX NSE/NotificationService.swift#L403) | +| [`deliverBestAttemptNtf()`](../../SimpleX NSE/NotificationService.swift#L604) | Delivers the best available notification content | [604](../../SimpleX NSE/NotificationService.swift#L604) | +| [`didReceive(_:withContentHandler:)`](../../SimpleX%20NSE/NotificationService.swift#L300) | Main NSE entry point -- processes incoming notification | [300](../../SimpleX%20NSE/NotificationService.swift#L300) | + +--- + +## 5. Token Lifecycle + +### Registration Flow + +``` +1. App starts → AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken + └── ChatModel.deviceToken = token + +2. Token registration (when chat running and token available): + └── apiRegisterToken(token, notificationMode) + └── Response: ntfToken(token, status, ntfMode, ntfServer) + └── ChatModel.tokenStatus = status + +3. Token verification (if server requires): + └── apiVerifyToken(token, nonce, code) + └── ChatModel.tokenRegistered = true + +4. Token check (periodic): + └── apiCheckToken(token) + └── Updates ChatModel.tokenStatus +``` + +### Token States (NtfTknStatus) + +| Status | Description | +|--------|-------------| +| `.new` | Token just registered, not yet verified | +| `.registered` | Token registered with notification server | +| `.confirmed` | Token confirmed and ready | +| `.active` | Token actively receiving notifications | +| `.expired` | Token expired, needs re-registration | +| `.invalid` | Token invalid, needs new registration | +| `.invalidBad` | Token invalid due to bad data | +| `.invalidTopic` | Token invalid due to wrong topic | +| `.invalidExpired` | Token invalid because it expired | +| `.invalidUnregistered` | Token invalid, was unregistered | + +### Token Deletion + +```swift +ChatCommand.apiDeleteToken(token: DeviceToken) +``` + +Called when: +- User switches to `.off` notification mode +- User deletes their profile +- Token becomes invalid and needs replacement + +--- + +## 6. Notification Categories & Actions + +Registered in [`NtfManager.registerCategories()`](../../Shared/Model/NtfManager.swift#L156): + +### Contact Request Category + +```swift +// Category: "NTF_CAT_CONTACT_REQUEST" +// Actions: +// - "NTF_ACT_ACCEPT_CONTACT": Accept contact request +``` + +When user taps "Accept" on a contact request notification: +1. `processNotificationResponse()` detects `ntfActionAcceptContact` +2. Calls `apiAcceptContact(incognito: false, contactReqId:)` +3. Navigates to the new contact's chat + +### Call Invitation Category + +```swift +// Category: "NTF_CAT_CALL_INVITATION" +// Actions: +// - "NTF_ACT_ACCEPT_CALL": Accept incoming call +// - "NTF_ACT_REJECT_CALL": Reject incoming call +``` + +When user taps "Accept" / "Reject" on a call notification: +1. `processNotificationResponse()` detects the action +2. Sets `ChatModel.ntfCallInvitationAction = (chatId, .accept/.reject)` +3. Call controller picks up the pending action + +### Message Category + +Standard tap-to-open behavior navigates to the chat. + +### Many Events Category + +Batch notification for multiple events -- navigates to the app without specific chat context. + +--- + +## 7. Badge Management + +The app icon badge shows the total unread message count: + +```swift +// Updated when: +// 1. App enters background: +NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) + +// 2. Messages are read: +// Badge is recalculated and updated + +// 3. NSE receives notification: +// NSE updates badge based on its count +``` + +`totalUnreadCountForAllUsers()` sums unread counts across all user profiles (not just the active user). + +### NSE Badge Handling + +| Method | Purpose | Line | +|--------|---------|------| +| [`setBadgeCount()`](../../SimpleX NSE/NotificationService.swift#L592) | Increments badge via `ntfBadgeCountGroupDefault` | [592](../../SimpleX NSE/NotificationService.swift#L592) | +| [`setNtfBadgeCount(_:)`](../../Shared/Model/NtfManager.swift#L264) | Sets badge on `UIApplication` | [264](../../Shared/Model/NtfManager.swift#L264) | +| [`changeNtfBadgeCount(by:)`](../../Shared/Model/NtfManager.swift#L270) | Adjusts badge by delta | [270](../../Shared/Model/NtfManager.swift#L270) | + +--- + +## 8. Background Tasks + +**File**: [`Shared/Model/BGManager.swift`](../../Shared/Model/BGManager.swift) + +### [BGManager](../../Shared/Model/BGManager.swift#L30) + +```swift +class BGManager { + static let shared = BGManager() + func register() // Register BGAppRefreshTask handlers + func schedule() // Schedule next background refresh +} +``` + +| Method | Purpose | Line | +|--------|---------|------| +| [`register()`](../../Shared/Model/BGManager.swift#L38) | Registers `BGAppRefreshTask` handler with iOS | [38](../../Shared/Model/BGManager.swift#L38) | +| [`schedule()`](../../Shared/Model/BGManager.swift#L46) | Schedules next background refresh request | [46](../../Shared/Model/BGManager.swift#L46) | +| [`handleRefresh(_:)`](../../Shared/Model/BGManager.swift#L74) | Processes background refresh task | [74](../../Shared/Model/BGManager.swift#L74) | +| [`completionHandler(_:)`](../../Shared/Model/BGManager.swift#L95) | Creates completion callback with cleanup | [95](../../Shared/Model/BGManager.swift#L95) | +| [`receiveMessages(_:)`](../../Shared/Model/BGManager.swift#L112) | Activates chat and receives pending messages | [112](../../Shared/Model/BGManager.swift#L112) | + +### Background Refresh (Periodic Mode) + +When notification mode is `.periodic`: + +1. `BGManager.schedule()` is called when app enters background +2. iOS wakes the app in the background approximately every 20 minutes +3. `BGAppRefreshTask` handler: + - Activates the chat engine: `apiActivateChat(restoreChat: true)` + - Checks for new messages + - Creates local notifications for any new messages + - Suspends chat: `apiSuspendChat(timeoutMicroseconds:)` + - Schedules next refresh +4. Must complete within ~30 seconds or iOS terminates the task + +### Background Task Protection + +All API calls use `beginBGTask()` / `endBackgroundTask()` to request extra execution time: + +```swift +func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { + var id: UIBackgroundTaskIdentifier! + // ... + id = UIApplication.shared.beginBackgroundTask(expirationHandler: endTask) + return endTask +} +``` + +Maximum task duration: `maxTaskDuration = 15` seconds. + +--- + +## Notification Content Builders + +**File**: [`SimpleXChat/Notifications.swift`](../../SimpleXChat/Notifications.swift) + +| Function | Purpose | Line | +|----------|---------|------| +| [`createContactRequestNtf()`](../../SimpleXChat/Notifications.swift#L27) | Builds notification for incoming contact request | [L27](../../SimpleXChat/Notifications.swift#L27) | +| [`createContactConnectedNtf()`](../../SimpleXChat/Notifications.swift#L46) | Builds notification for contact connected event | [L46](../../SimpleXChat/Notifications.swift#L46) | +| [`createMessageReceivedNtf()`](../../SimpleXChat/Notifications.swift#L66) | Builds notification for received message | [L66](../../SimpleXChat/Notifications.swift#L66) | +| [`createCallInvitationNtf()`](../../SimpleXChat/Notifications.swift#L86) | Builds notification for incoming call | [L86](../../SimpleXChat/Notifications.swift#L86) | +| [`createConnectionEventNtf()`](../../SimpleXChat/Notifications.swift#L102) | Builds notification for connection events | [L102](../../SimpleXChat/Notifications.swift#L102) | +| [`createErrorNtf()`](../../SimpleXChat/Notifications.swift#L134) | Builds notification for database/encryption errors | [L134](../../SimpleXChat/Notifications.swift#L134) | +| [`createAppStoppedNtf()`](../../SimpleXChat/Notifications.swift#L160) | Builds notification when app is stopped | [L160](../../SimpleXChat/Notifications.swift#L160) | +| [`createNotification()`](../../SimpleXChat/Notifications.swift#L175) | Generic notification builder (used by all above) | [L175](../../SimpleXChat/Notifications.swift#L175) | +| [`hideSecrets()`](../../SimpleXChat/Notifications.swift#L200) | Redacts secret-formatted text in previews | [L200](../../SimpleXChat/Notifications.swift#L200) | + +--- + +## Source Files + +| File | Path | +|------|------| +| Notification manager | [`Shared/Model/NtfManager.swift`](../../Shared/Model/NtfManager.swift) | +| Background manager | [`Shared/Model/BGManager.swift`](../../Shared/Model/BGManager.swift) | +| Notification types | [`SimpleXChat/Notifications.swift`](../../SimpleXChat/Notifications.swift) | +| NSE service | [`SimpleX NSE/NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) | +| App delegate (token) | `Shared/AppDelegate.swift` | +| Notification settings UI | `Shared/Views/UserSettings/NotificationsView.swift` | diff --git a/apps/ios/spec/services/theme.md b/apps/ios/spec/services/theme.md new file mode 100644 index 0000000000..321f3307f9 --- /dev/null +++ b/apps/ios/spec/services/theme.md @@ -0,0 +1,383 @@ +# SimpleX Chat iOS -- Theme Engine + +> Technical specification for the theming system: ThemeManager, default themes, customization layers, wallpapers, and YAML export. +> +> Related specs: [State Management](../state.md) | [Architecture](../architecture.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) | [`ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | [`ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | [`Theme.swift`](../../Shared/Theme/Theme.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ThemeManager](#2-thememanager) +3. [Default Themes](#3-default-themes) +4. [Customization Layers](#4-customization-layers) +5. [Color System](#5-color-system) +6. [Wallpapers](#6-wallpapers) +7. [Chat Bubble Styling](#7-chat-bubble-styling) +8. [Color Scheme Mode](#8-color-scheme-mode) +9. [YAML Export/Import](#9-yaml-exportimport) + +--- + +## 1. Overview + +The theme engine provides a layered customization system where themes can be overridden at multiple levels: global defaults, per-user, and per-chat. + +``` +Theme Resolution Order (most specific wins): +┌─────────────────────┐ +│ Per-chat override │ apiSetChatUIThemes(chatId:, themes:) +├─────────────────────┤ +│ Per-user override │ apiSetUserUIThemes(userId:, themes:) +├─────────────────────┤ +│ App settings theme │ themeOverridesDefault (UserDefaults) +├─────────────────────┤ +│ Base theme │ Light / Dark / SimpleX / Black +└─────────────────────┘ +``` + +The resolved theme is published as `AppTheme.shared` and consumed by all SwiftUI views via `@EnvironmentObject`. + +--- + +## 2. [ThemeManager](../../Shared/Theme/ThemeManager.swift) (L15) + +**File**: [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) + +Static utility class that resolves the current theme by merging all customization layers. + +### [ActiveTheme](../../Shared/Theme/ThemeManager.swift#L17) + +The resolved theme output: + +```swift +struct ActiveTheme: Equatable { + let name: String // Theme name (e.g., "light", "dark", "simplex", "black", "system") + let base: DefaultTheme // Base theme enum + let colors: Colors // Resolved color palette + let appColors: AppColors // App-specific colors (sent/received bubbles, etc.) + var wallpaper: AppWallpaper // Resolved wallpaper +} +``` + +### Key Static Methods + +| Method | Purpose | Line | +|--------|---------|------| +| [`applyTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L124) | Apply a theme by name, updates `AppTheme.shared` | [L124](../../Shared/Theme/ThemeManager.swift#L124) | +| [`currentColors(...)`](../../Shared/Theme/ThemeManager.swift#L64) | Resolve full theme from all layers | [L64](../../Shared/Theme/ThemeManager.swift#L64) | +| [`defaultActiveTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L48) | Get default theme override from app settings | [L48](../../Shared/Theme/ThemeManager.swift#L48) | +| [`currentThemeOverridesForExport(...)`](../../Shared/Theme/ThemeManager.swift#L105) | Get current overrides for YAML export | [L105](../../Shared/Theme/ThemeManager.swift#L105) | +| [`adjustWindowStyle()`](../../Shared/Theme/ThemeManager.swift#L136) | Adjust window style after theme change | [L136](../../Shared/Theme/ThemeManager.swift#L136) | +| [`changeDarkTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L166) | Change the dark theme variant | [L166](../../Shared/Theme/ThemeManager.swift#L166) | +| [`saveAndApplyThemeColor(...)`](../../Shared/Theme/ThemeManager.swift#L173) | Save and apply a theme color override | [L173](../../Shared/Theme/ThemeManager.swift#L173) | +| [`applyThemeColor(...)`](../../Shared/Theme/ThemeManager.swift#L186) | Apply a theme color to a binding | [L186](../../Shared/Theme/ThemeManager.swift#L186) | +| [`saveAndApplyWallpaper(...)`](../../Shared/Theme/ThemeManager.swift#L191) | Save and apply a wallpaper change | [L191](../../Shared/Theme/ThemeManager.swift#L191) | +| [`copyFromSameThemeOverrides(...)`](../../Shared/Theme/ThemeManager.swift#L213) | Copy overrides from matching theme | [L213](../../Shared/Theme/ThemeManager.swift#L213) | +| [`applyWallpaper(...)`](../../Shared/Theme/ThemeManager.swift#L256) | Apply wallpaper to a binding | [L256](../../Shared/Theme/ThemeManager.swift#L256) | +| [`saveAndApplyThemeOverrides(...)`](../../Shared/Theme/ThemeManager.swift#L267) | Save and apply full theme overrides | [L267](../../Shared/Theme/ThemeManager.swift#L267) | +| [`resetAllThemeColors(_:)`](../../Shared/Theme/ThemeManager.swift#L288) | Reset all color overrides (CodableDefault) | [L288](../../Shared/Theme/ThemeManager.swift#L288) | +| [`resetAllThemeColors(_:)`](../../Shared/Theme/ThemeManager.swift#L302) | Reset all color overrides (Binding) | [L302](../../Shared/Theme/ThemeManager.swift#L302) | +| [`removeTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L311) | Remove a saved theme by ID | [L311](../../Shared/Theme/ThemeManager.swift#L311) | + +### Theme Resolution Algorithm + +[`currentColors()`](../../Shared/Theme/ThemeManager.swift#L64) in `ThemeManager.swift`: + +1. Determine base theme from `currentThemeDefault`: + - If `"system"`: use light or dark based on [`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95) + - Dark mode maps to `systemDarkThemeDefault` (Dark, SimpleX, or Black) +2. Get base color palette ([`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650), [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629), [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671), [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692)) +3. Look up app settings theme override (`themeOverridesDefault`) +4. Look up per-user theme override (`User.uiThemes`) +5. Look up per-chat theme override (from ChatInfo) +6. Look up wallpaper preset colors (if wallpaper has preset color overrides) +7. Merge layers: base <- app override <- preset wallpaper colors <- per-user <- per-chat +8. Return `ActiveTheme` with resolved colors, app colors, and wallpaper + +--- + +## 3. Default Themes + +Four built-in themes with pre-defined color palettes: + +| Theme | Enum | Key Characteristics | +|-------|------|---------------------| +| **Light** | `DefaultTheme.LIGHT` | White background, standard colors | +| **Dark** | `DefaultTheme.DARK` | Dark gray background, light text | +| **SimpleX** | `DefaultTheme.SIMPLEX` | Brand purple accents, dark background | +| **Black** | `DefaultTheme.BLACK` | Pure black background (OLED), high contrast | + +### [DefaultTheme](../../SimpleXChat/Theme/ThemeTypes.swift#L13) Enum + +```swift +enum DefaultTheme { + case LIGHT + case DARK + case SIMPLEX + case BLACK + + static let SYSTEM_THEME_NAME = "SYSTEM" + + var themeName: String { ... } + var mode: DefaultThemeMode { ... } // .light or .dark +} +``` + +### Color Palettes + +Each base theme defines two palette types: +- [`Colors`](../../SimpleXChat/Theme/ThemeTypes.swift#L44): Standard UI colors (primary, background, surface, error, onBackground, onSurface) +- [`AppColors`](../../SimpleXChat/Theme/ThemeTypes.swift#L90): App-specific colors (sentMessage, receivedMessage, title, primaryVariant2) + +Palette instances: +- [`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650) / [`LightColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L662) +- [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629) / [`DarkColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L641) +- [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671) / [`SimplexColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L683) +- [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692) / [`BlackColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L704) + +--- + +## 4. Customization Layers + +### Layer 1: App Settings Theme + +Stored in `themeOverridesDefault` (UserDefaults). Contains `[ThemeOverrides]` -- an array of theme overrides, one per base theme. + +#### [`ThemeOverrides`](../../SimpleXChat/Theme/ThemeTypes.swift#L385) + +```swift +struct ThemeOverrides: Codable { + var base: DefaultTheme + var colors: ThemeColors? // Color overrides + var wallpaper: ThemeWallpaper? // Wallpaper setting +} +``` + +### Layer 2: Per-User Theme + +Stored on the `User` object (`User.uiThemes: ThemeModeOverrides?`), persisted in the Haskell database via `apiSetUserUIThemes(userId:, themes:)`. + +#### [`ThemeModeOverrides`](../../SimpleXChat/Theme/ThemeTypes.swift#L570) + +```swift +struct ThemeModeOverrides: Codable { + var light: ThemeModeOverride? + var dark: ThemeModeOverride? +} +``` + +#### [`ThemeModeOverride`](../../SimpleXChat/Theme/ThemeTypes.swift#L585) + +```swift +struct ThemeModeOverride: Codable { + var mode: DefaultThemeMode? + var colors: ThemeColors? + var wallpaper: ThemeWallpaper? + var type: WallpaperType? // Computed from wallpaper +} +``` + +### Layer 3: Per-Chat Theme + +Stored per-chat via `apiSetChatUIThemes(chatId:, themes:)`. Same `ThemeModeOverrides` structure. + +### Override Merging + +Colors are merged field-by-field: if a more-specific layer defines a color, it overrides; if nil, falls through to the next layer. + +--- + +## 5. Color System + +**File**: [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) + +### [ThemeColors](../../SimpleXChat/Theme/ThemeTypes.swift#L230) + +Overridable color definitions: + +```swift +struct ThemeColors: Codable { + var primary: String? // Primary brand color + var primaryVariant: String? // Primary variant + var secondary: String? // Secondary color + var secondaryVariant: String? // Secondary variant + var background: String? // Main background + var surface: String? // Card/surface background + var title: String? // Title text color + var primaryVariant2: String? // Additional variant + var sentMessage: String? // Sent message bubble + var sentQuote: String? // Sent quote background + var receivedMessage: String? // Received message bubble + var receivedQuote: String? // Received quote background +} +``` + +Colors are stored as hex strings (e.g., `"#FF6600"`) and converted to SwiftUI `Color` values at resolution time. + +### [Colors](../../SimpleXChat/Theme/ThemeTypes.swift#L44) (Resolved Palette) + +```swift +struct Colors { + var isLight: Bool + var primary: Color + var primaryVariant: Color + var secondary: Color + var secondaryVariant: Color + var background: Color + var surface: Color + var error: Color + var onBackground: Color + var onSurface: Color + // ... etc +} +``` + +### [AppColors](../../SimpleXChat/Theme/ThemeTypes.swift#L90) (Resolved App-Specific) + +```swift +struct AppColors { + var title: Color + var primaryVariant2: Color + var sentMessage: Color + var sentQuote: Color + var receivedMessage: Color + var receivedQuote: Color +} +``` + +--- + +## 6. Wallpapers + +**File**: [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) + +### [Preset Wallpapers](../../SimpleXChat/Theme/ChatWallpaperTypes.swift#L13) + +6 built-in wallpaper presets: + +| Preset | ID | Description | +|--------|-----|-------------| +| Cats | `cats` | Cat-themed pattern | +| Flowers | `flowers` | Floral pattern | +| Hearts | `hearts` | Heart pattern | +| Kids | `kids` | Children's pattern | +| School | `school` | School/notebook pattern (default) | +| Travel | `travel` | Travel-themed pattern | + +Each preset defines per-theme color tints (`PresetWallpaper.colors[DefaultTheme]`) that subtly adjust the color palette to complement the wallpaper. + +### Custom Wallpapers + +Users can set a custom image as wallpaper: +- Stored in `Documents/wallpapers/` directory +- Scaled and tiled to fill the chat background +- Custom wallpapers can be combined with color overrides + +### [WallpaperType](../../SimpleXChat/Theme/ChatWallpaperTypes.swift#L311) + +```swift +enum WallpaperType { + case preset(filename: String, scale: Float?) // Built-in wallpaper + case image(filename: String, scale: Float?) // Custom image + case empty // No wallpaper +} +``` + +### [AppWallpaper](../../SimpleXChat/Theme/ThemeTypes.swift#L142) (Resolved) + +```swift +struct AppWallpaper { + var background: Color? // Background color override + var tint: Color? // Tint/overlay color + var type: WallpaperType +} +``` + +--- + +## 7. Chat Bubble Styling + +Configurable bubble appearance properties: + +| Property | Description | Stored In | +|----------|-------------|-----------| +| `chatItemRoundness` | Corner radius of message bubbles | App settings | +| `chatItemTail` | Whether bubbles have a tail/arrow | App settings | +| Avatar corner radius | Roundness of profile avatars | App settings | + +These are configured in [`Shared/Views/UserSettings/AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) ([L26](../../Shared/Views/UserSettings/AppearanceSettings.swift#L26)). + +--- + +## 8. Color Scheme Mode + +### System Follow + +When theme is set to `"system"` (DefaultTheme.SYSTEM_THEME_NAME): +- Light mode: uses `DefaultTheme.LIGHT` palette +- Dark mode: uses the configured dark theme (`systemDarkThemeDefault`), which can be Dark, SimpleX, or Black + +### Forced Mode + +Users can force light or dark mode regardless of system setting by selecting a specific theme other than "system". + +### Detection + +[`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95): + +```swift +var systemInDarkThemeCurrently: Bool { + return UITraitCollection.current.userInterfaceStyle == .dark +} +``` + +`ChatModel.currentUser` setter triggers [`ThemeManager.applyTheme()`](../../Shared/Theme/ThemeManager.swift#L124) to handle per-user theme overrides when switching users. + +--- + +## 9. YAML Export/Import + +Theme configurations can be exported as YAML for sharing: + +### Export + +[`ThemeManager.currentThemeOverridesForExport()`](../../Shared/Theme/ThemeManager.swift#L105) generates a `ThemeOverrides` representing the current resolved theme, which is then serialized to YAML using the Yams library. + +### Import + +YAML theme strings are parsed back into `ThemeOverrides` and applied as app settings theme overrides. + +Key functions in [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift): + +| Function | Purpose | Line | +|----------|---------|------| +| [`ImportExportThemeSection`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L603) | UI section for import/export controls | [L603](../../Shared/Views/UserSettings/AppearanceSettings.swift#L603) | +| [`ThemeImporter`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L640) | ViewModifier for YAML file import | [L640](../../Shared/Views/UserSettings/AppearanceSettings.swift#L640) | +| [`decodeYAML(_:)`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1150) | Parse YAML string into Decodable type | [L1150](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1150) | +| [`encodeThemeOverrides(_:)`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1160) | Encode ThemeOverrides to YAML string | [L1160](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1160) | + +### Toolbar Material + +[`ToolbarMaterial`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L319) controls the navigation bar appearance: +- Configurable opacity/material (translucent, opaque) +- Stored in app settings + +--- + +## Source Files + +| File | Path | Key Definitions | +|------|------|-----------------| +| Theme manager | [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | `ThemeManager` (L15), `ActiveTheme` (L17) | +| Theme types & colors | [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | `DefaultTheme` (L13), `Colors` (L44), `AppColors` (L90), `AppWallpaper` (L142), `ThemeColors` (L230), `ThemeWallpaper` (L302), `ThemeOverrides` (L385), `ThemeModeOverrides` (L570), `ThemeModeOverride` (L585) | +| Wallpaper types | [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | `PresetWallpaper` (L13), `WallpaperType` (L311) | +| Color utilities | [`SimpleXChat/Theme/Color.swift`](../../SimpleXChat/Theme/Color.swift) | Hex color conversion | +| App theme observable | [`Shared/Theme/Theme.swift`](../../Shared/Theme/Theme.swift) | `AppTheme` (L22), `CurrentColors` (L14), `systemInDarkThemeCurrently` (L95) | +| Appearance settings UI | [`Shared/Views/UserSettings/AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) | `AppearanceSettings` (L26), `ToolbarMaterial` (L319), `ImportExportThemeSection` (L603) | +| Theme mode editor | `Shared/Views/Helpers/ThemeModeEditor.swift` | Theme mode selection UI | +| Haskell theme types | `../../src/Simplex/Chat/Types/UITheme.hs` | Server-side theme persistence | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md new file mode 100644 index 0000000000..db16aa2936 --- /dev/null +++ b/apps/ios/spec/state.md @@ -0,0 +1,517 @@ +# SimpleX Chat iOS -- State Management + +**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1404) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5377) + +> Technical specification for the app's state architecture: ChatModel, ItemsModel, Chat, ChatInfo, and preference storage. +> +> Related specs: [Architecture](architecture.md) | [API Reference](api.md) | [README](README.md) +> Related product: [Concept Index](../product/concepts.md) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatModel -- Primary App State](#2-chatmodel) +3. [ItemsModel -- Per-Chat Message State](#3-itemsmodel) +4. [ChatTagsModel -- Tag Filtering State](#4-chattagsmodel) +5. [ChannelRelaysModel -- Channel Relay State](#5-channelrelaysmodel) +6. [Chat -- Single Conversation State](#6-chat) +7. [ChatInfo -- Conversation Metadata](#7-chatinfo) +8. [State Flow](#8-state-flow) +9. [Preference Storage](#9-preference-storage) + +--- + +## 1. Overview + +The app uses SwiftUI's `ObservableObject` pattern for reactive state management. The state hierarchy is: + +``` +ChatModel (singleton -- global app state) +├── currentUser: User? +├── users: [UserInfo] +├── chats: [Chat] (chat list) +├── chatId: String? (active chat ID) +├── im: ItemsModel.shared (primary chat items) +├── secondaryIM: ItemsModel? (secondary chat items, e.g. support scope) +├── activeCall: Call? +├── callInvitations: [ChatId: RcvCallInvitation] +├── deviceToken / savedToken / tokenStatus +├── notificationMode: NotificationsMode +├── onboardingStage: OnboardingStage? +├── migrationState: MigrationToState? +└── ... (50+ @Published properties) + +ItemsModel (singleton + secondary instances -- per-chat message state) +├── reversedChatItems: [ChatItem] (messages in reverse order) +├── chatState: ActiveChatState (pagination/split state) +├── isLoading / showLoadingProgress +└── preloadState: PreloadState + +Chat (per-conversation -- one per entry in chat list) +├── chatInfo: ChatInfo (type + metadata) +├── chatItems: [ChatItem] (preview items) +└── chatStats: ChatStats (unread counts) + +ChatTagsModel (singleton -- filter state) +├── userTags: [ChatTag] +├── activeFilter: ActiveFilter? +├── presetTags: [PresetTag: Int] +└── unreadTags: [Int64: Int] +``` + +--- + +## 2. [ChatModel](../Shared/Model/ChatModel.swift#L353-L1289) + +**Class**: `final class ChatModel: ObservableObject` +**Singleton**: `ChatModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L353) + +### Key Published Properties + +#### App Lifecycle +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L354](../Shared/Model/ChatModel.swift#L354) | +| `chatInitialized` | `Bool` | Whether chat has been initialized | [L363](../Shared/Model/ChatModel.swift#L363) | +| `chatRunning` | `Bool?` | Whether chat engine is running | [L364](../Shared/Model/ChatModel.swift#L364) | +| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L365](../Shared/Model/ChatModel.swift#L365) | +| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L366](../Shared/Model/ChatModel.swift#L366) | +| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L367](../Shared/Model/ChatModel.swift#L367) | +| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L368](../Shared/Model/ChatModel.swift#L368) | +| `migrationState` | `MigrationToState?` | Device migration state | [L417](../Shared/Model/ChatModel.swift#L417) | + +#### User State +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L357](../Shared/Model/ChatModel.swift#L357) | +| `users` | `[UserInfo]` | All user profiles | [L362](../Shared/Model/ChatModel.swift#L362) | +| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L356](../Shared/Model/ChatModel.swift#L356) | + +#### Chat List +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chats` | `[Chat]` (private set) | All conversations for current user | [L374](../Shared/Model/ChatModel.swift#L374) | +| `deletedChats` | `Set` | Chat IDs pending deletion animation | [L375](../Shared/Model/ChatModel.swift#L375) | + +#### Active Chat +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chatId` | `String?` | Currently open chat ID | [L377](../Shared/Model/ChatModel.swift#L377) | +| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L378](../Shared/Model/ChatModel.swift#L378) | +| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L379](../Shared/Model/ChatModel.swift#L379) | +| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L380](../Shared/Model/ChatModel.swift#L380) | +| `chatToTop` | `String?` | Chat to scroll to top | [L381](../Shared/Model/ChatModel.swift#L381) | +| `groupMembers` | `[GMember]` | Members of active group | [L382](../Shared/Model/ChatModel.swift#L382) | +| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L383](../Shared/Model/ChatModel.swift#L383) | +| `membersLoaded` | `Bool` | Whether members have been loaded | [L384](../Shared/Model/ChatModel.swift#L384) | +| `secondaryIM` | `ItemsModel?` | Secondary items model (e.g. support chat scope) | [L435](../Shared/Model/ChatModel.swift#L435) | + +#### Authentication +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L371](../Shared/Model/ChatModel.swift#L371) | +| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L372](../Shared/Model/ChatModel.swift#L372) | + +#### Notifications +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `deviceToken` | `DeviceToken?` | Current APNs device token | [L395](../Shared/Model/ChatModel.swift#L395) | +| `savedToken` | `DeviceToken?` | Previously saved token | [L396](../Shared/Model/ChatModel.swift#L396) | +| `tokenRegistered` | `Bool` | Whether token is registered with server | [L397](../Shared/Model/ChatModel.swift#L397) | +| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L399](../Shared/Model/ChatModel.swift#L399) | +| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L400](../Shared/Model/ChatModel.swift#L400) | +| `notificationServer` | `String?` | Notification server URL | [L401](../Shared/Model/ChatModel.swift#L401) | +| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L402](../Shared/Model/ChatModel.swift#L402) | +| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L369](../Shared/Model/ChatModel.swift#L369) | +| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L404](../Shared/Model/ChatModel.swift#L404) | +| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L405](../Shared/Model/ChatModel.swift#L405) | + +#### Calls +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L407](../Shared/Model/ChatModel.swift#L407) | +| `activeCall` | `Call?` | Currently active call | [L408](../Shared/Model/ChatModel.swift#L408) | +| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L409](../Shared/Model/ChatModel.swift#L409) | +| `showCallView` | `Bool` | Whether to show full-screen call UI | [L410](../Shared/Model/ChatModel.swift#L410) | +| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L411](../Shared/Model/ChatModel.swift#L411) | + +#### Remote Desktop +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L414](../Shared/Model/ChatModel.swift#L414) | + +#### Misc +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `userAddress` | `UserContactLink?` | User's SimpleX address | [L391](../Shared/Model/ChatModel.swift#L391) | +| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L392](../Shared/Model/ChatModel.swift#L392) | +| `appOpenUrl` | `URL?` | URL opened while app active | [L393](../Shared/Model/ChatModel.swift#L393) | +| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L394](../Shared/Model/ChatModel.swift#L394) | +| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L416](../Shared/Model/ChatModel.swift#L416) | +| `draft` | `ComposeState?` | Saved compose draft | [L420](../Shared/Model/ChatModel.swift#L420) | +| `draftChatId` | `String?` | Chat ID for saved draft | [L421](../Shared/Model/ChatModel.swift#L421) | +| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L422](../Shared/Model/ChatModel.swift#L422) | +| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L424](../Shared/Model/ChatModel.swift#L424) | +| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L419](../Shared/Model/ChatModel.swift#L419) | + +### Non-Published Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L426](../Shared/Model/ChatModel.swift#L426) | +| `filesToDelete` | `Set` | Files queued for deletion | [L428](../Shared/Model/ChatModel.swift#L428) | +| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L432](../Shared/Model/ChatModel.swift#L432) | + +### Key Methods + +| Method | Description | Line | +|--------|-------------|------| +| `getUser(_ userId:)` | Find user by ID | [L455](../Shared/Model/ChatModel.swift#L455) | +| `updateUser(_ user:)` | Update user in list and current | [L466](../Shared/Model/ChatModel.swift#L466) | +| `removeUser(_ user:)` | Remove user from list | [L476](../Shared/Model/ChatModel.swift#L476) | +| `getChat(_ id:)` | Find chat by ID | [L487](../Shared/Model/ChatModel.swift#L487) | +| `addChat(_ chat:)` | Add chat to list | [L542](../Shared/Model/ChatModel.swift#L542) | +| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L556](../Shared/Model/ChatModel.swift#L556) | +| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L608](../Shared/Model/ChatModel.swift#L608) | +| `removeChat(_ id:)` | Remove chat from list | [L1217](../Shared/Model/ChatModel.swift#L1217) | +| `popChat(_ id:)` | Move chat to top of list | [L1193](../Shared/Model/ChatModel.swift#L1193) | +| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1093](../Shared/Model/ChatModel.swift#L1093) | + +--- + +## 3. [ItemsModel](../Shared/Model/ChatModel.swift#L74-L174) + +**Class**: `class ItemsModel: ObservableObject` +**Primary singleton**: `ItemsModel.shared` +**Secondary instances**: Created via `ItemsModel.loadSecondaryChat()` for scope-based views (e.g., group member support chat) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L74) + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L80](../Shared/Model/ChatModel.swift#L80) | +| `itemAdded` | `Bool` | Flag indicating a new item was added | [L83](../Shared/Model/ChatModel.swift#L83) | +| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L87](../Shared/Model/ChatModel.swift#L87) | +| `isLoading` | `Bool` | Whether messages are currently loading | [L91](../Shared/Model/ChatModel.swift#L91) | +| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L92](../Shared/Model/ChatModel.swift#L92) | +| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L77](../Shared/Model/ChatModel.swift#L77) | +| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L76](../Shared/Model/ChatModel.swift#L76) | + +### Computed Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L97](../Shared/Model/ChatModel.swift#L97) | +| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L159](../Shared/Model/ChatModel.swift#L159) | +| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L167](../Shared/Model/ChatModel.swift#L167) | + +### Throttling + +`ItemsModel` uses a custom publisher throttle (0.2 seconds) to batch rapid updates to `reversedChatItems` and prevent excessive SwiftUI re-renders: + +```swift +publisher + .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) + .sink { self.objectWillChange.send() } + .store(in: &bag) +``` + +Direct `@Published` properties (`isLoading`, `showLoadingProgress`) bypass throttling for immediate UI response. + +### Key Methods + +| Method | Description | Line | +|--------|-------------|------| +| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L117](../Shared/Model/ChatModel.swift#L117) | +| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L143](../Shared/Model/ChatModel.swift#L143) | +| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary ItemsModel instance | [L110](../Shared/Model/ChatModel.swift#L110) | + +### [SecondaryItemsModelFilter](../Shared/Model/ChatModel.swift#L58-L70) + +Used for secondary chat views (e.g., group member support scope, content type filter): + +```swift +enum SecondaryItemsModelFilter { + case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) + case msgContentTagContext(contentTag: MsgContentTag) +} +``` + +--- + +## 4. [ChatTagsModel](../Shared/Model/ChatModel.swift#L189-L291) + +**Class**: `class ChatTagsModel: ObservableObject` +**Singleton**: `ChatTagsModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L189) + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `userTags` | `[ChatTag]` | User-defined tags | [L192](../Shared/Model/ChatModel.swift#L192) | +| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L193](../Shared/Model/ChatModel.swift#L193) | +| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L194](../Shared/Model/ChatModel.swift#L194) | +| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L195](../Shared/Model/ChatModel.swift#L195) | + +### [ActiveFilter](../Shared/Views/ChatList/ChatListView.swift#L52) + +```swift +enum ActiveFilter { + case presetTag(PresetTag) // .favorites, .contacts, .groups, .business, .groupReports + case userTag(ChatTag) // User-defined tag + case unread // Unread conversations +} +``` + +--- + +## 5. [ChannelRelaysModel](../Shared/Model/ChatModel.swift#L336-L350) + +**Class**: `class ChannelRelaysModel: ObservableObject` +**Singleton**: `ChannelRelaysModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L336) + +Holds runtime relay state for the currently viewed channel. Used by `ChannelRelaysView` to display and manage relays. Reset when the view is dismissed. + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `groupId` | `Int64?` | Group ID of the channel whose relays are loaded | [L338](../Shared/Model/ChatModel.swift#L338) | +| `groupRelays` | `[GroupRelay]` | Current relay instances for the channel | [L339](../Shared/Model/ChatModel.swift#L339) | + +### Methods + +| Method | Description | Line | +|--------|-------------|------| +| `set(groupId:groupRelays:)` | Populate all properties at once | [L341](../Shared/Model/ChatModel.swift#L341) | +| `reset()` | Clear all properties to nil/empty | [L346](../Shared/Model/ChatModel.swift#L346) | + +--- + +## 6. [Chat](../Shared/Model/ChatModel.swift#L1301-L1353) + +**Class**: `final class Chat: ObservableObject, Identifiable, ChatLike` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1301) + +Represents a single conversation in the chat list. Each `Chat` is an independent observable object. + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1302](../Shared/Model/ChatModel.swift#L1302) | +| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1303](../Shared/Model/ChatModel.swift#L1303) | +| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1304](../Shared/Model/ChatModel.swift#L1304) | +| `created` | `Date` | Creation timestamp | [L1305](../Shared/Model/ChatModel.swift#L1305) | + +### [ChatStats](../SimpleXChat/ChatTypes.swift#L1881-L1903) + +```swift +struct ChatStats: Decodable, Hashable { + var unreadCount: Int = 0 + var unreadMentions: Int = 0 + var reportsCount: Int = 0 + var minUnreadItemId: Int64 = 0 + var unreadChat: Bool = false +} +``` + +### Computed Properties + +| Property | Description | Line | +|----------|-------------|------| +| `id` | Chat ID from `chatInfo.id` | [L1336](../Shared/Model/ChatModel.swift#L1336) | +| `viewId` | Unique view identity including creation time | [L1338](../Shared/Model/ChatModel.swift#L1338) | +| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1328](../Shared/Model/ChatModel.swift#L1328) | +| `supportUnreadCount` | Unread count for group support scope | [L1340](../Shared/Model/ChatModel.swift#L1340) | + +--- + +## 7. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1374-L1856) + +**Enum**: `public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable` +**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1374) + +Represents the type and metadata of a conversation: + +```swift +public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { + case direct(contact: Contact) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) + case local(noteFolder: NoteFolder) + case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) + case invalidJSON(json: Data?) +} +``` + +### Cases + +| Case | Associated Value | Description | +|------|-----------------|-------------| +| `.direct` | `Contact` | One-to-one conversation | +| `.group` | `GroupInfo, GroupChatScopeInfo?` | Group conversation (optional scope for member support threads) | +| `.local` | `NoteFolder` | Local notes (self-chat) | +| `.contactRequest` | `UserContactRequest` | Incoming contact request | +| `.contactConnection` | `PendingContactConnection` | Pending connection | +| `.invalidJSON` | `Data?` | Undecodable chat data | + +### Key Computed Properties on ChatInfo + +| Property | Type | Description | +|----------|------|-------------| +| `chatType` | `ChatType` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` | +| `id` | `ChatId` | Prefixed ID (e.g., `"@1"` for direct, `"#5"` for group) | +| `displayName` | `String` | Contact/group name | +| `image` | `String?` | Profile image (base64) | +| `chatSettings` | `ChatSettings?` | Notification/favorite settings | +| `chatTags` | `[Int64]?` | Assigned tag IDs | + +### Relay-Related Data Model (Channels) + +A **channel** is a group with `groupInfo.useRelays == true`. These types support the relay/channel infrastructure: + +#### New Fields on Existing Types + +| Type | Field | Type | Description | Line | +|------|-------|------|-------------|------| +| `User` | `userChatRelay` | `Bool` | Whether user acts as a chat relay | [L46](../SimpleXChat/ChatTypes.swift#L46) | +| `GroupInfo` | `useRelays` | `Bool` | Whether group uses relay infrastructure (channel mode) | [L2343](../SimpleXChat/ChatTypes.swift#L2343) | +| `GroupInfo` | `relayOwnStatus` | `RelayStatus?` | Current user's relay status in this group | [L2344](../SimpleXChat/ChatTypes.swift#L2344) | +| `GroupProfile` | `publicGroup` | `PublicGroupProfile?` | Channel-specific profile data (type, link, ID) | [L2472](../SimpleXChat/ChatTypes.swift#L2472) | + +#### New Types + +| Type | Kind | Description | Line | +|------|------|-------------|------| +| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive`, `.rsInactive`, `.rsRejected` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | +| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active/Inactive/Rejected | [L2565](../SimpleXChat/ChatTypes.swift#L2565) | +| `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) | +| `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) | + +#### New Enum Cases + +| Enum | Case | Description | Line | +|------|------|-------------|------| +| `GroupMemberRole` | `.relay` | Role for relay members (below `.observer`) | [L2807](../SimpleXChat/ChatTypes.swift#L2807) | +| `CIDirection` | `.channelRcv` | Message direction for channel-received messages (via relay) | [L3529](../SimpleXChat/ChatTypes.swift#L3529) | + +--- + +## 8. State Flow + +### App Start +``` +SimpleXApp.init() + → haskell_init() + → initChatAndMigrate() + → chat_migrate_init_key() -- creates/opens DB + → startChat(mainApp: true) -- starts core + → apiGetChats(userId) -- populates ChatModel.chats + → UI renders ChatListView +``` + +### Opening a Chat +``` +User taps chat in ChatListView + → ItemsModel.loadOpenChat(chatId) + → 250ms delay for navigation animation + → ChatModel.chatId = chatId + → loadChat(chatId:, im:) + → apiGetChat(chatId, pagination: .last(count: 50)) + → ItemsModel.reversedChatItems = [ChatItem] + → ChatView renders messages +``` + +### Receiving a Message (Event) +``` +Haskell core generates ChatEvent.newChatItems + → Event loop calls chat_recv_msg_wait + → Decoded as ChatEvent.newChatItems(user, chatItems) + → ChatModel updates: + 1. Insert new Chat items into ChatModel.chats (preview) + 2. If chat is open: insert into ItemsModel.reversedChatItems + 3. Update ChatStats (unread counts) + 4. Update ChatTagsModel (tag unread counts) + → SwiftUI re-renders affected views via @Published observation +``` + +### Sending a Message +``` +User taps send in ComposeView + → apiSendMessages(type, id, scope, live, ttl, composedMessages) + → Haskell processes, returns ChatResponse1.newChatItems + → ChatModel.chats updated with new preview + → ItemsModel.reversedChatItems gets new item + → ChatView scrolls to bottom, shows sent message +``` + +--- + +## 9. Preference Storage + +### UserDefaults (via @AppStorage) + +App-level UI settings stored in `UserDefaults.standard`: + +| Key Constant | Type | Description | +|--------------|------|-------------| +| `DEFAULT_PERFORM_LA` | `Bool` | Enable local authentication | +| `DEFAULT_PRIVACY_PROTECT_SCREEN` | `Bool` | Hide screen in app switcher | +| `DEFAULT_SHOW_LA_NOTICE` | `Bool` | Show LA setup notice | +| `DEFAULT_NOTIFICATION_ALERT_SHOWN` | `Bool` | Notification permission alert shown | +| `DEFAULT_CALL_KIT_CALLS_IN_RECENTS` | `Bool` | Show CallKit calls in recents | + +### GroupDefaults + +Settings shared between main app and extensions (NSE, SE) via app group `UserDefaults`: + +| Key | Description | +|-----|-------------| +| `appStateGroupDefault` | Current app state (.active/.suspended/.stopped) | +| `dbContainerGroupDefault` | Database container location (.group/.documents) | +| `ntfPreviewModeGroupDefault` | Notification preview mode | +| `storeDBPassphraseGroupDefault` | Whether to store DB passphrase | +| `callKitEnabledGroupDefault` | Whether CallKit is enabled | +| `onboardingStageDefault` | Current onboarding stage | +| `currentThemeDefault` | Current theme name | +| `systemDarkThemeDefault` | Dark mode theme name | +| `themeOverridesDefault` | Custom theme overrides | +| `currentThemeIdsDefault` | Active theme override IDs | + +### Keychain (KeyChain wrapper) + +Sensitive data stored in iOS Keychain: + +| Key | Description | +|-----|-------------| +| `kcDatabasePassword` | SQLite database encryption key | +| `kcAppPassword` | App lock password | +| `kcSelfDestructPassword` | Self-destruct trigger password | + +### Haskell DB (via apiSaveSettings / apiGetSettings) + +Chat-level preferences stored in the SQLite database (managed by Haskell core): + +- Per-contact preferences (timed messages, voice, calls, etc.) +- Per-group preferences +- Per-user notification settings +- Network configuration +- Server lists + +--- + +## Source Files + +| File | Path | +|------|------| +| ChatModel, ItemsModel, Chat, ChatTagsModel, ChannelRelaysModel | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift) | +| ChatInfo, User, Contact, GroupInfo, ChatItem | [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift) | +| ActiveFilter | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift#L52) | +| Preference defaults | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift), [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) | diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 923c1960c7..652737b4ca 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -13,15 +13,9 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 มีสี!"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[มีส่วนร่วม](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[ส่งอีเมลถึงเรา](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[ติดดาวบน GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**e2e encrypted** audio call" = "การโทรเสียงแบบ **encrypted จากต้นจนจบ**"; @@ -233,9 +227,6 @@ swipe action */ /* call status */ "accepted call" = "รับสายแล้ว"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "เพิ่มที่อยู่ลงในโปรไฟล์ของคุณ เพื่อให้ผู้ติดต่อของคุณสามารถแชร์กับผู้อื่นได้ การอัปเดตโปรไฟล์จะถูกส่งไปยังผู้ติดต่อของคุณ"; - /* No comment provided by engineer. */ "Add profile" = "เพิ่มโปรไฟล์"; @@ -362,9 +353,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "รับสาย"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "โปรโตคอลและโค้ดโอเพ่นซอร์ส – ใคร ๆ ก็สามารถเปิดใช้เซิร์ฟเวอร์ได้"; - /* No comment provided by engineer. */ "App build: %@" = "รุ่นแอป: %@"; @@ -626,7 +614,8 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm password" = "ยืนยันรหัสผ่าน"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "เชื่อมต่อ"; /* No comment provided by engineer. */ @@ -671,7 +660,7 @@ set passcode view */ /* alert title */ "Connection error" = "การเชื่อมต่อผิดพลาด"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "การเชื่อมต่อผิดพลาด (AUTH)"; /* chat list item title (it should not be shown */ @@ -719,15 +708,15 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "ดำเนินการต่อ"; +/* No comment provided by engineer. */ +"Contribute" = "มีส่วนร่วม"; + /* No comment provided by engineer. */ "Copy" = "คัดลอก"; /* No comment provided by engineer. */ "Core version: v%@" = "รุ่นหลัก: v%@"; -/* No comment provided by engineer. */ -"Create" = "สร้าง"; - /* server test step */ "Create file" = "สร้างไฟล์"; @@ -827,9 +816,6 @@ set passcode view */ /* time unit */ "days" = "วัน"; -/* No comment provided by engineer. */ -"Decentralized" = "กระจายอำนาจแล้ว"; - /* message decrypt error item */ "Decryption error" = "ข้อผิดพลาดในการ decrypt"; @@ -910,7 +896,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "ลบข้อความ?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "ลบข้อความ"; /* No comment provided by engineer. */ @@ -1054,7 +1041,7 @@ swipe action */ /* No comment provided by engineer. */ "Edit group profile" = "แก้ไขโปรไฟล์กลุ่ม"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "เปิดใช้งาน"; /* No comment provided by engineer. */ @@ -1072,9 +1059,6 @@ swipe action */ /* No comment provided by engineer. */ "Enable lock" = "เปิดใช้งานการล็อค"; -/* No comment provided by engineer. */ -"Enable notifications" = "เปิดใช้งานการแจ้งเตือน"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "เปิดใช้การแจ้งเตือนเป็นระยะๆ ไหม?"; @@ -1180,7 +1164,7 @@ swipe action */ /* No comment provided by engineer. */ "error" = "ผิดพลาด"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "ผิดพลาด"; /* No comment provided by engineer. */ @@ -1380,7 +1364,8 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "ค้นหาแชทได้เร็วขึ้น"; -/* server test error */ +/* relay test error +server test error */ "Fingerprint in server address does not match certificate." = "อาจเป็นไปได้ว่าลายนิ้วมือของ certificate ในที่อยู่เซิร์ฟเวอร์ไม่ถูกต้อง"; /* No comment provided by engineer. */ @@ -1446,7 +1431,7 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "คำเชิญเข้าร่วมกลุ่มใช้ไม่ถูกต้องอีกต่อไป คำเชิญถูกลบโดยผู้ส่ง"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "ลิงค์กลุ่ม"; /* No comment provided by engineer. */ @@ -1548,9 +1533,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "โดยทันที"; -/* No comment provided by engineer. */ -"Immune to spam" = "มีภูมิคุ้มกันต่อสแปมและการละเมิด"; - /* No comment provided by engineer. */ "Import" = "นำเข้า"; @@ -1612,7 +1594,7 @@ snd error text */ "Initial role" = "บทบาทเริ่มต้น"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "ติดตั้ง [SimpleX Chat สำหรับเทอร์มินัล](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "ติดตั้ง SimpleX Chat สำหรับเทอร์มินัล"; /* No comment provided by engineer. */ "Instant" = "ทันที"; @@ -1629,7 +1611,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "ข้อมูลแชทไม่ถูกต้อง"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "ลิงค์เชื่อมต่อไม่ถูกต้อง"; /* invalid chat item */ @@ -1815,7 +1797,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "บทบาทของสมาชิกจะถูกเปลี่ยนเป็น \"%@\" สมาชิกจะได้รับคำเชิญใหม่"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "สมาชิกจะถูกลบออกจากกลุ่ม - ไม่สามารถยกเลิกได้!"; /* No comment provided by engineer. */ @@ -1926,7 +1908,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "การตั้งค่าเครือข่าย"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "สถานะเครือข่าย"; /* delete after time */ @@ -2001,9 +1983,6 @@ snd error text */ /* copied message info in history */ "no text" = "ไม่มีข้อความ"; -/* No comment provided by engineer. */ -"No user identifiers." = "แพลตฟอร์มแรกที่ไม่มีตัวระบุผู้ใช้ - ถูกออกแบบให้เป็นส่วนตัว"; - /* No comment provided by engineer. */ "Notifications" = "การแจ้งเตือน"; @@ -2195,9 +2174,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "ความเป็นส่วนตัวและความปลอดภัย"; -/* No comment provided by engineer. */ -"Privacy redefined" = "นิยามความเป็นส่วนตัวใหม่"; - /* No comment provided by engineer. */ "Private filenames" = "ชื่อไฟล์ส่วนตัว"; @@ -2210,9 +2186,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile password" = "รหัสผ่านโปรไฟล์"; -/* alert message */ -"Profile update will be sent to your contacts." = "การอัปเดตโปรไฟล์จะถูกส่งไปยังผู้ติดต่อของคุณ"; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "ห้ามการโทรด้วยเสียง/วิดีโอ"; @@ -2265,13 +2238,10 @@ new chat action */ "Read more" = "อ่านเพิ่มเติม"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "อ่านเพิ่มเติมใน[คู่มือผู้ใช้](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)"; +"Read more in our GitHub repository." = "อ่านเพิ่มเติมในพื้นที่เก็บข้อมูล GitHub"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "อ่านเพิ่มเติมใน[คู่มือผู้ใช้](https://simplex.chat/docs/guide/readme.html#connect-to-friends)"; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "อ่านเพิ่มเติมใน[พื้นที่เก็บข้อมูล GitHub](https://github.com/simplex-chat/simplex-chat#readme)"; +"Read more in User Guide." = "อ่านเพิ่มเติมในคู่มือผู้ใช้"; /* No comment provided by engineer. */ "received answer…" = "ได้รับคำตอบ…"; @@ -2285,9 +2255,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "ได้รับการยืนยัน…"; -/* notification */ -"Received file event" = "ได้รับไฟล์"; - /* message info title */ "Received message" = "ได้รับข้อความ"; @@ -2335,13 +2302,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "เซิร์ฟเวอร์รีเลย์ปกป้องที่อยู่ IP ของคุณ แต่สามารถสังเกตระยะเวลาของการโทรได้"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "ลบ"; /* No comment provided by engineer. */ "Remove member" = "ลบสมาชิกออก"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "ลบสมาชิกออก?"; /* No comment provided by engineer. */ @@ -2570,9 +2537,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "ส่งเมื่อ: %@"; -/* notification */ -"Sent file event" = "เหตุการณ์ไฟล์ที่ส่ง"; - /* message info title */ "Sent message" = "ข้อความที่ส่งแล้ว"; @@ -2628,15 +2592,9 @@ chat item action */ /* No comment provided by engineer. */ "Share address" = "แชร์ที่อยู่"; -/* alert title */ -"Share address with contacts?" = "แชร์ที่อยู่กับผู้ติดต่อ?"; - /* No comment provided by engineer. */ "Share link" = "แชร์ลิงก์"; -/* No comment provided by engineer. */ -"Share with contacts" = "แชร์กับผู้ติดต่อ"; - /* No comment provided by engineer. */ "Show calls in phone history" = "แสดงการโทรในประวัติการโทร"; @@ -2697,6 +2655,9 @@ chat item action */ /* notification title */ "Somebody" = "ใครบางคน"; +/* No comment provided by engineer. */ +"Star on GitHub" = "ติดดาวบน GitHub"; + /* No comment provided by engineer. */ "Start chat" = "เริ่มแชท"; @@ -2775,7 +2736,8 @@ chat item action */ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "การทดสอบล้มเหลวในขั้นตอน %@"; /* No comment provided by engineer. */ @@ -2814,9 +2776,6 @@ chat item action */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "encryption กำลังทำงานและไม่จำเป็นต้องใช้ข้อตกลง encryption ใหม่ อาจทำให้การเชื่อมต่อผิดพลาดได้!"; -/* No comment provided by engineer. */ -"The future of messaging" = "การส่งข้อความส่วนตัวรุ่นต่อไป"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "แฮชของข้อความก่อนหน้านี้แตกต่างกัน"; @@ -2895,12 +2854,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "การแยกการขนส่ง"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %@)"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "พยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้"; - /* No comment provided by engineer. */ "Turn off" = "ปิด"; @@ -2982,9 +2935,6 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = "ใช้โฮสต์ .onion"; -/* No comment provided by engineer. */ -"Use chat" = "ใช้แชท"; - /* No comment provided by engineer. */ "Use for new connections" = "ใช้สำหรับการเชื่อมต่อใหม่"; @@ -3123,9 +3073,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "คุณได้เชื่อมต่อกับ %@ แล้ว"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้"; - /* No comment provided by engineer. */ "You are invited to group" = "คุณได้รับเชิญให้เข้าร่วมกลุ่ม"; @@ -3186,9 +3133,6 @@ chat item action */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "เราไม่สามารถตรวจสอบคุณได้ กรุณาลองอีกครั้ง."; -/* No comment provided by engineer. */ -"You decide who can connect." = "ผู้คนสามารถเชื่อมต่อกับคุณผ่านลิงก์ที่คุณแบ่งปันเท่านั้น"; - /* No comment provided by engineer. */ "You have to enter passphrase every time the app starts - it is not stored on the device." = "คุณต้องใส่รหัสผ่านทุกครั้งที่เริ่มแอป - รหัสผ่านไม่ได้จัดเก็บไว้ในอุปกรณ์"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index f6d38b0632..fb3bf39168 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(bu cihaz v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Katkıda bulun](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Bize e-posta gönder](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Bize GitHub'da yıldız verin](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Kişi ekle**: yeni bir davet bağlantısı oluşturmak için, ya da aldığın bağlantıyla bağlan."; @@ -398,9 +392,6 @@ swipe action */ /* No comment provided by engineer. */ "Active connections" = "Aktif bağlantılar"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Kişilerinizin başkalarıyla paylaşabilmesi için profilinize adres ekleyin. Profil güncellemesi kişilerinize gönderilecek."; - /* No comment provided by engineer. */ "Add friends" = "Arkadaş ekle"; @@ -647,9 +638,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Aramayı cevapla"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Açık kaynak protokolü ve kodu - herhangi biri sunucuları çalıştırabilir."; - /* No comment provided by engineer. */ "App build: %@" = "Uygulama sürümü: %@"; @@ -891,7 +879,7 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgarca, Fince, Tayca ve Ukraynaca - kullanıcılara ve [Weblate] e teşekkürler! (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; -/* No comment provided by engineer. */ +/* chat link info line */ "Business address" = "İş adresi"; /* No comment provided by engineer. */ @@ -906,9 +894,6 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Sohbet profiline göre (varsayılan) veya [bağlantıya göre](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "SimpleX Chat'i kullanarak şunları kabul etmiş olursunuz:\n- herkese açık gruplarda yalnızca yasal içerik göndermek.\n- diğer kullanıcılara saygı göstermek – spam yapmamak."; - /* No comment provided by engineer. */ "call" = "Ara"; @@ -1092,7 +1077,8 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Sohbet senden silinecek - bu geri alınamaz!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Yöneticilerle sohbet et"; /* No comment provided by engineer. */ @@ -1191,7 +1177,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Kullanım koşulları"; /* No comment provided by engineer. */ @@ -1206,9 +1192,6 @@ set passcode view */ /* No comment provided by engineer. */ "Configure ICE servers" = "ICE sunucularını ayarla"; -/* No comment provided by engineer. */ -"Configure server operators" = "Sunucu operatörlerini yapılandır"; - /* No comment provided by engineer. */ "Confirm" = "Onayla"; @@ -1242,7 +1225,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Onaylandı"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Bağlan"; /* No comment provided by engineer. */ @@ -1341,7 +1325,7 @@ set passcode view */ /* alert title */ "Connection error" = "Bağlantı hatası"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Bağlantı hatası (DOĞRULAMA)"; /* chat list item title (it should not be shown */ @@ -1443,6 +1427,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Devam et"; +/* No comment provided by engineer. */ +"Contribute" = "Katkıda bulun"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Sohbet silindi!"; @@ -1458,12 +1445,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Köşeleri yuvarlama"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "İsim %@ olarak düzeltilsin mi?"; -/* No comment provided by engineer. */ -"Create" = "Oluştur"; - /* No comment provided by engineer. */ "Create 1-time link" = "Tek kullanımlık bağlantı oluştur"; @@ -1617,9 +1601,6 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Hata ayıklama teslimatı"; -/* No comment provided by engineer. */ -"Decentralized" = "Merkezi Olmayan"; - /* message decrypt error item */ "Decryption error" = "Şifre çözme hatası"; @@ -1733,7 +1714,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Mesaj silinsin mi?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Mesajları sil"; /* No comment provided by engineer. */ @@ -2016,7 +1998,7 @@ chat item action */ /* No comment provided by engineer. */ "Empty message!" = "Boş mesaj!"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Etkinleştir"; /* No comment provided by engineer. */ @@ -2046,9 +2028,6 @@ chat item action */ /* No comment provided by engineer. */ "Enable lock" = "Kilidi etkinleştir"; -/* No comment provided by engineer. */ -"Enable notifications" = "Bildirimleri etkinleştir"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "Periyodik bildirimler etkinleştirilsin mi?"; @@ -2190,7 +2169,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "hata"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Hata"; /* No comment provided by engineer. */ @@ -2322,9 +2301,6 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "Kişiyi hazırlama hatası"; -/* No comment provided by engineer. */ -"Error opening group" = "Grubu hazırlama hatası"; - /* alert title */ "Error receiving file" = "Dosya alınırken sorun oluştu"; @@ -2573,7 +2549,8 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Sohbetleri daha hızlı bul"; -/* server test error */ +/* relay test error +server test error */ "Fingerprint in server address does not match certificate." = "Muhtemelen, sunucu adresindeki parmakizi sertifikası doğru değil"; /* No comment provided by engineer. */ @@ -2597,7 +2574,8 @@ snd error text */ /* No comment provided by engineer. */ "For all moderators" = "Tüm moderatörler için"; -/* servers error */ +/* servers error +servers warning */ "For chat profile %@:" = "Sohbet profili için %@:"; /* No comment provided by engineer. */ @@ -2729,7 +2707,7 @@ snd error text */ /* No comment provided by engineer. */ "group is deleted" = "grup silindi"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Grup bağlantısı"; /* No comment provided by engineer. */ @@ -2855,9 +2833,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "Hemen"; -/* No comment provided by engineer. */ -"Immune to spam" = "Spam ve kötüye kullanıma karşı bağışıklı"; - /* No comment provided by engineer. */ "Import" = "İçe aktar"; @@ -2958,7 +2933,7 @@ snd error text */ "Initial role" = "Başlangıç rolü"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[Terminal için SimpleX Chat]i indir(https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Terminal için SimpleX Chat'i indir"; /* No comment provided by engineer. */ "Instant" = "Anında"; @@ -2993,7 +2968,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "geçersi̇z sohbet verisi"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Geçersiz bağlanma bağlantısı"; /* invalid chat item */ @@ -3008,7 +2983,7 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Geçersiz taşıma onayı"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Geçersiz isim!"; /* No comment provided by engineer. */ @@ -3293,10 +3268,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Üye rolü \"%@\" olarak değiştirilecektir. Ve üye yeni bir davetiye alacaktır."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Üye sohbetten kaldırılacak - bu geri alınamaz!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Üye gruptan çıkarılacaktır - bu geri alınamaz!"; /* alert message */ @@ -3428,9 +3403,6 @@ snd error text */ /* No comment provided by engineer. */ "Migrate device" = "Cihazı taşıma"; -/* No comment provided by engineer. */ -"Migrate from another device" = "Başka bir cihazdan geçiş yapın"; - /* No comment provided by engineer. */ "Migrate here" = "Buraya göç edin"; @@ -3539,7 +3511,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Ağ ayarları"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Ağ durumu"; /* delete after time */ @@ -3707,9 +3679,6 @@ snd error text */ /* No comment provided by engineer. */ "No unread chats" = "Okunmamış sohbet yok"; -/* No comment provided by engineer. */ -"No user identifiers." = "Herhangi bir kullanıcı tanımlayıcısı yok."; - /* No comment provided by engineer. */ "Not compatible!" = "Uyumlu değil!"; @@ -3766,7 +3735,7 @@ alert button new chat action */ "Ok" = "Tamam"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "TAMAM"; /* No comment provided by engineer. */ @@ -3847,7 +3816,8 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sadece karşıdaki kişi sesli mesajlar gönderebilir."; -/* alert action */ +/* alert action +alert button */ "Open" = "Aç"; /* No comment provided by engineer. */ @@ -4102,12 +4072,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy policy and conditions of use." = "Gizlilik politikası ve kullanım koşulları."; -/* No comment provided by engineer. */ -"Privacy redefined" = "Gizlilik yeniden tanımlandı"; - -/* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "Özel sohbetler, gruplar ve kişilerinize sunucu operatörleri tarafından erişilemez."; - /* No comment provided by engineer. */ "Private filenames" = "Gizli dosya adları"; @@ -4147,9 +4111,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile theme" = "Profil teması"; -/* alert message */ -"Profile update will be sent to your contacts." = "Profil güncellemesi kişilerinize gönderilecektir."; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Sesli/görüntülü aramaları yasakla."; @@ -4238,16 +4199,10 @@ new chat action */ "Read more" = "Dahasını oku"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "[Kullanıcı Rehberi]nde daha fazlasını okuyun(https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "GitHub deposunda daha fazlasını okuyun."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "[Kullanıcı Rehberi]nde daha fazlasını okuyun(https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "[Kullanıcı Rehberi]nde daha fazlasını okuyun(https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "[GitHub deposu]nda daha fazlasını okuyun(https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Kullanıcı Rehberinde daha fazlasını okuyun."; /* No comment provided by engineer. */ "Receipts are disabled" = "Alındı onayları devre dışı bırakıldı"; @@ -4267,9 +4222,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "onaylama alındı…"; -/* notification */ -"Received file event" = "Dosya etkinliği alındı"; - /* message info title */ "Received message" = "Mesaj alındı"; @@ -4365,7 +4317,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Yönlendirici sunucu IP adresinizi korur, ancak aramanın süresini gözlemleyebilir."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Sil"; /* No comment provided by engineer. */ @@ -4380,7 +4332,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Kişiyi sil"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Kişi silinsin mi?"; /* No comment provided by engineer. */ @@ -4831,9 +4783,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direkt gönderildi"; -/* notification */ -"Sent file event" = "Dosya etkinliği gönderildi"; - /* message info title */ "Sent message" = "Mesaj gönderildi"; @@ -4982,9 +4931,6 @@ chat item action */ /* No comment provided by engineer. */ "Share address publicly" = "Adresinizi herkese açık olarak paylaşın"; -/* alert title */ -"Share address with contacts?" = "Kişilerle adres paylaşılsın mı?"; - /* No comment provided by engineer. */ "Share from other apps." = "Diğer uygulamalardan paylaşın."; @@ -5009,9 +4955,6 @@ chat item action */ /* No comment provided by engineer. */ "Share to SimpleX" = "SimpleX ile paylaş"; -/* No comment provided by engineer. */ -"Share with contacts" = "Kişilerle paylaş"; - /* No comment provided by engineer. */ "Share your address" = "Adresini paylaş"; @@ -5114,9 +5057,6 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX protocols reviewed by Trail of Bits." = "SimpleX protokolleri Trail of Bits tarafından incelenmiştir."; -/* simplex link type */ -"SimpleX relay link" = "SimpleX aktarıcı bağlantısı"; - /* No comment provided by engineer. */ "Simplified incognito mode" = "Basitleştirilmiş gizli mod"; @@ -5169,6 +5109,9 @@ report reason */ /* chat item text */ "standard end-to-end encryption" = "standart uçtan uca şifreleme"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Bize GitHub'da yıldız verin"; + /* No comment provided by engineer. */ "Start chat" = "Sohbeti başlat"; @@ -5274,9 +5217,6 @@ report reason */ /* No comment provided by engineer. */ "Tap Connect to use bot" = "Botu kullanmak için Bağlan tuşuna bas"; -/* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Daha sonra oluşturmak için menüden BasitX adresi oluştur'a dokunun."; - /* No comment provided by engineer. */ "Tap Join group" = "Gruba katıl'a dokunun"; @@ -5322,7 +5262,8 @@ report reason */ /* file error alert title */ "Temporary file error" = "Geçici dosya hatası"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Test %@ adımında başarısız oldu."; /* No comment provided by engineer. */ @@ -5379,9 +5320,6 @@ report reason */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Şifreleme çalışıyor ve yeni şifreleme anlaşması gerekli değil. Bağlantı hatalarına neden olabilir!"; -/* No comment provided by engineer. */ -"The future of messaging" = "Gizli mesajlaşmanın yeni nesli"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Önceki mesajın hash'i farklı."; @@ -5556,9 +5494,6 @@ report reason */ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Kişinizle uçtan uca şifrelemeyi doğrulamak için cihazlarınızdaki kodu karşılaştırın (veya tarayın)."; -/* No comment provided by engineer. */ -"Toggle chat list:" = "Sohbet listesini değiştir:"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Bağlanırken gizli moda geçiş yap."; @@ -5577,12 +5512,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Taşıma oturumları"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor."; - /* No comment provided by engineer. */ "Turkish interface" = "Türkçe arayüz"; @@ -5682,7 +5611,7 @@ report reason */ /* swipe action */ "Unread" = "Okunmamış"; -/* No comment provided by engineer. */ +/* conn error description */ "Unsupported connection link" = "Desteklenmeyen bağlantı bağlantısı"; /* No comment provided by engineer. */ @@ -5757,9 +5686,6 @@ report reason */ /* No comment provided by engineer. */ "Use %@" = "%@ kullan"; -/* No comment provided by engineer. */ -"Use chat" = "Sohbeti kullan"; - /* new chat action */ "Use current profile" = "Şu anki profili kullan"; @@ -6075,9 +6001,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Gruba zaten katılıyorsunuz!\nKatılma isteği tekrarlansın mı?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız."; - /* No comment provided by engineer. */ "You are invited to group" = "Gruba davet edildiniz"; @@ -6171,9 +6094,6 @@ report reason */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "Doğrulanamadınız; lütfen tekrar deneyin."; -/* No comment provided by engineer. */ -"You decide who can connect." = "Kimin bağlanabileceğine siz karar verirsiniz."; - /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Zaten bağlantı isteğinde bulundunuz!\nBağlantı isteği tekrarlansın mı?"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 2c3d7b083d..a0d9490b00 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(цей пристрій v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Внесок](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Напишіть нам електронною поштою](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Зірка на GitHub](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Додати контакт**: створити нове посилання-запрошення."; @@ -398,9 +392,6 @@ swipe action */ /* No comment provided by engineer. */ "Active connections" = "Активні з'єднання"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Повідомлення про оновлення профілю буде надіслано вашим контактам."; - /* No comment provided by engineer. */ "Add friends" = "Додайте друзів"; @@ -641,9 +632,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "Відповісти на дзвінок"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "Кожен може хостити сервери."; - /* No comment provided by engineer. */ "App build: %@" = "Збірка програми: %@"; @@ -879,7 +867,7 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Болгарською, фінською, тайською та українською мовами - завдяки користувачам та [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; -/* No comment provided by engineer. */ +/* chat link info line */ "Business address" = "Адреса підприємства"; /* No comment provided by engineer. */ @@ -894,9 +882,6 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Через профіль чату (за замовчуванням) або [за з'єднанням](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Використовуючи SimpleX Chat, ви погоджуєтеся:\n- надсилати лише легальний контент у публічних групах.\n- поважати інших користувачів - без спаму."; - /* No comment provided by engineer. */ "call" = "дзвонити"; @@ -1080,7 +1065,8 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Чат буде видалено для вас - цю дію неможливо скасувати!"; -/* chat toolbar */ +/* chat feature +chat toolbar */ "Chat with admins" = "Чат з адміністраторами"; /* No comment provided by engineer. */ @@ -1179,7 +1165,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Умови вже прийняті для наступних операторів: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Умови використання"; /* No comment provided by engineer. */ @@ -1194,9 +1180,6 @@ set passcode view */ /* No comment provided by engineer. */ "Configure ICE servers" = "Налаштування серверів ICE"; -/* No comment provided by engineer. */ -"Configure server operators" = "Налаштувати операторів сервера"; - /* No comment provided by engineer. */ "Confirm" = "Підтвердити"; @@ -1230,7 +1213,8 @@ set passcode view */ /* token status text */ "Confirmed" = "Підтверджений"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "Підключіться"; /* No comment provided by engineer. */ @@ -1329,7 +1313,7 @@ set passcode view */ /* alert title */ "Connection error" = "Помилка підключення"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "Помилка підключення (AUTH)"; /* chat list item title (it should not be shown */ @@ -1428,6 +1412,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "Продовжуйте"; +/* No comment provided by engineer. */ +"Contribute" = "Внесок"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Розмова видалена!"; @@ -1443,12 +1430,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "Кут"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "Виправити ім'я на %@?"; -/* No comment provided by engineer. */ -"Create" = "Створити"; - /* No comment provided by engineer. */ "Create 1-time link" = "Створити одноразове посилання"; @@ -1602,9 +1586,6 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "Доставка налагодження"; -/* No comment provided by engineer. */ -"Decentralized" = "Децентралізований"; - /* message decrypt error item */ "Decryption error" = "Помилка розшифровки"; @@ -1718,7 +1699,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Видалити повідомлення?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Видалити повідомлення"; /* No comment provided by engineer. */ @@ -1998,7 +1980,7 @@ chat item action */ /* No comment provided by engineer. */ "Empty message!" = "Порожнє повідомлення!"; -/* No comment provided by engineer. */ +/* alert button */ "Enable" = "Увімкнути"; /* No comment provided by engineer. */ @@ -2028,9 +2010,6 @@ chat item action */ /* No comment provided by engineer. */ "Enable lock" = "Увімкнути блокування"; -/* No comment provided by engineer. */ -"Enable notifications" = "Увімкнути сповіщення"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "Увімкнути періодичні сповіщення?"; @@ -2172,7 +2151,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "помилка"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "Помилка"; /* No comment provided by engineer. */ @@ -2304,9 +2283,6 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "Помилка відкриття чату"; -/* No comment provided by engineer. */ -"Error opening group" = "Помилка відкриття групи"; - /* alert title */ "Error receiving file" = "Помилка отримання файлу"; @@ -2549,8 +2525,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Швидше знаходьте чати"; -/* server test error */ -"Fingerprint in server address does not match certificate." = "Можливо, в адресі сервера неправильно вказано відбиток сертифіката"; +/* relay test error +server test error */ +"Fingerprint in server address does not match certificate." = "Відбиток в адресі сервера не співпадає з сертифікатом."; /* No comment provided by engineer. */ "Fix" = "Виправити"; @@ -2573,7 +2550,8 @@ snd error text */ /* No comment provided by engineer. */ "For all moderators" = "Для всіх модераторів"; -/* servers error */ +/* servers error +servers warning */ "For chat profile %@:" = "Для профілю чату %@:"; /* No comment provided by engineer. */ @@ -2705,7 +2683,7 @@ snd error text */ /* No comment provided by engineer. */ "group is deleted" = "групу видалено"; -/* No comment provided by engineer. */ +/* chat link info line */ "Group link" = "Посилання на групу"; /* No comment provided by engineer. */ @@ -2831,9 +2809,6 @@ snd error text */ /* No comment provided by engineer. */ "Immediately" = "Негайно"; -/* No comment provided by engineer. */ -"Immune to spam" = "Імунітет до спаму та зловживань"; - /* No comment provided by engineer. */ "Import" = "Імпорт"; @@ -2934,7 +2909,7 @@ snd error text */ "Initial role" = "Початкова роль"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Встановіть [SimpleX Chat для терміналу](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "Встановіть SimpleX Chat для терміналу"; /* No comment provided by engineer. */ "Instant" = "Миттєво"; @@ -2969,7 +2944,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "невірні дані чату"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "Неправильне посилання для підключення"; /* invalid chat item */ @@ -2984,7 +2959,7 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "Недійсне підтвердження міграції"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "Неправильне ім'я!"; /* No comment provided by engineer. */ @@ -3263,10 +3238,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль учасника буде змінено на \"%@\". Учасник отримає нове запрошення."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Учасника буде видалено з чату – це неможливо скасувати!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Учасник буде видалений з групи - це неможливо скасувати!"; /* alert message */ @@ -3398,9 +3373,6 @@ snd error text */ /* No comment provided by engineer. */ "Migrate device" = "Перенести пристрій"; -/* No comment provided by engineer. */ -"Migrate from another device" = "Перехід з іншого пристрою"; - /* No comment provided by engineer. */ "Migrate here" = "Мігруйте сюди"; @@ -3509,7 +3481,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Налаштування мережі"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Стан мережі"; /* delete after time */ @@ -3677,9 +3649,6 @@ snd error text */ /* No comment provided by engineer. */ "No unread chats" = "Немає непрочитаних чатів"; -/* No comment provided by engineer. */ -"No user identifiers." = "Ніяких ідентифікаторів користувачів."; - /* No comment provided by engineer. */ "Not compatible!" = "Не сумісні!"; @@ -3736,7 +3705,7 @@ alert button new chat action */ "Ok" = "Гаразд"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "ОК"; /* No comment provided by engineer. */ @@ -3811,7 +3780,8 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Тільки ваш контакт може надсилати голосові повідомлення."; -/* alert action */ +/* alert action +alert button */ "Open" = "Відкрито"; /* No comment provided by engineer. */ @@ -4057,12 +4027,6 @@ new chat action */ /* No comment provided by engineer. */ "Privacy policy and conditions of use." = "Політика конфіденційності та умови використання."; -/* No comment provided by engineer. */ -"Privacy redefined" = "Конфіденційність переглянута"; - -/* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "Приватні чати, групи та ваші контакти недоступні для операторів сервера."; - /* No comment provided by engineer. */ "Private filenames" = "Приватні імена файлів"; @@ -4102,9 +4066,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile theme" = "Тема профілю"; -/* alert message */ -"Profile update will be sent to your contacts." = "Оновлення профілю буде надіслано вашим контактам."; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "Заборонити аудіо/відеодзвінки."; @@ -4193,16 +4154,10 @@ new chat action */ "Read more" = "Читати далі"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "Читайте більше в [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)."; +"Read more in our GitHub repository." = "Читайте більше в нашому GitHub репозиторії."; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)."; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Читайте більше в [Посібнику користувача](https://simplex.chat/docs/guide/readme.html#connect-to-friends)."; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Читайте більше в нашому [GitHub репозиторії](https://github.com/simplex-chat/simplex-chat#readme)."; +"Read more in User Guide." = "Читайте більше в User Guide."; /* No comment provided by engineer. */ "Receipts are disabled" = "Підтвердження виключені"; @@ -4222,9 +4177,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "отримали підтвердження…"; -/* notification */ -"Received file event" = "Подія отримання файлу"; - /* message info title */ "Received message" = "Отримано повідомлення"; @@ -4320,7 +4272,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Сервер ретрансляції захищає вашу IP-адресу, але він може спостерігати за тривалістю дзвінка."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Видалити"; /* No comment provided by engineer. */ @@ -4332,7 +4284,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Видалити учасника"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Видалити учасника?"; /* No comment provided by engineer. */ @@ -4777,9 +4729,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Відправлено напряму"; -/* notification */ -"Sent file event" = "Подія надісланого файлу"; - /* message info title */ "Sent message" = "Надіслано повідомлення"; @@ -4826,10 +4775,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "інформація про чергу на сервері: %1$@\n\nостаннє отримане повідомлення: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "Сервер вимагає авторизації для створення черг, перевірте пароль"; +"Server requires authorization to create queues, check password." = "Сервер вимагає авторизації для створення черг, перевірте пароль."; /* server test error */ -"Server requires authorization to upload, check password." = "Сервер вимагає авторизації для завантаження, перевірте пароль"; +"Server requires authorization to upload, check password." = "Сервер вимагає авторизації для завантаження, перевірте пароль."; /* No comment provided by engineer. */ "Server test failed!" = "Тест сервера завершився невдало!"; @@ -4928,9 +4877,6 @@ chat item action */ /* No comment provided by engineer. */ "Share address publicly" = "Поділіться адресою публічно"; -/* alert title */ -"Share address with contacts?" = "Поділіться адресою з контактами?"; - /* No comment provided by engineer. */ "Share from other apps." = "Діліться з інших програм."; @@ -4955,9 +4901,6 @@ chat item action */ /* No comment provided by engineer. */ "Share to SimpleX" = "Поділіться з SimpleX"; -/* No comment provided by engineer. */ -"Share with contacts" = "Поділіться з контактами"; - /* No comment provided by engineer. */ "Share your address" = "Поділіться своєю адресою"; @@ -5112,6 +5055,9 @@ report reason */ /* chat item text */ "standard end-to-end encryption" = "стандартне наскрізне шифрування"; +/* No comment provided by engineer. */ +"Star on GitHub" = "Зірка на GitHub"; + /* No comment provided by engineer. */ "Start chat" = "Почати чат"; @@ -5214,9 +5160,6 @@ report reason */ /* No comment provided by engineer. */ "Tap Connect to send request" = "Натисніть Підключитися, щоб відправити запит"; -/* No comment provided by engineer. */ -"Tap Create SimpleX address in the menu to create it later." = "Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше."; - /* No comment provided by engineer. */ "Tap Join group" = "Натисніть Приєднатися до групи"; @@ -5262,7 +5205,8 @@ report reason */ /* file error alert title */ "Temporary file error" = "Тимчасова помилка файлу"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "Тест завершився невдало на кроці %@."; /* No comment provided by engineer. */ @@ -5319,9 +5263,6 @@ report reason */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з'єднання!"; -/* No comment provided by engineer. */ -"The future of messaging" = "Наступне покоління приватних повідомлень"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хеш попереднього повідомлення відрізняється."; @@ -5490,9 +5431,6 @@ report reason */ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Щоб перевірити наскрізне шифрування з вашим контактом, порівняйте (або відскануйте) код на ваших пристроях."; -/* No comment provided by engineer. */ -"Toggle chat list:" = "Перемикання списку чату:"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Увімкніть інкогніто при підключенні."; @@ -5511,12 +5449,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Транспортні сесії"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту (помилка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту."; - /* No comment provided by engineer. */ "Turkish interface" = "Турецький інтерфейс"; @@ -5616,7 +5548,7 @@ report reason */ /* swipe action */ "Unread" = "Непрочитане"; -/* No comment provided by engineer. */ +/* conn error description */ "Unsupported connection link" = "Несумісне посилання для підключення"; /* No comment provided by engineer. */ @@ -5691,9 +5623,6 @@ report reason */ /* No comment provided by engineer. */ "Use %@" = "Використовуйте %@"; -/* No comment provided by engineer. */ -"Use chat" = "Використовуйте чат"; - /* new chat action */ "Use current profile" = "Використовувати поточний профіль"; @@ -6009,9 +5938,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Ви вже приєдналися до групи!\nПовторити запит на приєднання?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту."; - /* No comment provided by engineer. */ "You are invited to group" = "Запрошуємо вас до групи"; @@ -6105,9 +6031,6 @@ report reason */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "Вас не вдалося верифікувати, спробуйте ще раз."; -/* No comment provided by engineer. */ -"You decide who can connect." = "Ви вирішуєте, хто може під'єднатися."; - /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "Ви вже надіслали запит на підключення!\nПовторити запит на підключення?"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 2803b374f7..3893351fdd 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -25,15 +25,9 @@ /* No comment provided by engineer. */ "(this device v%@)" = "(此设备 v%@)"; -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[贡献](https://github.com/simplex-chat/simplex-chat#contribute)"; - /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[给我们发电邮](mailto:chat@simplex.chat)"; -/* No comment provided by engineer. */ -"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[在 GitHub 上加星](https://github.com/simplex-chat/simplex-chat)"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**添加联系人**: 创建新的邀请链接,或通过您收到的链接进行连接."; @@ -346,12 +340,21 @@ alert action swipe action */ "Accept" = "接受"; +/* alert action */ +"Accept as member" = "接受为成员"; + +/* alert action */ +"Accept as observer" = "接受为观察员"; + /* No comment provided by engineer. */ "Accept conditions" = "接受条款"; /* No comment provided by engineer. */ "Accept connection request?" = "接受联系人?"; +/* alert title */ +"Accept contact request" = "接受联络请求"; + /* notification body */ "Accept contact request from %@?" = "接受来自 %@ 的联系人请求?"; @@ -359,12 +362,21 @@ swipe action */ swipe action */ "Accept incognito" = "接受隐身聊天"; +/* alert title */ +"Accept member" = "接受成员"; + /* call status */ "accepted call" = "已接受通话"; /* No comment provided by engineer. */ "Accepted conditions" = "已接受的条款"; +/* chat list item title */ +"accepted invitation" = "已接受邀请"; + +/* rcv group event chat item */ +"accepted you" = "接受了你"; + /* No comment provided by engineer. */ "Acknowledged" = "确认"; @@ -377,15 +389,15 @@ swipe action */ /* No comment provided by engineer. */ "Active connections" = "活动连接"; -/* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "将地址添加到您的个人资料,以便您的联系人可以与其他人共享。个人资料更新将发送给您的联系人。"; - /* No comment provided by engineer. */ "Add friends" = "添加好友"; /* No comment provided by engineer. */ "Add list" = "添加列表"; +/* placeholder for sending contact request */ +"Add message" = "添加信息"; + /* No comment provided by engineer. */ "Add profile" = "添加个人资料"; @@ -461,6 +473,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "同意加密…"; +/* member criteria value */ +"all" = "全部"; + /* No comment provided by engineer. */ "All" = "全部"; @@ -485,6 +500,9 @@ swipe action */ /* feature role */ "all members" = "所有成员"; +/* No comment provided by engineer. */ +"All messages" = "所有消息"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。"; @@ -530,6 +548,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow downgrade" = "允许降级"; +/* No comment provided by engineer. */ +"Allow files and media only if your contact allows them." = "只有你的联系人允许的情况下才允许文件和媒体。"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "仅有您的联系人许可后才允许不可撤回消息移除"; @@ -581,6 +602,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "允许您的联系人发送限时消息。"; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "允许你的联系人发送文件和媒体。"; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "允许您的联系人发送语音消息。"; @@ -614,9 +638,6 @@ swipe action */ /* No comment provided by engineer. */ "Answer call" = "接听来电"; -/* No comment provided by engineer. */ -"Anybody can host servers." = "任何人都可以托管服务器。"; - /* No comment provided by engineer. */ "App build: %@" = "应用程序构建:%@"; @@ -683,6 +704,9 @@ swipe action */ /* No comment provided by engineer. */ "Archived contacts" = "已存档的联系人"; +/* No comment provided by engineer. */ +"archived report" = "已存档的举报"; + /* No comment provided by engineer. */ "Archiving database" = "正在存档数据库"; @@ -698,6 +722,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "语音和视频通话"; +/* No comment provided by engineer. */ +"Audio call" = "语音通话"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "语音通话(非端到端加密)"; @@ -752,6 +779,12 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "错误消息 ID"; +/* No comment provided by engineer. */ +"Be free in your network." = "在你的网络中自由畅行。"; + +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "因为我们摧毁了知道你是谁的权力,因而您的权利永远不会被夺走。"; + /* No comment provided by engineer. */ "Better calls" = "更佳的通话"; @@ -782,6 +815,12 @@ swipe action */ /* No comment provided by engineer. */ "Better user experience" = "更佳的使用体验"; +/* No comment provided by engineer. */ +"Bio" = "自我介绍"; + +/* alert title */ +"Bio too large" = "自我介绍过大"; + /* No comment provided by engineer. */ "Black" = "黑色"; @@ -825,6 +864,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "加粗"; +/* No comment provided by engineer. */ +"Bot" = "机器人"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "您和您的联系人都可以添加消息回应。"; @@ -837,27 +879,30 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "您和您的联系人都可以发送限时消息。"; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "你和你的联系人都可发送文件和媒体。"; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "您和您的联系人都可以发送语音消息。"; /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "保加利亚语、芬兰语、泰语和乌克兰语——感谢用户和[Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; -/* No comment provided by engineer. */ +/* chat link info line */ "Business address" = "企业地址"; /* No comment provided by engineer. */ "Business chats" = "企业聊天"; +/* No comment provided by engineer. */ +"Business connection" = "企业连接"; + /* No comment provided by engineer. */ "Businesses" = "企业"; /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。"; -/* No comment provided by engineer. */ -"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "使用 SimpleX Chat 代表您同意:\n- 在公开群中只发送合法内容\n- 尊重其他用户 – 没有垃圾信息。"; - /* No comment provided by engineer. */ "call" = "呼叫"; @@ -888,6 +933,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't call member" = "无法呼叫成员"; +/* alert title */ +"Can't change profile" = "无法更改个人资料"; + /* No comment provided by engineer. */ "Can't invite contact!" = "无法邀请联系人!"; @@ -897,6 +945,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "无法向成员发送消息"; +/* No comment provided by engineer. */ +"can't send messages" = "无法发送消息"; + /* alert action alert button new chat action */ @@ -1035,9 +1086,22 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "将为你删除聊天 - 此操作无法撤销!"; +/* chat feature +chat toolbar */ +"Chat with admins" = "和管理员聊天"; + +/* No comment provided by engineer. */ +"Chat with member" = "和成员聊天"; + +/* No comment provided by engineer. */ +"Chat with members before they join." = "在成员加入前和这些人聊天"; + /* No comment provided by engineer. */ "Chats" = "聊天"; +/* No comment provided by engineer. */ +"Chats with members" = "和成员聊天"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "每 20 分钟检查消息。"; @@ -1122,7 +1186,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "已经接受下列运营方的条款:**%@**。"; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "使用条款"; /* No comment provided by engineer. */ @@ -1137,9 +1201,6 @@ set passcode view */ /* No comment provided by engineer. */ "Configure ICE servers" = "配置 ICE 服务器"; -/* No comment provided by engineer. */ -"Configure server operators" = "配置服务器运营方"; - /* No comment provided by engineer. */ "Confirm" = "确认"; @@ -1173,12 +1234,16 @@ set passcode view */ /* token status text */ "Confirmed" = "已确定"; -/* server test step */ +/* relay test step +server test step */ "Connect" = "连接"; /* No comment provided by engineer. */ "Connect automatically" = "自动连接"; +/* No comment provided by engineer. */ +"Connect faster! 🚀" = "更快地连接!🚀"; + /* No comment provided by engineer. */ "Connect to desktop" = "连接到桌面"; @@ -1269,7 +1334,7 @@ set passcode view */ /* alert title */ "Connection error" = "连接错误"; -/* No comment provided by engineer. */ +/* conn error description */ "Connection error (AUTH)" = "连接错误(AUTH)"; /* chat list item title (it should not be shown */ @@ -1317,9 +1382,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "联系人已存在"; +/* No comment provided by engineer. */ +"contact deleted" = "删除了联系人"; + /* No comment provided by engineer. */ "Contact deleted!" = "联系人已删除!"; +/* No comment provided by engineer. */ +"contact disabled" = "禁用了联系人"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "联系人具有端到端加密"; @@ -1338,9 +1409,18 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "联系人姓名"; +/* No comment provided by engineer. */ +"contact not ready" = "联系人未就绪"; + /* No comment provided by engineer. */ "Contact preferences" = "联系人偏好设置"; +/* No comment provided by engineer. */ +"Contact requests from groups" = "来自群的联络请求"; + +/* No comment provided by engineer. */ +"contact should accept…" = "联系人应当接受…"; + /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "联系人将被删除-这是无法撤消的!"; @@ -1356,6 +1436,9 @@ set passcode view */ /* No comment provided by engineer. */ "Continue" = "继续"; +/* No comment provided by engineer. */ +"Contribute" = "贡献"; + /* No comment provided by engineer. */ "Conversation deleted!" = "对话已删除!"; @@ -1371,12 +1454,9 @@ set passcode view */ /* No comment provided by engineer. */ "Corner" = "拐角"; -/* No comment provided by engineer. */ +/* alert message */ "Correct name to %@?" = "将名称更正为 %@?"; -/* No comment provided by engineer. */ -"Create" = "创建"; - /* No comment provided by engineer. */ "Create 1-time link" = "创建一次性链接"; @@ -1410,6 +1490,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create SimpleX address" = "创建 SimpleX 地址"; +/* No comment provided by engineer. */ +"Create your address" = "创建地址"; + /* No comment provided by engineer. */ "Create your profile" = "创建您的资料"; @@ -1527,9 +1610,6 @@ set passcode view */ /* No comment provided by engineer. */ "Debug delivery" = "调试交付"; -/* No comment provided by engineer. */ -"Decentralized" = "分散式"; - /* message decrypt error item */ "Decryption error" = "解密错误"; @@ -1583,6 +1663,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete chat profile?" = "删除聊天资料?"; +/* alert title */ +"Delete chat with member?" = "删除和成员的聊天吗?"; + /* No comment provided by engineer. */ "Delete chat?" = "删除聊天?"; @@ -1637,10 +1720,14 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "删除成员消息?"; +/* No comment provided by engineer. */ +"Delete member messages" = "删除成员消息"; + /* No comment provided by engineer. */ "Delete message?" = "删除消息吗?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "删除消息"; /* No comment provided by engineer. */ @@ -1709,9 +1796,15 @@ swipe action */ /* No comment provided by engineer. */ "Delivery receipts!" = "送达回执!"; +/* No comment provided by engineer. */ +"Deprecated options" = "已废弃的选项"; + /* No comment provided by engineer. */ "Description" = "描述"; +/* alert title */ +"Description too large" = "描述过大"; + /* No comment provided by engineer. */ "Desktop address" = "桌面地址"; @@ -1915,6 +2008,9 @@ chat item action */ "Edit group profile" = "编辑群组资料"; /* No comment provided by engineer. */ +"Empty message!" = "空消息!"; + +/* alert button */ "Enable" = "启用"; /* No comment provided by engineer. */ @@ -1926,6 +2022,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "启用相机访问"; +/* No comment provided by engineer. */ +"Enable disappearing messages by default." = "默认启用定时消失消息。"; + /* No comment provided by engineer. */ "Enable Flux in Network & servers settings for better metadata privacy." = "在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。"; @@ -1941,9 +2040,6 @@ chat item action */ /* No comment provided by engineer. */ "Enable lock" = "启用锁定"; -/* No comment provided by engineer. */ -"Enable notifications" = "启用通知"; - /* No comment provided by engineer. */ "Enable periodic notifications?" = "启用定期通知?"; @@ -2085,7 +2181,7 @@ chat item action */ /* No comment provided by engineer. */ "error" = "错误"; -/* No comment provided by engineer. */ +/* conn error description */ "Error" = "错误"; /* No comment provided by engineer. */ @@ -2097,15 +2193,24 @@ chat item action */ /* No comment provided by engineer. */ "Error accepting contact request" = "接受联系人请求错误"; +/* alert title */ +"Error accepting member" = "接受成员出错"; + /* No comment provided by engineer. */ "Error adding member(s)" = "添加成员错误"; /* alert title */ "Error adding server" = "添加服务器出错"; +/* No comment provided by engineer. */ +"Error adding short link" = "添加短链接出错"; + /* No comment provided by engineer. */ "Error changing address" = "更改地址错误"; +/* alert title */ +"Error changing chat profile" = "更改聊天资料出错"; + /* No comment provided by engineer. */ "Error changing connection profile" = "更改连接资料出错"; @@ -2118,6 +2223,9 @@ chat item action */ /* No comment provided by engineer. */ "Error changing to incognito!" = "切换至隐身聊天出错!"; +/* No comment provided by engineer. */ +"Error checking token status" = "查询token状态出错"; + /* alert message */ "Error connecting to forwarding server %@. Please try later." = "连接到转发服务器 %@ 时出错。请稍后尝试。"; @@ -2148,6 +2256,9 @@ chat item action */ /* No comment provided by engineer. */ "Error decrypting file" = "解密文件时出错"; +/* alert title */ +"Error deleting chat" = "删除聊天出错"; + /* alert title */ "Error deleting chat database" = "删除聊天数据库错误"; @@ -2214,6 +2325,9 @@ chat item action */ /* alert title */ "Error registering for notifications" = "注册消息推送出错"; +/* alert title */ +"Error rejecting contact request" = "拒绝联络请求出错"; + /* alert title */ "Error removing member" = "删除成员错误"; @@ -2259,6 +2373,9 @@ chat item action */ /* No comment provided by engineer. */ "Error sending message" = "发送消息错误"; +/* No comment provided by engineer. */ +"Error setting auto-accept" = "设置自动接受出错"; + /* No comment provided by engineer. */ "Error setting delivery receipts!" = "设置送达回执出错!"; @@ -2309,6 +2426,10 @@ file error text snd error text */ "Error: %@" = "错误: %@"; +/* relay test error +server test error */ +"Error: %@." = "错误:%@。"; + /* No comment provided by engineer. */ "Error: no database file" = "错误:没有数据库文件"; @@ -2417,6 +2538,9 @@ snd error text */ /* chat feature */ "Files and media" = "文件和媒体"; +/* No comment provided by engineer. */ +"Files and media are prohibited in this chat." = "此聊天禁止文件和媒体。"; + /* No comment provided by engineer. */ "Files and media are prohibited." = "此群组中禁止文件和媒体。"; @@ -2426,6 +2550,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "禁止文件和媒体!"; +/* No comment provided by engineer. */ +"Filter" = "过滤器"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "过滤未读和收藏的聊天记录。"; @@ -2441,7 +2568,17 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "更快地查找聊天记录"; -/* server test error */ +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "目的地服务器的指纹与证书不符:%@。"; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "转发服务器的指纹与证书不符:%@。"; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "服务器的指纹与证书不符:%@。"; + +/* relay test error +server test error */ "Fingerprint in server address does not match certificate." = "服务器地址中的证书指纹可能不正确"; /* No comment provided by engineer. */ @@ -2465,7 +2602,8 @@ snd error text */ /* No comment provided by engineer. */ "For all moderators" = "所有 moderators"; -/* servers error */ +/* servers error +servers warning */ "For chat profile %@:" = "为聊天资料 %@:"; /* No comment provided by engineer. */ @@ -2561,6 +2699,9 @@ snd error text */ /* message preview */ "Good morning!" = "早上好!"; +/* shown on group welcome message */ +"group" = "群"; + /* No comment provided by engineer. */ "Group" = "群组"; @@ -2592,6 +2733,9 @@ snd error text */ "Group invitation is no longer valid, it was removed by sender." = "群组邀请不再有效,已被发件人删除。"; /* No comment provided by engineer. */ +"group is deleted" = "群被删除了"; + +/* chat link info line */ "Group link" = "群组链接"; /* No comment provided by engineer. */ @@ -2615,6 +2759,9 @@ snd error text */ /* snd group event chat item */ "group profile updated" = "群组资料已更新"; +/* alert message */ +"Group profile was changed. If you save it, the updated profile will be sent to group members." = "群资料已修改。如果你进行保存,修改后的群资料将发送给其他群成员。"; + /* No comment provided by engineer. */ "Group welcome message" = "群欢迎词"; @@ -2712,10 +2859,10 @@ snd error text */ "Image will be received when your contact is online, please wait or check later!" = "图片将在您的联系人在线时收到,请稍等或稍后查看!"; /* No comment provided by engineer. */ -"Immediately" = "立即"; +"Images" = "图片"; /* No comment provided by engineer. */ -"Immune to spam" = "不受垃圾和骚扰消息影响"; +"Immediately" = "立即"; /* No comment provided by engineer. */ "Import" = "导入"; @@ -2817,7 +2964,7 @@ snd error text */ "Initial role" = "初始角色"; /* No comment provided by engineer. */ -"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "安装[用于终端的 SimpleX Chat](https://github.com/simplex-chat/simplex-chat)"; +"Install SimpleX Chat for terminal" = "安装用于终端的 SimpleX Chat"; /* No comment provided by engineer. */ "Instant" = "即时"; @@ -2852,7 +2999,7 @@ snd error text */ /* No comment provided by engineer. */ "invalid chat data" = "无效聊天数据"; -/* No comment provided by engineer. */ +/* conn error description */ "Invalid connection link" = "无效的连接链接"; /* invalid chat item */ @@ -2867,7 +3014,7 @@ snd error text */ /* No comment provided by engineer. */ "Invalid migration confirmation" = "迁移确认无效"; -/* No comment provided by engineer. */ +/* alert title */ "Invalid name!" = "无效名称!"; /* No comment provided by engineer. */ @@ -2894,6 +3041,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "邀请朋友"; +/* No comment provided by engineer. */ +"Invite member" = "邀请成员"; + /* No comment provided by engineer. */ "Invite members" = "邀请成员"; @@ -2990,6 +3140,9 @@ snd error text */ /* alert title */ "Keep unused invitation?" = "保留未使用的邀请吗?"; +/* No comment provided by engineer. */ +"Keep your chats clean" = "保持聊天洁净"; + /* No comment provided by engineer. */ "Keep your connections" = "保持连接"; @@ -3023,6 +3176,9 @@ snd error text */ /* rcv group event chat item */ "left" = "已离开"; +/* No comment provided by engineer. */ +"Less traffic on mobile networks." = "消耗更少的移动网络数据。"; + /* email subject */ "Let's talk in SimpleX Chat" = "让我们一起在 SimpleX Chat 里聊天"; @@ -3041,6 +3197,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "已链接桌面"; +/* No comment provided by engineer. */ +"Links" = "链接"; + /* swipe action */ "List" = "列表"; @@ -3059,6 +3218,9 @@ snd error text */ /* No comment provided by engineer. */ "Live messages" = "实时消息"; +/* in progress text */ +"Loading profile…" = "正加载个人资料…"; + /* No comment provided by engineer. */ "Local name" = "本地名称"; @@ -3110,15 +3272,27 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "成员"; +/* past/unknown group member */ +"Member %@" = "成员 %@"; + /* profile update event chat item */ "member %@ changed to %@" = "成员 %1$@ 已更改为 %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "成员准入"; + /* rcv group event chat item */ "member connected" = "已连接"; +/* No comment provided by engineer. */ +"member has old version" = "成员有旧版本"; + /* item status text */ "Member inactive" = "成员不活跃"; +/* No comment provided by engineer. */ +"Member is deleted - can't accept request" = "成员被删除——无法接受请求"; + /* chat feature */ "Member reports" = "成员举报"; @@ -3131,12 +3305,15 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "成员角色将更改为 \"%@\"。该成员将收到一份新的邀请。"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "将从聊天中删除成员 - 此操作无法撤销!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "成员将被移出群组——此操作无法撤消!"; +/* alert message */ +"Member will join the group, accept member?" = "成员将加入本群,接受成员吗?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "群组成员可以添加信息回应。"; @@ -3185,6 +3362,9 @@ snd error text */ /* item status text */ "Message forwarded" = "消息已转发"; +/* No comment provided by engineer. */ +"Message instantly once you tap Connect." = "轻按连接后即刻发消息。"; + /* item status description */ "Message may be delivered later if member becomes active." = "如果 member 变为活动状态,则稍后可能会发送消息。"; @@ -3233,6 +3413,9 @@ snd error text */ /* No comment provided by engineer. */ "Messages & files" = "消息"; +/* No comment provided by engineer. */ +"Messages are protected by **end-to-end encryption**." = "消息已通过**端到端加密**保护。"; + /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "将显示来自 %@ 的消息!"; @@ -3257,9 +3440,6 @@ snd error text */ /* No comment provided by engineer. */ "Migrate device" = "迁移设备"; -/* No comment provided by engineer. */ -"Migrate from another device" = "从另一台设备迁移"; - /* No comment provided by engineer. */ "Migrate here" = "迁移到此处"; @@ -3311,6 +3491,9 @@ snd error text */ /* marked deleted chat item preview text */ "moderated by %@" = "由 %@ 审核"; +/* member role */ +"moderator" = "协管"; + /* time unit */ "months" = "月"; @@ -3365,7 +3548,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "网络设置"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "网络状态"; /* delete after time */ @@ -3395,6 +3578,9 @@ snd error text */ /* notification */ "New events" = "新事件"; +/* No comment provided by engineer. */ +"New group role: Moderator" = "新的群角色:协管"; + /* No comment provided by engineer. */ "New in %@" = "%@ 的新内容"; @@ -3404,6 +3590,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "新成员角色"; +/* rcv group event chat item */ +"New member wants to join the group." = "新成员要加入本群。"; + /* notification */ "new message" = "新消息"; @@ -3443,6 +3632,9 @@ snd error text */ /* No comment provided by engineer. */ "No chats in list %@" = "列表 %@ 中无聊天"; +/* No comment provided by engineer. */ +"No chats with members" = "没有和成员的聊天"; + /* No comment provided by engineer. */ "No contacts selected" = "未选择联系人"; @@ -3494,6 +3686,9 @@ snd error text */ /* No comment provided by engineer. */ "No permission to record voice message" = "没有录制语音消息的权限"; +/* alert title */ +"No private routing session" = "无私密路由会话"; + /* No comment provided by engineer. */ "No push server" = "本地"; @@ -3512,6 +3707,9 @@ snd error text */ /* servers error */ "No servers to send files." = "无文件发送服务器。"; +/* No comment provided by engineer. */ +"no subscription" = "无订阅"; + /* copied message info in history */ "no text" = "无文本"; @@ -3522,11 +3720,17 @@ snd error text */ "No unread chats" = "没有未读聊天"; /* No comment provided by engineer. */ -"No user identifiers." = "没有用户标识符。"; +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "没有人追踪你的谈话内容。没有人绘制你去过的地方的地图。隐私从来都不是一项功能--而是一种生活方式。"; + +/* No comment provided by engineer. */ +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "别人家的门锁再好也比不上这里。房东再好也比不上这里,他既尊重你的隐私,又保留着所有访客的记录。你不是客人,你是家。没有国王能闯入--你是主人。"; /* No comment provided by engineer. */ "Not compatible!" = "不兼容!"; +/* No comment provided by engineer. */ +"not synchronized" = "未同步"; + /* No comment provided by engineer. */ "Notes" = "附注"; @@ -3577,7 +3781,7 @@ alert button new chat action */ "Ok" = "好的"; -/* No comment provided by engineer. */ +/* alert button */ "OK" = "好的"; /* No comment provided by engineer. */ @@ -3634,6 +3838,9 @@ new chat action */ /* No comment provided by engineer. */ "Only you can send disappearing messages." = "只有您可以发送限时消息。"; +/* No comment provided by engineer. */ +"Only you can send files and media." = "只有你可以发送文件和媒体。"; + /* No comment provided by engineer. */ "Only you can send voice messages." = "只有您可以发送语音消息。"; @@ -3649,10 +3856,14 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send disappearing messages." = "只有您的联系人才可以发送限时消息。"; +/* No comment provided by engineer. */ +"Only your contact can send files and media." = "只有你的联系人可以发送文件和媒体。"; + /* No comment provided by engineer. */ "Only your contact can send voice messages." = "只有您的联系人可以发送语音消息。"; -/* alert action */ +/* alert action +alert button */ "Open" = "打开"; /* No comment provided by engineer. */ @@ -3664,18 +3875,45 @@ new chat action */ /* authentication reason */ "Open chat console" = "打开聊天控制台"; +/* alert action */ +"Open clean link" = "打开干净链接"; + /* No comment provided by engineer. */ "Open conditions" = "打开条款"; +/* alert action */ +"Open full link" = "打开完整链接"; + /* new chat action */ "Open group" = "打开群"; +/* alert title */ +"Open link?" = "打开链接?"; + /* authentication reason */ "Open migration to another device" = "打开迁移到另一台设备"; +/* new chat action */ +"Open new chat" = "打开新聊天"; + +/* new chat action */ +"Open new group" = "打开新群"; + /* No comment provided by engineer. */ "Open Settings" = "打开设置"; +/* No comment provided by engineer. */ +"Open to accept" = "打开以接受"; + +/* No comment provided by engineer. */ +"Open to connect" = "打开以连接"; + +/* No comment provided by engineer. */ +"Open to join" = "打开以加入"; + +/* No comment provided by engineer. */ +"Open to use bot" = "打开来使用机器人"; + /* No comment provided by engineer. */ "Opening app…" = "正在打开应用程序…"; @@ -3715,6 +3953,9 @@ new chat action */ /* No comment provided by engineer. */ "other errors" = "其他错误"; +/* alert message */ +"Other file errors:\n%@" = "其他文件错误:\n%@"; + /* member role */ "owner" = "群主"; @@ -3760,6 +4001,12 @@ new chat action */ /* No comment provided by engineer. */ "Pending" = "待定"; +/* No comment provided by engineer. */ +"pending approval" = "待批准"; + +/* No comment provided by engineer. */ +"pending review" = "待审核"; + /* No comment provided by engineer. */ "Periodic" = "定期"; @@ -3826,15 +4073,33 @@ new chat action */ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "请安全地保存密码,如果您丢失了密码,您将无法更改它。"; +/* token info */ +"Please try to disable and re-enable notfications." = "请尝试禁用并重新启用通知。"; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "请等待群的协管审核你加入该群的请求。"; + +/* token info */ +"Please wait for token activation to complete." = "请等待token激活完成。"; + +/* token info */ +"Please wait for token to be registered." = "请等待token注册完成。"; + /* No comment provided by engineer. */ "Polish interface" = "波兰语界面"; +/* No comment provided by engineer. */ +"Port" = "端口"; + /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "保留最后的消息草稿及其附件。"; /* No comment provided by engineer. */ "Preset server address" = "预设服务器地址"; +/* No comment provided by engineer. */ +"Preset servers" = "预设服务器"; + /* No comment provided by engineer. */ "Preview" = "预览"; @@ -3844,18 +4109,18 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "隐私和安全"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "客户隐私。"; + /* No comment provided by engineer. */ "Privacy policy and conditions of use." = "隐私政策和使用条款。"; -/* No comment provided by engineer. */ -"Privacy redefined" = "重新定义隐私"; - -/* No comment provided by engineer. */ -"Private chats, groups and your contacts are not accessible to server operators." = "服务器运营方无法访问私密聊天、群组和你的联系人。"; - /* No comment provided by engineer. */ "Private filenames" = "私密文件名"; +/* No comment provided by engineer. */ +"Private media file names." = "私密媒体文件名。"; + /* No comment provided by engineer. */ "Private message routing" = "私有消息路由"; @@ -3871,6 +4136,9 @@ new chat action */ /* alert title */ "Private routing error" = "专用路由错误"; +/* alert title */ +"Private routing timeout" = "私密路由超时"; + /* No comment provided by engineer. */ "Profile and server connections" = "资料和服务器连接"; @@ -3886,9 +4154,6 @@ new chat action */ /* No comment provided by engineer. */ "Profile theme" = "个人资料主题"; -/* alert message */ -"Profile update will be sent to your contacts." = "个人资料更新将被发送给您的联系人。"; - /* No comment provided by engineer. */ "Prohibit audio/video calls." = "禁止音频/视频通话。"; @@ -3901,6 +4166,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit messages reactions." = "禁止消息回应。"; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "禁止向 协管 举报消息。"; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "禁止向成员发送私信。"; @@ -3928,6 +4196,9 @@ new chat action */ /* No comment provided by engineer. */ "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "保护您的 IP 地址免受联系人选择的消息中继的攻击。\n在*网络和服务器*设置中启用。"; +/* No comment provided by engineer. */ +"Protocol background timeout" = "协议后台超时"; + /* No comment provided by engineer. */ "Protocol timeout" = "协议超时"; @@ -3940,6 +4211,9 @@ new chat action */ /* No comment provided by engineer. */ "Proxied servers" = "代理服务器"; +/* No comment provided by engineer. */ +"Proxy requires password" = "代理需要密码"; + /* No comment provided by engineer. */ "Push notifications" = "推送通知"; @@ -3968,16 +4242,10 @@ new chat action */ "Read more" = "阅读更多"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)." = "阅读更多[User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode)。"; +"Read more in our GitHub repository." = "在我们的 GitHub 仓库 中阅读更多信息。"; /* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses)." = "在 [用户指南](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses) 中阅读更多内容。"; - -/* No comment provided by engineer. */ -"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "在 [用户指南](https://simplex.chat/docs/guide/readme.html#connect-to-friends) 中阅读更多内容。"; - -/* No comment provided by engineer. */ -"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "在我们的 [GitHub 仓库](https://github.com/simplex-chat/simplex-chat#readme) 中阅读更多信息。"; +"Read more in User Guide." = "阅读更多User Guide。"; /* No comment provided by engineer. */ "Receipts are disabled" = "回执已禁用"; @@ -3997,9 +4265,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "已受到确认……"; -/* notification */ -"Received file event" = "收到文件项目"; - /* message info title */ "Received message" = "收到的信息"; @@ -4060,6 +4325,12 @@ new chat action */ /* No comment provided by engineer. */ "Reduced battery usage" = "减少电池使用量"; +/* No comment provided by engineer. */ +"Register" = "注册"; + +/* token status text */ +"Registered" = "已注册"; + /* alert action reject incoming call via notification swipe action */ @@ -4071,6 +4342,12 @@ swipe action */ /* alert title */ "Reject contact request" = "拒绝联系人请求"; +/* alert title */ +"Reject member?" = "拒绝成员?"; + +/* No comment provided by engineer. */ +"rejected" = "被拒绝"; + /* call status */ "rejected call" = "拒接来电"; @@ -4080,16 +4357,25 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "中继服务器保护您的 IP 地址,但它可以观察通话的持续时间。"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "移除"; +/* alert action */ +"Remove and delete messages" = "移除并删除消息"; + +/* No comment provided by engineer. */ +"Remove archive?" = "删除存档?"; + /* No comment provided by engineer. */ "Remove image" = "移除图片"; /* No comment provided by engineer. */ -"Remove member" = "删除成员"; +"Remove link tracking" = "删除链接跟踪"; /* No comment provided by engineer. */ +"Remove member" = "删除成员"; + +/* alert title */ "Remove member?" = "删除成员吗?"; /* No comment provided by engineer. */ @@ -4104,12 +4390,18 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "删除了联系地址"; +/* No comment provided by engineer. */ +"removed from group" = "从群被删除了"; + /* profile update event chat item */ "removed profile picture" = "删除了资料图片"; /* rcv group event chat item */ "removed you" = "已将您移除"; +/* No comment provided by engineer. */ +"Removes messages and blocks members." = "删除消息并封禁成员。"; + /* No comment provided by engineer. */ "Renegotiate" = "重新协商"; @@ -4131,6 +4423,54 @@ swipe action */ /* chat item action */ "Reply" = "回复"; +/* chat item action */ +"Report" = "举报"; + +/* report reason */ +"Report content: only group moderators will see it." = "举报内容:仅协管会看到。"; + +/* report reason */ +"Report member profile: only group moderators will see it." = "举报成员个人资料:仅协管会看到。"; + +/* report reason */ +"Report other: only group moderators will see it." = "举报其他:仅协管会看到。"; + +/* No comment provided by engineer. */ +"Report reason?" = "举报理由?"; + +/* alert title */ +"Report sent to moderators" = "举报已发送至 协管"; + +/* report reason */ +"Report spam: only group moderators will see it." = "举报垃圾信息:仅协管会看到。"; + +/* report reason */ +"Report violation: only group moderators will see it." = "举报违规:仅协管会看到。"; + +/* report in notification */ +"Report: %@" = "举报: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "向协管举报消息已被禁止。"; + +/* No comment provided by engineer. */ +"Reports" = "举报"; + +/* No comment provided by engineer. */ +"request is sent" = "发送了请求"; + +/* No comment provided by engineer. */ +"request to join rejected" = "加入请求被拒绝"; + +/* rcv group event chat item */ +"requested connection" = "已请求连接"; + +/* rcv direct event chat item */ +"requested connection from group %@" = "来自群组%@的已请求连接"; + +/* chat list item title */ +"requested to connect" = "被请求连接"; + /* No comment provided by engineer. */ "Required" = "必须"; @@ -4182,9 +4522,21 @@ swipe action */ /* chat item action */ "Reveal" = "揭示"; +/* No comment provided by engineer. */ +"review" = "审核"; + /* No comment provided by engineer. */ "Review conditions" = "审阅条款"; +/* No comment provided by engineer. */ +"Review group members" = "审核群成员"; + +/* admission stage */ +"Review members" = "审核成员"; + +/* No comment provided by engineer. */ +"reviewed by admins" = "由管理员审核"; + /* No comment provided by engineer. */ "Revoke" = "吊销"; @@ -4213,6 +4565,12 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "保存(并通知联系人)"; +/* alert button */ +"Save (and notify members)" = "保存(并通知成员)"; + +/* alert title */ +"Save admission settings?" = "保存入群设置?"; + /* alert button */ "Save and notify contact" = "保存并通知联系人"; @@ -4228,6 +4586,9 @@ chat item action */ /* No comment provided by engineer. */ "Save group profile" = "保存群组资料"; +/* alert title */ +"Save group profile?" = "保存群资料?"; + /* No comment provided by engineer. */ "Save list" = "保存列表"; @@ -4306,9 +4667,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "搜索栏接受邀请链接。"; +/* No comment provided by engineer. */ +"Search files" = "搜索文件"; + +/* No comment provided by engineer. */ +"Search images" = "搜索图片"; + +/* No comment provided by engineer. */ +"Search links" = "搜索链接"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "搜索或粘贴 SimpleX 链接"; +/* No comment provided by engineer. */ +"Search videos" = "搜索视频"; + +/* No comment provided by engineer. */ +"Search voice messages" = "搜索语音消息"; + /* network option */ "sec" = "秒"; @@ -4339,6 +4715,9 @@ chat item action */ /* chat item action */ "Select" = "选择"; +/* No comment provided by engineer. */ +"Select chat profile" = "选择聊天个人资料"; + /* No comment provided by engineer. */ "Selected %lld" = "选定的 %lld"; @@ -4363,6 +4742,9 @@ chat item action */ /* No comment provided by engineer. */ "Send a live message - it will update for the recipient(s) as you type it" = "发送实时消息——它会在您键入时为收件人更新"; +/* No comment provided by engineer. */ +"Send contact request?" = "发送联络请求?"; + /* No comment provided by engineer. */ "Send delivery receipts to" = "将送达回执发送给"; @@ -4393,18 +4775,30 @@ chat item action */ /* No comment provided by engineer. */ "Send notifications" = "发送通知"; +/* No comment provided by engineer. */ +"Send private reports" = "发送私下举报"; + /* No comment provided by engineer. */ "Send questions and ideas" = "发送问题和想法"; /* No comment provided by engineer. */ "Send receipts" = "发送回执"; +/* No comment provided by engineer. */ +"Send request" = "发送请求"; + +/* No comment provided by engineer. */ +"Send request without message" = "发送无消息请求"; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "发送它们来自图库或自定义键盘。"; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "给新成员发送最多 100 条历史消息。"; +/* No comment provided by engineer. */ +"Send your private feedback to groups." = "向群发送私密反馈。"; + /* alert message */ "Sender cancelled file transfer." = "发送人已取消文件传输。"; @@ -4444,9 +4838,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "直接发送"; -/* notification */ -"Sent file event" = "已发送文件项目"; - /* message info title */ "Sent message" = "已发信息"; @@ -4465,6 +4856,12 @@ chat item action */ /* No comment provided by engineer. */ "Sent via proxy" = "通过代理发送"; +/* No comment provided by engineer. */ +"Server" = "服务器"; + +/* alert message */ +"Server added to operator %@." = "服务器已添加到运营方 %@。"; + /* No comment provided by engineer. */ "Server address" = "服务器地址"; @@ -4474,6 +4871,15 @@ chat item action */ /* srv error text. */ "Server address is incompatible with network settings." = "服务器地址与网络设置不兼容。"; +/* alert title */ +"Server operator changed." = "服务器运营方已更改。"; + +/* No comment provided by engineer. */ +"Server operators" = "服务器运营方"; + +/* alert title */ +"Server protocol changed." = "服务器协议已更改。"; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "服务器队列信息: %1$@\n\n上次收到的消息: %2$@"; @@ -4510,6 +4916,9 @@ chat item action */ /* No comment provided by engineer. */ "Set 1 day" = "设定1天"; +/* No comment provided by engineer. */ +"Set chat name…" = "设置聊天名称…"; + /* No comment provided by engineer. */ "Set contact name…" = "设置联系人姓名……"; @@ -4522,6 +4931,12 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "设置它以代替系统身份验证。"; +/* No comment provided by engineer. */ +"Set member admission" = "设置成员入群准许"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "在聊天中设置消息过期时间。"; + /* profile update event chat item */ "set new contact address" = "设置新的联系地址"; @@ -4537,6 +4952,9 @@ chat item action */ /* No comment provided by engineer. */ "Set passphrase to export" = "设置密码来导出"; +/* No comment provided by engineer. */ +"Set profile bio and welcome message." = "设置自我介绍和欢迎消息。"; + /* No comment provided by engineer. */ "Set the message shown to new members!" = "设置向新成员显示的消息!"; @@ -4546,6 +4964,9 @@ chat item action */ /* No comment provided by engineer. */ "Settings" = "设置"; +/* alert message */ +"Settings were changed." = "设置已修改。"; + /* No comment provided by engineer. */ "Shape profile images" = "改变个人资料图形状"; @@ -4556,11 +4977,14 @@ chat item action */ /* No comment provided by engineer. */ "Share 1-time link" = "分享一次性链接"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "和一位好友分享一次性链接"; + /* No comment provided by engineer. */ "Share address" = "分享地址"; -/* alert title */ -"Share address with contacts?" = "与联系人分享地址?"; +/* No comment provided by engineer. */ +"Share address publicly" = "公开分享地址"; /* No comment provided by engineer. */ "Share from other apps." = "从其他应用程序共享。"; @@ -4568,6 +4992,18 @@ chat item action */ /* No comment provided by engineer. */ "Share link" = "分享链接"; +/* alert button */ +"Share old address" = "分享旧地址"; + +/* alert button */ +"Share old link" = "分享旧链接"; + +/* No comment provided by engineer. */ +"Share profile" = "分享资料"; + +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "在社媒上分享 SimpleX 地址。"; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "分享此一次性邀请链接"; @@ -4575,7 +5011,16 @@ chat item action */ "Share to SimpleX" = "分享到 SimpleX"; /* No comment provided by engineer. */ -"Share with contacts" = "与联系人分享"; +"Share your address" = "分享地址"; + +/* No comment provided by engineer. */ +"Short description" = "短描述"; + +/* No comment provided by engineer. */ +"Short link" = "短链接"; + +/* No comment provided by engineer. */ +"Short SimpleX address" = "SimpleX 短地址"; /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "显示 → 通过专用路由发送的信息."; @@ -4685,6 +5130,9 @@ chat item action */ /* No comment provided by engineer. */ "SMP server" = "SMP 服务器"; +/* No comment provided by engineer. */ +"SOCKS proxy" = "SOCKS代理"; + /* blur media */ "Soft" = "软"; @@ -4700,15 +5148,25 @@ chat item action */ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "导入过程中出现一些非致命错误:"; +/* alert message */ +"Some servers failed the test:\n%@" = "有服务器测试未通过:\n%@"; + /* notification title */ "Somebody" = "某人"; +/* blocking reason +report reason */ +"Spam" = "垃圾信息"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "方形、圆形、或两者之间的任意形状."; /* chat item text */ "standard end-to-end encryption" = "标准端到端加密"; +/* No comment provided by engineer. */ +"Star on GitHub" = "在 GitHub 上加星"; + /* No comment provided by engineer. */ "Start chat" = "开始聊天"; @@ -4781,18 +5239,39 @@ chat item action */ /* No comment provided by engineer. */ "Support SimpleX Chat" = "支持 SimpleX Chat"; +/* No comment provided by engineer. */ +"Switch audio and video during the call." = "通话期间切换音频和视频。"; + +/* No comment provided by engineer. */ +"Switch chat profile for 1-time invitations." = "对一次性邀请切换聊天个人资料。"; + /* No comment provided by engineer. */ "System" = "系统"; /* No comment provided by engineer. */ "System authentication" = "系统验证"; +/* No comment provided by engineer. */ +"Tail" = "尾部"; + /* No comment provided by engineer. */ "Take picture" = "拍照"; /* No comment provided by engineer. */ "Tap button " = "点击按钮 "; +/* No comment provided by engineer. */ +"Tap Connect to chat" = "轻按连接进行聊天"; + +/* No comment provided by engineer. */ +"Tap Connect to send request" = "轻按连接来发送请求"; + +/* No comment provided by engineer. */ +"Tap Connect to use bot" = "轻按“连接”使用机器人"; + +/* No comment provided by engineer. */ +"Tap Join group" = "轻按加入群"; + /* No comment provided by engineer. */ "Tap to activate profile." = "点击以激活个人资料。"; @@ -4814,9 +5293,15 @@ chat item action */ /* No comment provided by engineer. */ "TCP connection" = "TCP 连接"; +/* No comment provided by engineer. */ +"TCP connection bg timeout" = "TCP 连接后台超时"; + /* No comment provided by engineer. */ "TCP connection timeout" = "TCP 连接超时"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "用于消息收发的 TCP 端口"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4829,9 +5314,13 @@ chat item action */ /* file error alert title */ "Temporary file error" = "临时文件错误"; -/* server test failure */ +/* relay test failure +server test failure */ "Test failed at step %@." = "在步骤 %@ 上测试失败。"; +/* No comment provided by engineer. */ +"Test notifications" = "测试通知"; + /* No comment provided by engineer. */ "Test server" = "测试服务器"; @@ -4850,9 +5339,15 @@ chat item action */ /* No comment provided by engineer. */ "Thanks to the users – contribute via Weblate!" = "感谢用户——通过 Weblate 做出贡献!"; +/* alert message */ +"The address will be short, and your profile will be shared via the address." = "地址不会长,将通过该简短地址分享个人资料。"; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "该应用可以在您收到消息或联系人请求时通知您——请打开设置以启用通知。"; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "应用通过在每个对话中使用不同运营方保护你的隐私。"; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "该应用程序将要求确认从未知文件服务器(.onion 除外)下载。"; @@ -4862,6 +5357,9 @@ chat item action */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "您扫描的码不是 SimpleX 链接的二维码。"; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "连接达到了未送达消息上限,你的联系人可能处于离线状态。"; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "您接受的连接将被取消!"; @@ -4874,15 +5372,15 @@ chat item action */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "加密正在运行,不需要新的加密协议。这可能会导致连接错误!"; -/* No comment provided by engineer. */ -"The future of messaging" = "下一代私密通讯软件"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "上一条消息的散列不同。"; /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "下一条消息的 ID 不正确(小于或等于上一条)。\n它可能是由于某些错误或连接被破坏才发生。"; +/* alert message */ +"The link will be short, and group profile will be shared via the link." = "链接不会长,群资料会通过短链接分享。"; + /* No comment provided by engineer. */ "The message will be deleted for all members." = "将为所有成员删除该消息。"; @@ -4898,6 +5396,12 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "旧数据库在迁移过程中没有被移除,可以删除。"; +/* No comment provided by engineer. */ +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "人类最古老的自由--与他人交谈而不被监视--建立在不会背叛它的基础设施之上。"; + +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "应用中的第二个预设运营方!"; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "我们错过的第二个\"√\"!✅"; @@ -4910,9 +5414,21 @@ chat item action */ /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "您粘贴的文本不是 SimpleX 链接。"; +/* No comment provided by engineer. */ +"The uploaded database archive will be permanently removed from the servers." = "已上传的数据库归档将会从服务器中永久移除。"; + /* No comment provided by engineer. */ "Themes" = "主题"; +/* No comment provided by engineer. */ +"Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "然后我们转向线上,每个平台都要求你提供一些信息--你的姓名、电话号码、好友列表。我们接受了这样一个事实:与人交流的代价就是让别人知道我们在和谁交流。每一代人,每一代科技,都遵循着这样的模式--电话、电子邮件、即时通讯、社交媒体。这似乎是唯一可行的方式。"; + +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "还有另一种方法。一个没有电话号码、没有用户名、没有账户、没有任何用户身份的网络。一个连接人们并传输加密信息的网络,而无需知道谁连接了。"; + +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "这些条件将同样适用于: **%@**。"; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "这些设置适用于您当前的配置文件 **%@**。"; @@ -4925,6 +5441,9 @@ chat item action */ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。"; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。"; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。"; @@ -4949,12 +5468,24 @@ chat item action */ /* No comment provided by engineer. */ "This group no longer exists." = "该群组已不存在。"; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。"; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "此链接已在其他移动设备上使用,请在桌面上创建新链接。"; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "此消息被删除或尚未收到。"; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "此设置适用于您当前聊天资料 **%@** 中的消息。"; +/* No comment provided by engineer. */ +"This setting is for your current profile **%@**." = "此设置用于当前个人资料 **%@**。"; + +/* No comment provided by engineer. */ +"Time to disappear is set only for new contacts." = "只为新联系人设置了消失时间。"; + /* No comment provided by engineer. */ "Title" = "标题"; @@ -4970,6 +5501,9 @@ chat item action */ /* No comment provided by engineer. */ "To make a new connection" = "建立新连接"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "为了防止链接被替换,你可以比较联系人安全代码。"; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "为了保护时区,图像/语音文件使用 UTC。"; @@ -4982,21 +5516,39 @@ chat item action */ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "为了保护隐私,SimpleX使用针对消息队列的标识符,而不是所有其他平台使用的用户ID,每个联系人都有独立的标识符。"; +/* No comment provided by engineer. */ +"To receive" = "消息接收"; + +/* No comment provided by engineer. */ +"To record speech please grant permission to use Microphone." = "为了记录语音请授予使用麦克风权限。"; + +/* No comment provided by engineer. */ +"To record video please grant permission to use Camera." = "为了录制视频请授予使用相机权限。"; + /* No comment provided by engineer. */ "To record voice message please grant permission to use Microphone." = "请授权使用麦克风以录制语音消息。"; /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "要显示您的隐藏的个人资料,请在**您的聊天个人资料**页面的搜索字段中输入完整密码。"; +/* No comment provided by engineer. */ +"To send" = "发送"; + +/* alert message */ +"To send commands you must be connected." = "你必须已连接才能发送命令。"; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "为了支持即时推送通知,聊天数据库必须被迁移。"; +/* alert message */ +"To use another profile after connection attempt, delete the chat and use the link again." = "要在连接尝试后使用不同的个人资料,请删除聊天并再次使用该链接。"; + +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "要使用**%@**的服务器,需接受条款。"; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "要与您的联系人验证端到端加密,请比较(或扫描)您设备上的代码。"; -/* No comment provided by engineer. */ -"Toggle chat list:" = "切换聊天列表:"; - /* No comment provided by engineer. */ "Toggle incognito when connecting." = "在连接时切换隐身模式。"; @@ -5012,11 +5564,8 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "传输会话"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "正在尝试连接到用于从该联系人接收消息的服务器(错误:%@)。"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "正在尝试连接到用于从该联系人接收消息的服务器。"; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "尝试连接到用于从该连接接收消息的服务器。"; /* No comment provided by engineer. */ "Turkish interface" = "土耳其语界面"; @@ -5048,6 +5597,9 @@ chat item action */ /* rcv group event chat item */ "unblocked %@" = "未阻止 %@"; +/* No comment provided by engineer. */ +"Undelivered messages" = "未送达的消息"; + /* No comment provided by engineer. */ "Unexpected migration state" = "未预料的迁移状态"; @@ -5114,6 +5666,9 @@ chat item action */ /* swipe action */ "Unread" = "未读"; +/* conn error description */ +"Unsupported connection link" = "不支持的连接链接"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "给新成员发送了最多 100 条历史消息。"; @@ -5129,6 +5684,9 @@ chat item action */ /* No comment provided by engineer. */ "Update settings?" = "更新设置?"; +/* No comment provided by engineer. */ +"Updated conditions" = "条款已更新"; + /* rcv group event chat item */ "updated group profile" = "已更新的群组资料"; @@ -5138,9 +5696,27 @@ chat item action */ /* No comment provided by engineer. */ "Updating settings will re-connect the client to all servers." = "更新设置会将客户端重新连接到所有服务器。"; +/* alert button */ +"Upgrade" = "升级"; + +/* No comment provided by engineer. */ +"Upgrade address" = "升级地址"; + +/* alert message */ +"Upgrade address?" = "升级地址?"; + /* No comment provided by engineer. */ "Upgrade and open chat" = "升级并打开聊天"; +/* alert message */ +"Upgrade group link?" = "升级群链接?"; + +/* No comment provided by engineer. */ +"Upgrade link" = "升级链接"; + +/* No comment provided by engineer. */ +"Upgrade your address" = "升级你的地址"; + /* No comment provided by engineer. */ "Upload errors" = "上传错误"; @@ -5163,17 +5739,26 @@ chat item action */ "Use .onion hosts" = "使用 .onion 主机"; /* No comment provided by engineer. */ -"Use chat" = "使用聊天"; +"Use %@" = "使用 %@"; /* new chat action */ "Use current profile" = "使用当前配置文件"; +/* No comment provided by engineer. */ +"Use for files" = "用于文件"; + +/* No comment provided by engineer. */ +"Use for messages" = "用于消息"; + /* No comment provided by engineer. */ "Use for new connections" = "用于新连接"; /* No comment provided by engineer. */ "Use from desktop" = "从桌面端使用"; +/* No comment provided by engineer. */ +"Use incognito profile" = "使用隐身个人资料"; + /* No comment provided by engineer. */ "Use iOS call interface" = "使用 iOS 通话界面"; @@ -5192,18 +5777,36 @@ chat item action */ /* No comment provided by engineer. */ "Use server" = "使用服务器"; +/* No comment provided by engineer. */ +"Use servers" = "使用服务器"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "使用 SimpleX Chat 服务器?"; +/* No comment provided by engineer. */ +"Use SOCKS proxy" = "使用 SOCKS 代理"; + +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "当未指定端口时使用TCP端口%@。"; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "仅预设服务器使用 TCP 协议 443 端口。"; + /* No comment provided by engineer. */ "Use the app while in the call." = "通话时使用本应用."; /* No comment provided by engineer. */ "Use the app with one hand." = "用一只手使用应用程序。"; +/* No comment provided by engineer. */ +"Use web port" = "使用 web 端口"; + /* No comment provided by engineer. */ "User selection" = "用户选择"; +/* No comment provided by engineer. */ +"Username" = "用户名"; + /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "使用 SimpleX Chat 服务器。"; @@ -5267,12 +5870,21 @@ chat item action */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "视频将在您的联系人在线时收到,请稍等或稍后查看!"; +/* No comment provided by engineer. */ +"Videos" = "视频"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "最大 1gb 的视频和文件"; +/* No comment provided by engineer. */ +"View conditions" = "查看条款"; + /* No comment provided by engineer. */ "View security code" = "查看安全码"; +/* No comment provided by engineer. */ +"View updated conditions" = "查看更新后的条款"; + /* chat feature */ "Visible history" = "可见的历史"; @@ -5342,6 +5954,9 @@ chat item action */ /* No comment provided by engineer. */ "Welcome message is too long" = "欢迎消息太大了"; +/* No comment provided by engineer. */ +"Welcome your contacts 👋" = "欢迎联系人👋"; + /* No comment provided by engineer. */ "What's new" = "更新内容"; @@ -5354,6 +5969,9 @@ chat item action */ /* No comment provided by engineer. */ "when IP hidden" = "当 IP 隐藏时"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "当启用了超过一个运营方时,没有一个运营方拥有了解谁和谁联络的元数据。"; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "当您与某人共享隐身聊天资料时,该资料将用于他们邀请您加入的群组。"; @@ -5408,6 +6026,9 @@ chat item action */ /* No comment provided by engineer. */ "You accepted connection" = "您已接受连接"; +/* snd group event chat item */ +"you accepted this member" = "你接受了该成员"; + /* No comment provided by engineer. */ "You allow" = "您允许"; @@ -5417,6 +6038,9 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "您已经连接到 %@。"; +/* No comment provided by engineer. */ +"You are already connected with %@." = "你已经与%@保持连接。"; + /* new chat sheet message */ "You are already connecting to %@." = "您已连接到 %@。"; @@ -5435,12 +6059,15 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "您已经加入了这个群组!\n重复加入请求?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "您已连接到用于接收该联系人消息的服务器。"; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "你已连接到用于接收该连接消息的服务器。"; /* No comment provided by engineer. */ "You are invited to group" = "您被邀请加入群组"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "未连接到用于从该连接接收消息的服务器(无订阅)。"; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "您未连接到这些服务器。私有路由用于向他们发送消息。"; @@ -5456,6 +6083,9 @@ chat item action */ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "您可以在外观设置中更改它。"; +/* No comment provided by engineer. */ +"You can configure servers via settings." = "你可以通过设置配置服务器。"; + /* No comment provided by engineer. */ "You can create it later" = "您可以以后创建它"; @@ -5480,6 +6110,9 @@ chat item action */ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "您可以从存档的联系人向%@发送消息。"; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "你可以设置连接名称,用来记住和谁分享了这个链接。"; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "您可以通过设置来设置锁屏通知预览。"; @@ -5504,6 +6137,9 @@ chat item action */ /* alert message */ "You can view invitation link again in connection details." = "您可以在连接详情中再次查看邀请链接。"; +/* alert message */ +"You can view your reports in Chat with admins." = "你可以在和管理员和聊天中查看你的举报。"; + /* alert title */ "You can't send messages!" = "您无法发送消息!"; @@ -5522,9 +6158,6 @@ chat item action */ /* No comment provided by engineer. */ "You could not be verified; please try again." = "您的身份无法验证,请再试一次。"; -/* No comment provided by engineer. */ -"You decide who can connect." = "你决定谁可以连接。"; - /* new chat sheet title */ "You have already requested connection!\nRepeat connection request?" = "您已经请求连接了!\n重复连接请求?"; @@ -5576,6 +6209,12 @@ chat item action */ /* snd group event chat item */ "you unblocked %@" = "您解封了 %@"; +/* No comment provided by engineer. */ +"You were born without an account" = "你生来就没有账户。"; + +/* No comment provided by engineer. */ +"You will be able to send messages **only after your request is accepted**." = "**只有在你的请求被接受后**你才能发送消息。"; + /* No comment provided by engineer. */ "You will be connected to group when the group host's device is online, please wait or check later!" = "您将在组主设备上线时连接到该群组,请稍等或稍后再检查!"; @@ -5594,6 +6233,9 @@ chat item action */ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "当静音配置文件处于活动状态时,您仍会收到来自静音配置文件的电话和通知。"; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "你将停止从这个聊天收到消息。聊天历史将被保留。"; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "您将停止接收来自该群组的消息。聊天记录将被保留。"; @@ -5609,6 +6251,9 @@ chat item action */ /* No comment provided by engineer. */ "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "您正在为该群组使用隐身个人资料——为防止共享您的主要个人资料,不允许邀请联系人"; +/* No comment provided by engineer. */ +"Your business contact" = "你的企业联系人"; + /* No comment provided by engineer. */ "Your calls" = "您的通话"; @@ -5618,9 +6263,15 @@ chat item action */ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "您的聊天数据库未加密——设置密码来加密。"; +/* alert title */ +"Your chat preferences" = "你的聊天偏好设置"; + /* No comment provided by engineer. */ "Your chat profiles" = "您的聊天资料"; +/* No comment provided by engineer. */ +"Your contact" = "你的联系人"; + /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "您的联系人发送的文件大于当前支持的最大大小 (%@)。"; @@ -5630,12 +6281,21 @@ chat item action */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "与您的联系人保持连接。"; +/* No comment provided by engineer. */ +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "你的对话内容始终属于你,就像互联网出现之前一样。网络不是一个你访问的地方,而是一个你创建并拥有的地方。无论你将其设为私密还是公开,任何人都无法将其夺走。"; + +/* No comment provided by engineer. */ +"Your credentials may be sent unencrypted." = "你的凭据可能以未经加密的方式被发送。"; + /* No comment provided by engineer. */ "Your current chat database will be DELETED and REPLACED with the imported one." = "您当前的聊天数据库将被删除并替换为导入的数据库。"; /* No comment provided by engineer. */ "Your current profile" = "您当前的资料"; +/* No comment provided by engineer. */ +"Your group" = "你的群"; + /* No comment provided by engineer. */ "Your ICE servers" = "您的 ICE 服务器"; @@ -5657,12 +6317,18 @@ chat item action */ /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。"; +/* alert message */ +"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "您的个人资料已修改。如果进行保存,更新后的个人资料将发送到所有联系人。"; + /* No comment provided by engineer. */ "Your random profile" = "您的随机资料"; /* No comment provided by engineer. */ "Your server address" = "您的服务器地址"; +/* No comment provided by engineer. */ +"Your servers" = "你的服务器"; + /* No comment provided by engineer. */ "Your settings" = "您的设置"; diff --git a/apps/multiplatform/.gitignore b/apps/multiplatform/.gitignore index f30061200c..bc00225c87 100644 --- a/apps/multiplatform/.gitignore +++ b/apps/multiplatform/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.kotlin /local.properties /.idea !/.idea/codeStyles/* @@ -15,4 +16,7 @@ android/build android/release common/build desktop/build -release \ No newline at end of file +release + +# Generated SimpleX assets +common/src/commonMain/resources/assets/simplex/ \ No newline at end of file diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md new file mode 100644 index 0000000000..26a36e75bb --- /dev/null +++ b/apps/multiplatform/CODE.md @@ -0,0 +1,309 @@ +# Coding and building + +You are an expert developer for SimpleX Chat, a privacy-first decentralized messaging platform. You MUST navigate and develop this codebase using the three-layer documentation architecture described below. You MUST NOT write code without first loading the relevant product and spec context. + +## Three-Layer Documentation Architecture + +### Why this structure exists + +LLMs start each session with no persistent understanding of the codebase. Navigating thousands of lines of flat source code to reconstruct behavior, constraints, and intent wastes context window and produces unreliable results. + +The `product/`, `spec/`, and source layers form a persistent, structured representation of the system that survives across sessions. Each layer is connected to the next by bidirectional cross-references. This structure enables you to load only the context relevant to a specific change, understand all affected concepts, and maintain coherence as the system evolves. + +### The layers + +| Layer | Contains | Question it answers | +|-------|----------|-------------------| +| `product/` | Capabilities, user flows, views, business rules, glossary | **What** does the system do and why? | +| `spec/` | Technical design, API contracts, database schema, service internals | **How** is it organized technically? | +| `common/src/commonMain/` | Shared Kotlin/Compose code (Android + Desktop) | What does it **execute** on both platforms? | +| `common/src/androidMain/` | Android-specific Kotlin (platform implementations) | What does it execute on **Android**? | +| `common/src/desktopMain/` | Desktop-specific Kotlin (platform implementations) | What does it execute on **Desktop**? | +| `android/src/main/` | Android app module (Application, Activity, Services) | What is the **Android entry point**? | +| `desktop/src/jvmMain/` | Desktop app module (main function) | What is the **Desktop entry point**? | +| `../../src/Simplex/Chat/` | Haskell core (chat logic, protocol, database) | What does the **core** execute? | + +Each layer links to the next: +- `product/concepts.md` links every concept to its spec docs, source files, and tests in a single table — this is the primary navigation entry point +- `product/views/*.md` and `product/flows/*.md` each have a **Related spec:** line linking to their most relevant spec documents +- `product/glossary.md` uses *See: [spec/...]* references and `product/rules.md` uses **Spec:** [spec/...] references to link individual terms and rules down to spec +- `spec/` documents contain **Source:** headers and inline function links pointing down to source. Line references MUST be clickable by embedding the `#Lxx-Lyy` fragment in the link URL: [`functionName()`](common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#Lxx-Lyy). You MUST NOT duplicate line numbers in the display text — the URL fragment is sufficient. Why: redundant line numbers in display text create maintenance burden on every line shift. +- Reverse direction: the Document Map (end of this file) maps source → spec → product + +### Navigation workflow + +When the user requests any change, you MUST follow these steps before writing any code: + +1. **Identify scope.** You MUST read `product/concepts.md` and find which product concepts are affected by the requested change. Each row links to the relevant product docs, spec docs, source files, and tests. Why: concepts.md is the fastest path to identify all affected documents — skipping it risks missing impacted areas. + +2. **Load product context.** You MUST read the relevant `product/views/*.md` or `product/flows/*.md` to understand current user-facing behavior. For business constraints, you MUST read `product/rules.md`. Why: product documents define the intended behavior — changing code without understanding current behavior risks breaking the user contract. + +3. **Load spec context.** You MUST follow the product → spec links to read the relevant `spec/*.md` or `spec/services/*.md`. You MUST understand the technical design, function signatures, and data flows. Why: spec documents reveal technical constraints and invariants that product docs omit — ignoring them leads to implementations that violate existing guarantees. + +4. **Load source context.** You MUST follow the spec → source links (with line numbers) to read the relevant source files. Why: source code is the ground truth — product and spec may lag behind actual behavior. + +5. **Identify full impact.** You MUST read `spec/impact.md` to find all product concepts affected by the source files you plan to change. This determines which documents you MUST update after the code change. Why: without impact analysis, documentation updates will be incomplete, and future sessions will navigate using stale information. + +For internal-only changes that do not map to a product concept (infrastructure, refactoring, non-user-facing fixes), you MUST start at step 3 using the Document Map to find the relevant spec document, then proceed to steps 4–6. + +6. **Implement.** Make the code change in source, then you MUST update all affected documentation as described in the Change Protocol below. + +### Key navigation documents + +| Document | Purpose | When to read | +|----------|---------|-------------| +| `product/concepts.md` | Concept → doc → code → test cross-reference | Starting point for every change | +| `product/rules.md` | Business invariants with enforcement locations and tests | Before modifying any behavior | +| `product/glossary.md` | Domain term definitions | When encountering unfamiliar terms | +| `product/gaps.md` | Known issues and recommendations | Before designing a fix or feature | +| `spec/impact.md` | Source file → affected product concepts | After identifying which files to change | +| Document Map (below) | Source ↔ spec ↔ product mapping | When updating documentation | + +--- + +## Code Security + +When designing code and planning implementations, you MUST: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. Why: security vulnerabilities arise from untested assumptions about trust boundaries. +- Formulate an explicit threat model for each change — who can do which undesirable things and under which circumstances. Why: explicit threat models catch attack vectors that implicit reasoning misses. + +--- + +## Code Style + +**Follow existing code patterns — you MUST:** +- Match the style of surrounding code. Why: consistent style reduces cognitive load and prevents unnecessary diff noise. +- Use Kotlin data classes for value types, regular classes for reference types, and sealed classes/interfaces for variants. Why: correct type choices leverage the type system for compile-time correctness. +- Prefer exhaustive `when` expressions over `else` branches. Why: `else` branches bypass compiler checks for new sealed subclasses and hide bugs. + +**Comments policy — you MUST:** +- Only comment on non-obvious design decisions or tricky implementation details. Why: redundant comments create maintenance burden and drift from code. +- Keep function names and type signatures self-documenting. Why: good names eliminate the need for most comments. +- Assume a competent Kotlin reader. Why: over-explaining trivial Kotlin adds noise without value. + +**Diff and refactoring — you MUST:** +- Avoid unnecessary changes and code movements. Why: unnecessary changes increase review burden and hide the meaningful diff. +- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring itself. Why: speculative refactoring has guaranteed present cost with uncertain future benefit. +- Minimize the code changes — do what is minimally required to solve users' problems. Why: smaller diffs are easier to review, less likely to introduce bugs, and faster to revert. + +**Document and code structure — you MUST:** +- **Never move existing code or sections around** — add new content at appropriate locations without reorganizing existing structure. Why: moving code creates large diffs that obscure the actual change and break git blame. +- When adding new sections to documents, continue the existing numbering scheme. Why: consistent numbering preserves document navigability. +- Minimize diff size — prefer small, targeted changes over reorganization. Why: large diffs compound review errors and make rollback difficult. + +**Code analysis and review — you MUST:** +- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. Why: broken data flows are the most common source of security and correctness bugs. +- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. Why: function signatures can be misleading about actual behavior. +- Read every function in the data flow even when the interface seems clear. Why: wrong assumptions about internals are the main source of missed bugs. + +--- + +## Plans + +When developing via plans (non-trivial features, multi-step changes, architectural decisions), you MUST store the plan in the `plans/` folder before implementing. Why: plans are the persistent record of design decisions and rationale — without them, future sessions cannot understand why the system was built the way it was. + +### Plan requirements + +1. **File naming.** You MUST use the format `YYYYMMDD_NN.md` (e.g., `20260211_01.md`). Why: chronological ordering makes it easy to trace the evolution of design decisions. + +2. **Plan structure.** Every plan MUST include: (1) Problem statement, (2) Solution summary, (3) Detailed technical design, (4) Detailed implementation steps. Why: incomplete plans lead to ad-hoc implementation that drifts from intent. + +3. **Consistency with product/ and spec/.** The plan MUST be consistent with the current state of `product/` and `spec/`. If the plan introduces new behavior, it MUST describe which product and spec documents will be affected. Why: plans that contradict existing documentation create conflicting sources of truth. + +4. **Adversarial self-review.** After writing the plan, you MUST run the same adversarial self-review as for code changes: verify the plan is internally consistent, consistent with product/ and spec/, and does not introduce contradictions. You MUST repeat until two consecutive passes find zero issues. Why: an incoherent plan produces incoherent implementation. + +--- + +## Change Protocol + +### The rule + +Every code change MUST include corresponding updates to `spec/` and `product/`. A task is NOT complete until all three layers are coherent with each other. Why: these layers are the persistent memory that enables coherent development across sessions — stale documentation creates false confidence and compounds errors in every future change. + +### What to update + +1. **spec/ — on every code change.** You MUST update the corresponding spec document to reflect the change. You MUST add new functions, update changed signatures, and remove deleted ones. Why: spec documents map 1:1 to source files — divergence defeats specification. + +2. **product/ — when user-visible behavior changes.** You MUST update the relevant `product/views/*.md` and any affected `product/flows/*.md`. You MUST update `product/rules.md` when business invariants change. Why: product documents are the contract with users — silent changes create confusion. + +3. **Line number references — on every code change.** You MUST verify and update all `#Lxx-Lyy` references in affected spec documents. Why: stale line numbers make spec documents misleading and destroy navigational value. + +4. **Cross-references — when adding or removing files.** You MUST add corresponding spec documents and update `spec/README.md` document index and reverse index. When adding pages, you MUST add `product/views/` and `spec/client/` documents. You MUST update the Document Map at the end of this file. Why: every source file must be covered for the navigation system to work. + +5. **Impact graph — when adding files or changing what a file affects.** You MUST update `spec/impact.md` to reflect the source file → product concept mapping. Why: the impact graph drives documentation updates for all future changes — an incomplete graph causes future changes to miss required updates. + +6. **Concept index — when adding or changing product concepts.** You MUST add or update the relevant row in `product/concepts.md` with links to product docs, spec docs, source files, and tests. Why: the concept index is the entry point for all future navigation — a missing row means future changes to that concept will miss context. + +7. **[GAP] annotations — when discovering issues.** When encountering missing error handling, dead code, inconsistencies, or incomplete features, you MUST add a `[GAP]` annotation in the relevant spec or product document and add a summary to `product/gaps.md`. Why: this builds institutional knowledge about technical debt. + +8. **[REC] annotations — when identifying improvements.** You MUST add a `[REC]` annotation in the relevant document. Why: capturing improvement ideas at discovery time preserves context that is lost later. + +9. **Preserve document structure.** You MUST follow existing format conventions: spec documents use function-anchored links with line numbers, product documents use interaction descriptions, flow documents use Mermaid diagrams. Why: consistent structure makes documents predictable and navigable. + +### Adversarial self-review + +After completing all changes (code + documentation), you MUST run an adversarial self-review. You MUST check coherence both within each layer and across layers. + +**Within-layer coherence — you MUST verify:** +- spec/ is internally consistent — no contradictory descriptions, state machines have no unreachable states, data model is referentially intact +- product/ is internally consistent — flows match views, rules match behavior descriptions + +**Across-layer coherence — you MUST verify:** +- Every new or changed function in source appears in the corresponding spec/ document +- Every user-visible behavior change in source appears in the relevant product/ document +- All `#Lxx-Lyy` line references in affected spec documents point to the correct lines +- All cross-references resolve — product → spec links, spec → source links +- `spec/impact.md` covers all affected product concepts for the changed source files +- `product/concepts.md` rows are current for any affected concepts + +**Convergence:** You MUST repeat the review-and-fix cycle until two consecutive passes find zero issues. You MUST fix all issues discovered between passes. Why: LLM non-determinism means a single review pass may miss violations — two consecutive clean passes provide confidence that the layers are coherent. + +--- + +## Multiplatform Architecture Notes + +### Kotlin Multiplatform (KMP) + Compose Multiplatform + +The app uses Kotlin Multiplatform with Compose Multiplatform for shared UI. The project has three Gradle modules: + +- **common/** — Shared library containing all models, views, platform abstractions, and theme system +- **android/** — Android app module (Application, Activity, Services) +- **desktop/** — Desktop JVM app module (main entry point) + +### expect/actual Pattern + +Platform-specific code uses Kotlin's `expect`/`actual` mechanism. The `commonMain` source set declares `expect` functions/classes, and `androidMain`/`desktopMain` provide `actual` implementations. Files follow the naming convention: +- `commonMain`: `FileName.kt` (contains `expect` declarations) +- `androidMain`: `FileName.android.kt` (contains `actual` implementations) +- `desktopMain`: `FileName.desktop.kt` (contains `actual` implementations) + +When modifying platform abstractions, you MUST update both `actual` implementations. + +### Source Set Structure + +``` +common/src/ +├── commonMain/kotlin/chat/simplex/common/ -- Shared code (195 files) +│ ├── model/ -- ChatModel, SimpleXAPI, CryptoFile +│ ├── platform/ -- expect/actual platform abstractions +│ ├── ui/theme/ -- Theme system (ThemeManager, colors, types) +│ └── views/ -- Compose UI (chat, chatlist, call, settings, etc.) +├── androidMain/kotlin/chat/simplex/common/ -- Android actuals (55 files) +│ ├── platform/ -- actual implementations +│ └── views/ -- Android-specific view variants +├── desktopMain/kotlin/chat/simplex/common/ -- Desktop actuals (56 files) +│ ├── platform/ -- actual implementations +│ └── views/ -- Desktop-specific view variants +android/src/main/java/chat/simplex/app/ -- Android app (8 files) +desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) +``` + +### Platform Differences + +| Aspect | Android | Desktop | +|--------|---------|---------| +| Layout | 2-column (chat list → chat) | 3-column (sidebar → chat list → details) | +| Background messaging | SimplexService (foreground service) + MessagesFetcherWorker (WorkManager) | Continuous (always-on process) | +| Notifications | Android NotificationManager with channels | Desktop system notifications | +| Calls | CallActivity (separate Activity) + CallService | In-window call view | +| Video playback | ExoPlayer | VLC (VLCJ) | +| Authentication | Android BiometricPrompt | Passcode only | +| Auto-update | Play Store / manual APK | Built-in AppUpdater | +| Window management | Standard Activity lifecycle | StoreWindowState persistence | +| Entry point | SimplexApp (Application) + MainActivity | Main.kt → initHaskell() → showApp() | + +--- + +## Document Map + +### Shared Sources (commonMain) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| common/.../common/App.kt | spec/architecture.md | product/views/chat-list.md | +| common/.../common/AppLock.kt | spec/architecture.md | product/views/settings.md | +| common/.../common/model/ChatModel.kt | spec/state.md | product/concepts.md | +| common/.../common/model/SimpleXAPI.kt | spec/api.md, spec/architecture.md | product/concepts.md | +| common/.../common/model/CryptoFile.kt | spec/services/files.md | product/flows/file-transfer.md | +| common/.../common/platform/Core.kt | spec/architecture.md | product/concepts.md | +| common/.../common/platform/AppCommon.kt | spec/architecture.md | product/flows/onboarding.md | +| common/.../common/platform/Notifications.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/NtfManager.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/Files.kt | spec/services/files.md | product/flows/file-transfer.md | +| common/.../common/platform/SimplexService.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/Share.kt | spec/architecture.md | product/concepts.md | +| common/.../common/platform/VideoPlayer.kt | spec/services/files.md | product/views/chat.md | +| common/.../common/platform/RecAndPlay.kt | spec/services/files.md | product/views/chat.md | +| common/.../common/platform/UI.kt | spec/architecture.md | product/views/chat.md | +| common/.../common/platform/Platform.kt | spec/architecture.md | product/concepts.md | +| common/.../common/ui/theme/ThemeManager.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/ui/theme/Theme.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/ui/theme/Color.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/views/chatlist/ChatListView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/ChatListNavLinkView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/ChatPreviewView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/UserPicker.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/TagListView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chat/ChatView.kt | spec/client/chat-view.md | product/views/chat.md | +| common/.../common/views/chat/ComposeView.kt | spec/client/compose.md | product/views/chat.md | +| common/.../common/views/chat/SendMsgView.kt | spec/client/compose.md | product/views/chat.md | +| common/.../common/views/chat/ChatInfoView.kt | spec/client/chat-view.md | product/views/contact-info.md | +| common/.../common/views/chat/group/ | spec/client/chat-view.md | product/views/group-info.md | +| common/.../common/views/chat/item/ | spec/client/chat-view.md | product/views/chat.md | +| common/.../common/views/call/CallView.kt | spec/services/calls.md | product/views/call.md | +| common/.../common/views/call/IncomingCallAlertView.kt | spec/services/calls.md | product/views/call.md | +| common/.../common/views/call/WebRTC.kt | spec/services/calls.md | product/flows/calling.md | +| common/.../common/views/newchat/NewChatView.kt | spec/client/navigation.md | product/views/new-chat.md | +| common/.../common/views/newchat/AddGroupView.kt | spec/client/navigation.md | product/views/new-chat.md | +| common/.../common/views/usersettings/SettingsView.kt | spec/client/navigation.md | product/views/settings.md | +| common/.../common/views/usersettings/Appearance.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/views/usersettings/PrivacySettings.kt | spec/client/navigation.md | product/views/settings.md | +| common/.../common/views/usersettings/networkAndServers/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/usersettings/UserProfilesView.kt | spec/client/navigation.md | product/views/user-profiles.md | +| common/.../common/views/onboarding/ | spec/client/navigation.md | product/views/onboarding.md | +| common/.../common/views/localauth/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/database/ | spec/database.md | product/views/settings.md | +| common/.../common/views/migration/ | spec/database.md | product/flows/onboarding.md | +| common/.../common/views/remote/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/contacts/ | spec/client/chat-view.md | product/views/contact-info.md | +| common/.../common/views/helpers/ | spec/architecture.md | product/concepts.md | + +### Android-Specific Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| android/.../app/SimplexApp.kt | spec/architecture.md | product/flows/onboarding.md | +| android/.../app/MainActivity.kt | spec/architecture.md | product/views/chat-list.md | +| android/.../app/SimplexService.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/CallService.kt | spec/services/calls.md | product/flows/calling.md | +| android/.../app/MessagesFetcherWorker.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/model/NtfManager.android.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/views/call/CallActivity.kt | spec/services/calls.md | product/views/call.md | + +### Desktop-Specific Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md | +| common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | +| common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md | +| common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md | + +### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/multiplatform/`) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| ../../src/Simplex/Chat/Controller.hs | spec/api.md | product/concepts.md | +| ../../src/Simplex/Chat/Types.hs | spec/api.md | product/glossary.md | +| ../../src/Simplex/Chat/Core.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Protocol.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Messages.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Messages/CIContent.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Call.hs | spec/services/calls.md | product/flows/calling.md | +| ../../src/Simplex/Chat/Files.hs | spec/services/files.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Messages.hs | spec/database.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Store/Groups.hs | spec/database.md | product/flows/group-lifecycle.md | +| ../../src/Simplex/Chat/Store/Direct.hs | spec/database.md | product/flows/connection.md | +| ../../src/Simplex/Chat/Store/Files.hs | spec/database.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Profiles.hs | spec/database.md | product/views/user-profiles.md | diff --git a/apps/multiplatform/README.md b/apps/multiplatform/README.md index e8b0e086c9..eef1048ada 100644 --- a/apps/multiplatform/README.md +++ b/apps/multiplatform/README.md @@ -1,8 +1,105 @@ # Android App Development -This readme is currently a stub and as such is in development. +This is a guide to contributing to the develop of the SimpleX android and desktop apps. -Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app. +## Project Overview + +This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX Chat, sharing code between Android and Desktop (JVM) platforms using Compose Multiplatform for UI. + +## Build Commands + +```bash +# Android debug APK +./gradlew assembleDebug + +# Android release APK +./gradlew assembleRelease + +# Desktop distribution (current OS) +./gradlew :desktop:packageDistributionForCurrentOS + +# Run desktop/JVM tests +./gradlew desktopTest + +# Run Android instrumented tests (requires connected device/emulator) +./gradlew connectedAndroidTest + +# Build native libraries for all platforms +./gradlew common:cmakeBuild -PcrossCompile + +# Clean build +./gradlew clean +``` + +## Architecture + +### Module Structure + +- **`common/`** - Shared code (Compose UI, models, business logic) + - `src/commonMain/` - Cross-platform code + - `src/androidMain/` - Android-specific implementations + - `src/desktopMain/` - Desktop-specific implementations +- **`android/`** - Android app container +- **`desktop/`** - Desktop JVM app container + +### Key Components (`common/src/commonMain/kotlin/chat/simplex/common/`) + +- **`model/ChatModel.kt`** - Main state container with reactive properties (MutableState, MutableStateFlow) +- **`model/SimpleXAPI.kt`** - API bindings to Haskell core library via FFI +- **`platform/Core.kt`** - FFI interface to native `libapp` library +- **`platform/`** - Platform abstraction layer (expect/actual pattern for Android/Desktop specifics) +- **`views/`** - Compose UI screens organized by feature (chat, chatlist, call, usersettings, etc.) +- **`ui/theme/`** - Design system (colors, typography, shapes) + +### Native Integration + +The app calls into a Haskell core library via JNI/FFI: +- CMake builds in `common/src/commonMain/cpp/android/` and `cpp/desktop/` +- Cross-compilation toolchains in `cpp/toolchains/` +- Built libraries go to `cpp/desktop/libs/` (organized by platform) + +## Configuration + +### `local.properties` (create from `local.properties.example`) + +```properties +compression.level=0 # APK compression (0-9) +enable_debuggable=true # Debug mode +application_id.suffix=.debug # Multiple app instances on same device +app.name=SimpleX Debug # App name for debug builds +``` + +### `gradle.properties` + +Contains versions (Kotlin, Compose, AGP) and app version info. Key settings: +- `kotlin.jvm.target=11` +- `database.backend=sqlite` (or `postgres`) + +## Testing + +Tests are in: +- `common/src/commonTest/kotlin/` - Cross-platform tests +- `common/src/desktopTest/kotlin/` - Desktop-specific tests (run with `./gradlew desktopTest`) +- `android/src/androidTest/` - Android instrumented tests + +## Resources & Localization + +- String resources: `common/src/commonMain/resources/MR/base/strings.xml` + 21 language variants +- Uses Moko Resources (`dev.icerock.moko:resources`) for cross-platform resource management +- The `adjustFormatting` gradle task validates string resources during build + +## Platform-Specific Notes + +### Android +- Min SDK 26, Target SDK 35 +- NDK 23.1.7779620 +- Supports ABI splits: `arm64-v8a`, `armeabi-v7a` +- Deep linking requires SHA certificate fingerprint in `assetlinks.json` (see README.md) + +### Desktop +- Distributions: DMG (macOS), MSI/EXE (Windows), DEB (Linux) +- Mac signing/notarization configured via `local.properties` +- Video playback uses VLCJ ## Gotchas diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index ae608c8c1d..5255319194 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -194,7 +194,7 @@ tasks { workingDir("../../scripts/android") environment = mapOf( "JAVA_HOME" to "$javaHome", - "PATH" to "${javaHome}/bin${File.pathSeparator}${System.getenv("PATH")}" + "PATH" to "${System.getenv("PATH")}:$javaHome/bin" ) commandLine = listOf( "./compress-and-sign-apk.sh", diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index d6059896a5..9e059afa14 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -51,6 +51,7 @@ ("copySimplexAssets") { + dependsOn(verifySimplexAssets) + from(srcImagesDir) + into(simplexAssetsLocal.resolve("MR/images")) + } +} else { + tasks.register("cleanSimplexAssets") { + delete(simplexAssetsLocal) + } +} + kotlin { androidTarget() jvm("desktop") @@ -31,6 +56,11 @@ kotlin { } val commonMain by getting { + if (hasSimplexAssets) { + resources.srcDir(simplexAssetsLocal) + } else { + resources.srcDir("src/commonMain/resources/assets/default") + } dependencies { api(compose.runtime) api(compose.foundation) @@ -118,8 +148,8 @@ kotlin { implementation("org.slf4j:slf4j-simple:2.0.12") implementation("uk.co.caprica:vlcj:4.8.3") implementation("net.java.dev.jna:jna:5.14.0") - implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") - implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf") implementation("com.squareup.okhttp3:okhttp:4.12.0") } } @@ -160,12 +190,18 @@ buildConfig { buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}") + buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets") } } afterEvaluate { tasks.named("generateMRcommonMain") { dependsOn("adjustFormatting") + if (hasSimplexAssets) { + dependsOn("copySimplexAssets") + } else { + dependsOn("cleanSimplexAssets") + } } tasks.create("adjustFormatting") { doLast { @@ -255,8 +291,11 @@ afterEvaluate { val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$") val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") + val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found") val treeList = ArrayList(tree.toList()) treeList.remove(baseStringsFile) + // removed lv/strings.xml file with 100+ errors + treeList.remove(lvStringsFile) treeList.add(0, baseStringsFile) val baseFormatting = mutableMapOf>() treeList.forEachIndexed { index, file -> diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 22e53af849..011619bab0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -108,6 +108,7 @@ class ActiveCallState: Closeable { } +// Spec: spec/services/calls.md#ActiveCallView @SuppressLint("SourceLockedOrientationActivity") @Composable actual fun ActiveCallView() { @@ -393,7 +394,7 @@ private fun ActiveCallOverlayLayout( DisabledBackgroundCallsButton() } - BoxWithConstraints(Modifier.padding(start = 6.dp, end = 6.dp, bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) { + BoxWithConstraints(Modifier.navigationBarsPadding().padding(start = 6.dp, end = 6.dp, bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) { val size = ((maxWidth - DEFAULT_PADDING_HALF * 4) / 5).coerceIn(0.dp, 60.dp) // limiting max width for tablets/wide screens, will be displayed in the center val padding = ((min(420.dp, maxWidth) - size * 5) / 4).coerceAtLeast(0.dp) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 9d1e0c4e97..a5021ae54c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -182,6 +182,8 @@ private fun spannableStringToAnnotatedString( actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI() +actual fun clearImageCaches() {} + // https://developer.android.com/training/data-storage/shared/documents-files#bitmap actual suspend fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt index d9d3af7bb7..a4fc74f6d4 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt @@ -10,7 +10,7 @@ import chat.simplex.res.MR @Composable actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { if (user == null) { - OnboardingActionButton(Modifier.fillMaxWidth(), labelId = MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onclick = onclick) + OnboardingActionButton(Modifier.fillMaxWidth(), labelId = MR.strings.get_started, onboarding = OnboardingStage.Step2_CreateProfile, onclick = onclick) } else { OnboardingActionButton(Modifier.fillMaxWidth(), labelId = MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onclick = onclick) } diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt index 49794a8ab5..fcba574974 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt @@ -53,12 +53,19 @@ add_library( support SHARED IMPORTED ) set_target_properties( support PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so) +target_compile_options(app-lib PRIVATE + -g0 +) + # Specifies libraries CMake should link to your target library. You # can link multiple libraries, such as libraries you define in this # build script, prebuilt third-party libraries, or system libraries. # https://developer.android.com/guide/practices/page-sizes#cmake -target_link_options(app-lib PRIVATE "-Wl,-z,max-page-size=16384") +target_link_options(app-lib PRIVATE + "-Wl,-z,max-page-size=16384" + "-Wl,--build-id=none" +) target_link_libraries( # Specifies the target library. app-lib @@ -67,4 +74,10 @@ target_link_libraries( # Specifies the target library. # Links the target library to the log library # included in the NDK. - ${log-lib}) + ${log-lib} +) + +add_custom_command(TARGET app-lib POST_BUILD + COMMAND ${CMAKE_STRIP} --remove-section=.comment $ + COMMENT "Stripping .comment section from app-lib" +) diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt index b304800a3a..1e3f62d384 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt @@ -54,11 +54,10 @@ add_library( # Sets the name of the library. simplex-api.c) add_library( simplex SHARED IMPORTED ) +FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libsimplex.${OS_LIB_EXT}) if(WIN32) - FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT}) set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB}) else() - FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex-chat*.${OS_LIB_EXT}) set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) endif() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 70e0067260..7542a0b8c6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -34,6 +34,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.ModalManager.Companion.fromEndToStartTransition import chat.simplex.common.views.helpers.ModalManager.Companion.fromStartToEndTransition import chat.simplex.common.views.localauth.VerticalDivider +import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR @@ -42,6 +43,7 @@ import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +// Spec: spec/client/navigation.md#AppScreen @Composable fun AppScreen() { AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() } @@ -78,6 +80,7 @@ fun AppScreen() { } } +// Spec: spec/client/navigation.md#MainScreen @Composable fun MainScreen() { val chatModel = ChatModel @@ -139,10 +142,8 @@ fun MainScreen() { when { onboarding == OnboardingStage.Step1_SimpleXInfo && chatModel.migrationState.value != null -> { // In migration process. Nothing should interrupt it, that's why it's the first branch in when() - SimpleXInfo(chatModel, onboarding = true) - if (appPlatform.isDesktop) { - ModalManager.fullscreen.showInView() - } + if (appPlatform.isDesktop) DesktopOnboarding(onboarding, chatModel) + else SimpleXInfo(chatModel, onboarding = true) } chatModel.dbMigrationInProgress.value -> DefaultProgressView(stringResource(MR.strings.database_migration_in_progress)) chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database)) @@ -172,36 +173,31 @@ fun MainScreen() { } } } - else -> AnimatedContent(targetState = onboarding, - transitionSpec = { - if (targetState > initialState) { - fromEndToStartTransition() - } else { - fromStartToEndTransition() - }.using(SizeTransform(clip = false)) - } - ) { state -> - when (state) { - OnboardingStage.OnboardingComplete -> { /* handled out of AnimatedContent block */} - OnboardingStage.Step1_SimpleXInfo -> { - SimpleXInfo(chatModel, onboarding = true) - if (appPlatform.isDesktop) { - ModalManager.fullscreen.showInView() + else -> { + if (appPlatform.isDesktop) { + DesktopOnboarding(onboarding, chatModel) + } else { + AnimatedContent(targetState = onboarding, + transitionSpec = { + if (targetState > initialState) { + fromEndToStartTransition() + } else { + fromStartToEndTransition() + }.using(SizeTransform(clip = false)) + } + ) { state -> + when (state) { + OnboardingStage.OnboardingComplete -> {} + OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true) + OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} + OnboardingStage.LinkAMobile -> LinkAMobile() + OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) + OnboardingStage.Step3_ChooseServerOperators, + OnboardingStage.Step3_CreateSimpleXAddress, + OnboardingStage.Step4_SetNotificationsMode -> YourNetworkView(chatModel) + OnboardingStage.Step4_NetworkCommitments -> OnboardingConditionsView(chatModel) } } - OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} - OnboardingStage.LinkAMobile -> LinkAMobile() - OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) - OnboardingStage.Step3_ChooseServerOperators -> { - val modalData = remember { ModalData() } - modalData.OnboardingConditionsView() - if (appPlatform.isDesktop) { - ModalManager.fullscreen.showInView() - } - } - // Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped - OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel) - OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } } } @@ -273,6 +269,27 @@ fun MainScreen() { } } +@Composable +private fun DesktopOnboarding(onboarding: OnboardingStage, chatModel: ChatModel) { + if (onboarding == OnboardingStage.LinkAMobile) { + LinkAMobile() + ModalManager.fullscreen.showInView() + } else { + DesktopOnboardingShell(onboarding) { + when (onboarding) { + OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true) + OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} + OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) + OnboardingStage.Step3_ChooseServerOperators, + OnboardingStage.Step3_CreateSimpleXAddress, + OnboardingStage.Step4_SetNotificationsMode -> YourNetworkView(chatModel) + OnboardingStage.Step4_NetworkCommitments -> OnboardingConditionsView(chatModel) + else -> {} + } + } + } +} + val ANDROID_CALL_TOP_PADDING = 40.dp @Composable @@ -289,6 +306,7 @@ fun AndroidWrapInCallLayout(content: @Composable () -> Unit) { } } +// Spec: spec/client/navigation.md#AndroidScreen @Composable fun AndroidScreen(userPickerState: MutableStateFlow) { BoxWithConstraints { @@ -380,7 +398,9 @@ fun CenterPartOfScreen() { } when (currentChatId.value) { null -> { - if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + if (shouldShowOnboarding()) { + ConnectOnboardingView() + } else if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { Box( Modifier .fillMaxSize() @@ -402,6 +422,7 @@ fun EndPartOfScreen() { ModalManager.end.showInView() } +// Spec: spec/client/navigation.md#DesktopScreen @Composable fun DesktopScreen(userPickerState: MutableStateFlow) { Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index d6f9640cb9..32a5ce1ef1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -13,6 +13,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.* +// Spec: spec/client/navigation.md#AppLock object AppLock { /** * We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 8c27db443b..3c9ece9dce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -78,9 +78,34 @@ object ConnectProgressManager { val connectProgressManager = ConnectProgressManager +object ChannelRelaysModel { + val groupId = mutableStateOf(null) + val groupRelays = mutableStateListOf() + + fun set(groupId: Long, groupRelays: List) { + this.groupId.value = groupId + this.groupRelays.clear() + this.groupRelays.addAll(groupRelays) + } + + fun updateRelay(groupInfo: GroupInfo, relay: GroupRelay) { + if (groupId.value == groupInfo.groupId) { + val i = groupRelays.indexOfFirst { it.groupRelayId == relay.groupRelayId } + if (i >= 0) groupRelays[i] = relay + else groupRelays.add(relay) + } + } + + fun reset() { + groupId.value = null + groupRelays.clear() + } +} + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ +// Spec: spec/state.md#ChatModel @Stable object ChatModel { val controller: ChatController = ChatController @@ -96,11 +121,12 @@ object ChatModel { val dbMigrationInProgress = mutableStateOf(false) val incompleteInitializedDbRemoved = mutableStateOf(false) // map of connections network statuses, key is agent connection id - val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) // current chat val chatId = mutableStateOf(null) + val chatAgentConnId = mutableStateOf(null) + val chatSubStatus = mutableStateOf(null) val openAroundItemId: MutableState = mutableStateOf(null) val chatsContext = ChatsContext(null) val secondaryChatsContext = mutableStateOf(null) @@ -108,9 +134,13 @@ object ChatModel { val chats: State> = chatsContext.chats // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) + val creatingChannelId = mutableStateOf(null) val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) val membersLoaded = mutableStateOf(false) + // Runtime-only relay hostnames for pre-join channel display, not persisted — lost on app restart. + // APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join. + val channelRelayHostnames = mutableStateMapOf>() // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -333,6 +363,7 @@ object ChatModel { } } + // Spec: spec/state.md#ChatsContext class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) /** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on. @@ -682,11 +713,6 @@ object ChatModel { return updatedItem } - // this should not happen, only another member can "remove" user, user can only "leave" (another event). - if (byMember.groupMemberId == groupInfo.membership.groupMemberId) { - Log.d(TAG, "exiting removeMemberItems") - return - } val cInfo = ChatInfo.Group(groupInfo, groupChatScope = null) // TODO [knocking] review if (chatId.value == groupInfo.id) { for (i in 0 until chatItems.value.size) { @@ -849,12 +875,22 @@ object ChatModel { } fun removeChat(rhId: Long?, id: String) { + var groupId: Long? = null val i = getChatIndex(rhId, id) if (i != -1) { val chat = chats.removeAt(i) + groupId = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.groupId removePresetChatTags(chat.chatInfo, chat.chatStats) removeWallpaperFilesFromChat(chat) } + if (chatId.value == id) { + groupMembers.value = emptyList() + groupMembersIndexes.value = emptyMap() + if (groupId != null) { + channelRelayHostnames.remove(groupId) + } + membersLoaded.value = false + } } suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { @@ -863,8 +899,8 @@ object ChatModel { updateGroup(rhId, groupInfo) return false } - // update current chat - return if (chatId.value == groupInfo.id) { + // update current chat or channel being created + return if (chatId.value == groupInfo.id || creatingChannelId.value == groupInfo.id) { if (groupMembers.value.isNotEmpty() && groupMembers.value.firstOrNull()?.groupId != groupInfo.groupId) { // stale data, should be cleared at that point, otherwise, duplicated items will be here which will produce crashes in LazyColumn groupMembers.value = emptyList() @@ -1122,10 +1158,10 @@ object ChatModel { showingInvitation.value = null chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = withId + ModalManager.start.closeModals() + ModalManager.end.closeModals() } } - ModalManager.start.closeModals() - ModalManager.end.closeModals() } } @@ -1147,21 +1183,6 @@ object ChatModel { showingInvitation.value = showingInvitation.value?.copy(connChatUsed = true) } - fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { - val conn = contact.activeConn - if (conn != null) { - networkStatuses[conn.agentConnId] = status - } - } - - fun contactNetworkStatus(contact: Contact): NetworkStatus { - val conn = contact.activeConn - return if (conn != null) - networkStatuses[conn.agentConnId] ?: NetworkStatus.Unknown() - else - NetworkStatus.Unknown() - } - fun addTerminalItem(item: TerminalItem) { val maxItems = if (appPreferences.developerTools.get()) 500 else 200 if (terminalsVisible.isNotEmpty()) { @@ -1237,6 +1258,7 @@ data class User( val autoAcceptMemberContacts: Boolean, val viewPwdHash: UserPwdHash?, val uiThemes: ThemeModeOverrides? = null, + val userChatRelay: Boolean, ): NamedChat, UserLike { override val displayName: String get() = profile.displayName override val fullName: String get() = profile.fullName @@ -1267,6 +1289,7 @@ data class User( autoAcceptMemberContacts = false, viewPwdHash = null, uiThemes = null, + userChatRelay = false, ) } } @@ -1340,6 +1363,7 @@ interface SomeChat { val updatedAt: Instant } +// Spec: spec/state.md#Chat @Serializable @Stable data class Chat( val remoteHostId: Long?, @@ -1381,6 +1405,7 @@ data class Chat( true } + // Spec: spec/state.md#ChatStats @Serializable data class ChatStats( val unreadCount: Int = 0, @@ -1401,6 +1426,7 @@ data class Chat( } } +// Spec: spec/state.md#ChatInfo @Serializable sealed class ChatInfo: SomeChat, NamedChat { @@ -1571,8 +1597,7 @@ sealed class ChatInfo: SomeChat, NamedChat { } } - val userCantSendReason: Pair? - get() { + fun userCantSendReason(allRelaysBroken: Boolean = false): Pair? { when (this) { is Direct -> { if (contact.sendMsgToConnect) return null @@ -1593,11 +1618,18 @@ sealed class ChatInfo: SomeChat, NamedChat { if (groupInfo.membership.memberActive) { when (groupChatScope) { null -> { + if (allRelaysBroken && groupInfo.useRelays) { + return generalGetString(MR.strings.cant_broadcast_message) to null + } if (groupInfo.membership.memberPending) { return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc) } if (groupInfo.membership.memberRole == GroupMemberRole.Observer) { - return generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + return if (groupInfo.useRelays) { + generalGetString(MR.strings.you_are_subscriber) to null + } else { + generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + } } return null } @@ -1637,7 +1669,7 @@ sealed class ChatInfo: SomeChat, NamedChat { } } - val sendMsgEnabled get() = userCantSendReason == null + val sendMsgEnabled get() = userCantSendReason() == null val sndReady: Boolean get() = when(this) { @@ -1656,6 +1688,18 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> null } + val sendAsGroup: Boolean get() { + val g = (this as? Group)?.groupInfo + return if (g != null && g.useRelays && g.membership.memberRole >= GroupMemberRole.Owner) { + when (groupChatScope()) { + null -> true + is GroupChatScope.MemberSupport -> false + } + } else { + false + } + } + fun ntfsEnabled(ci: ChatItem): Boolean = ntfsEnabled(ci.meta.userMention) @@ -1719,32 +1763,11 @@ sealed class ChatInfo: SomeChat, NamedChat { is Group -> groupInfo else -> null } + + val isChannel: Boolean + get() = groupInfo_?.useRelays == true } -@Serializable -sealed class NetworkStatus { - val statusString: String get() = - when (this) { - is Connected -> generalGetString(MR.strings.server_connected) - is Error -> generalGetString(MR.strings.server_error) - else -> generalGetString(MR.strings.server_connecting) - } - val statusExplanation: String get() = - when (this) { - is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError) - else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) - } - - @Serializable @SerialName("unknown") class Unknown: NetworkStatus() - @Serializable @SerialName("connected") class Connected: NetworkStatus() - @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus() -} - -@Serializable -data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus) - @Serializable data class Contact( val contactId: Long, @@ -1916,12 +1939,6 @@ class ContactRef( val id: ChatId get() = "@$contactId" } -@Serializable -class ContactSubStatus( - val contact: Contact, - val contactError: ChatError? = null -) - @Serializable data class Connection( val connId: Long, @@ -1948,6 +1965,12 @@ data class Connection( val connInactive: Boolean get() = quotaErrCounter >= 5 // quotaErrInactiveCount in core + val connFailedErr: String? + get() = when (connStatus) { + is ConnStatus.Failed -> connStatus.connError + else -> null + } + val connPQEnabled: Boolean get() = pqSndEnabled == true && pqRcvEnabled == true @@ -2047,6 +2070,8 @@ sealed class ForwardConfirmation { @Serializable data class GroupInfo ( val groupId: Long, + val useRelays: Boolean, + val relayOwnStatus: RelayStatus? = null, override val localDisplayName: String, val groupProfile: GroupProfile, val businessChat: BusinessChatInfo? = null, @@ -2058,6 +2083,7 @@ data class GroupInfo ( val chatTs: Instant?, val preparedGroup: PreparedGroup?, val uiThemes: ThemeModeOverrides? = null, + val groupSummary: GroupSummary, val membersRequireAttention: Int, val chatTags: List, val chatItemTTL: Long?, @@ -2081,6 +2107,7 @@ data class GroupInfo ( ChatFeature.Calls -> false } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } + val isChannel: Boolean get() = groupProfile.isChannel override val displayName get() = localAlias.ifEmpty { groupProfile.displayName } override val fullName get() = groupProfile.fullName override val shortDescr get() = groupProfile.shortDescr @@ -2099,7 +2126,9 @@ data class GroupInfo ( get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive val chatIconName: ImageResource - get() = when (businessChat?.chatType) { + get() = if (useRelays) { + MR.images.ic_bigtop_updates_circle_filled + } else when (businessChat?.chatType) { null -> MR.images.ic_supervised_user_circle_filled BusinessChatType.Business -> MR.images.ic_work_filled_padded BusinessChatType.Customer -> MR.images.ic_account_circle_filled @@ -2117,12 +2146,14 @@ data class GroupInfo ( GroupFeature.SimplexLinks -> p.simplexLinks.on(membership) GroupFeature.Reports -> p.reports.on GroupFeature.History -> p.history.on + GroupFeature.Support -> p.support.on } } companion object { val sampleData = GroupInfo( groupId = 1, + useRelays = false, localDisplayName = "team", groupProfile = GroupProfile.sampleData, fullGroupPreferences = FullGroupPreferences.sampleData, @@ -2133,6 +2164,7 @@ data class GroupInfo ( chatTs = Clock.System.now(), preparedGroup = null, uiThemes = null, + groupSummary = GroupSummary(currentMembers = 0), membersRequireAttention = 0, chatTags = emptyList(), localAlias = "", @@ -2151,6 +2183,39 @@ data class PreparedGroup ( @Serializable data class GroupRef(val groupId: Long, val localDisplayName: String) +@Serializable(with = GroupTypeSerializer::class) +sealed class GroupType { + @Serializable @SerialName("channel") object Channel: GroupType() + @Serializable @SerialName("unknown") data class Unknown(val type: String): GroupType() +} + +object GroupTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("GroupType", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): GroupType { + return when (val value = decoder.decodeString()) { + "channel" -> GroupType.Channel + else -> GroupType.Unknown(value) + } + } + + override fun serialize(encoder: Encoder, value: GroupType) { + val stringValue = when (value) { + is GroupType.Channel -> "channel" + is GroupType.Unknown -> value.type + } + encoder.encodeString(stringValue) + } +} + +@Serializable +data class PublicGroupProfile( + val groupType: GroupType, + val groupLink: String, + val publicGroupId: String +) + @Serializable data class GroupProfile ( override val displayName: String, @@ -2158,10 +2223,13 @@ data class GroupProfile ( override val shortDescr: String?, val description: String? = null, override val image: String? = null, + val publicGroup: PublicGroupProfile? = null, override val localAlias: String = "", val groupPreferences: GroupPreferences? = null, val memberAdmission: GroupMemberAdmission? = null ): NamedChat { + val isChannel: Boolean get() = publicGroup?.groupType == GroupType.Channel + companion object { val sampleData = GroupProfile( displayName = "team", @@ -2200,10 +2268,80 @@ data class ContactShortLinkData ( ) @Serializable -data class GroupShortLinkData ( - val groupProfile: GroupProfile +data class GroupSummary ( + val currentMembers: Long, + val publicMemberCount: Long? = null ) +@Serializable +data class PublicGroupData ( + val publicMemberCount: Long +) + +@Serializable +data class GroupShortLinkData ( + val groupProfile: GroupProfile, + val publicGroupData: PublicGroupData? = null +) + +@Serializable +enum class RelayStatus { + @SerialName("new") New, + @SerialName("invited") Invited, + @SerialName("accepted") Accepted, + @SerialName("active") Active, + @SerialName("inactive") Inactive, + @SerialName("rejected") Rejected; + + val text: String get() = when (this) { + New -> generalGetString(MR.strings.relay_status_new) + Invited -> generalGetString(MR.strings.relay_status_invited) + Accepted -> generalGetString(MR.strings.relay_status_accepted) + Active -> generalGetString(MR.strings.relay_status_active) + Inactive -> generalGetString(MR.strings.relay_status_inactive) + Rejected -> generalGetString(MR.strings.relay_status_rejected) + } +} + +@Serializable +data class RelayProfile( + val displayName: String, + val fullName: String, + val shortDescr: String? = null, + val image: String? = null +) + +@Serializable +data class UserChatRelay( + val chatRelayId: Long?, + val address: String, + val relayProfile: RelayProfile, + val domains: List, + val preset: Boolean, + val tested: Boolean? = null, + val enabled: Boolean, + val deleted: Boolean, +) { + @Transient + private val createdAt: Date = Date() + val id: String get() = "$address $createdAt" + + val displayName: String get() = relayProfile.displayName + + fun copyWithName(name: String): UserChatRelay = copy(relayProfile = relayProfile.copy(displayName = name)) +} + +@Serializable +data class GroupRelay( + val groupRelayId: Long, + val groupMemberId: Long, + val userChatRelay: UserChatRelay, + val relayStatus: RelayStatus, + val relayLink: String? = null +) { + val id: Long get() = groupRelayId +} + @Serializable data class BusinessChatInfo ( val chatType: BusinessChatType, @@ -2234,7 +2372,8 @@ data class GroupMember ( val memberContactProfileId: Long, var activeConn: Connection? = null, val supportChat: GroupSupportChat? = null, - val memberChatVRange: VersionRange + val memberChatVRange: VersionRange, + val relayLink: String? = null ): NamedChat { val id: String get() = "#$groupId @$groupMemberId" val ready get() = activeConn?.connStatus == ConnStatus.Ready @@ -2339,19 +2478,18 @@ data class GroupMember ( fun canBeRemoved(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft - && userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive + return userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive } fun canChangeRoleTo(groupInfo: GroupInfo): List? = - if (!canBeRemoved(groupInfo) || memberPending) null + if (memberRole == GroupMemberRole.Relay || !canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null else groupInfo.membership.memberRole.let { userRole -> GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Moderator + return memberRole != GroupMemberRole.Relay && memberRole < GroupMemberRole.Moderator && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -2412,7 +2550,8 @@ data class GroupMemberIds( @Serializable enum class GroupMemberRole(val memberRole: String) { - @SerialName("observer") Observer("observer"), // order matters in comparisons + @SerialName("relay") Relay("relay"), // order matters in comparisons + @SerialName("observer") Observer("observer"), @SerialName("author") Author("author"), @SerialName("member") Member("member"), @SerialName("moderator") Moderator("moderator"), @@ -2424,6 +2563,7 @@ enum class GroupMemberRole(val memberRole: String) { } val text: String get() = when (this) { + Relay -> generalGetString(MR.strings.group_member_role_relay) Observer -> generalGetString(MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) @@ -2521,12 +2661,6 @@ class LinkPreview ( } } -@Serializable -class MemberSubError ( - val member: GroupMemberIds, - val memberError: ChatError -) - @Serializable class NoteFolder( val noteFolderId: Long, @@ -2689,25 +2823,27 @@ class PendingContactConnection( } @Serializable -enum class ConnStatus { - @SerialName("new") New, - @SerialName("prepared") Prepared, - @SerialName("joined") Joined, - @SerialName("requested") Requested, - @SerialName("accepted") Accepted, - @SerialName("snd-ready") SndReady, - @SerialName("ready") Ready, - @SerialName("deleted") Deleted; +sealed class ConnStatus { + @Serializable @SerialName("new") object New: ConnStatus() + @Serializable @SerialName("prepared") object Prepared: ConnStatus() + @Serializable @SerialName("joined") object Joined: ConnStatus() + @Serializable @SerialName("requested") object Requested: ConnStatus() + @Serializable @SerialName("accepted") object Accepted: ConnStatus() + @Serializable @SerialName("sndReady") object SndReady: ConnStatus() + @Serializable @SerialName("ready") object Ready: ConnStatus() + @Serializable @SerialName("deleted") object Deleted: ConnStatus() + @Serializable @SerialName("failed") class Failed(val connError: String): ConnStatus() val initiated: Boolean? get() = when (this) { - New -> true - Prepared -> false - Joined -> false - Requested -> true - Accepted -> true - SndReady -> null - Ready -> null - Deleted -> null + is New -> true + is Prepared -> false + is Joined -> false + is Requested -> true + is Accepted -> true + is SndReady -> null + is Ready -> null + is Deleted -> null + is Failed -> null } } @@ -2781,12 +2917,14 @@ data class ChatItem ( val id: Long get() = meta.itemId val timestampText: String get() = meta.timestampText - val text: String get() { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String { val mc = content.msgContent return when { - content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration)) - content.text == "" && file != null -> file.fileName - else -> content.text + content.text(isChannel) == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration)) + content.text(isChannel) == "" && file != null -> file.fileName + else -> content.text(isChannel) } } @@ -2846,30 +2984,29 @@ data class ChatItem ( } val mergeCategory: CIMergeCategory? - get() = when (content) { - is CIContent.RcvChatFeature, - is CIContent.SndChatFeature, - is CIContent.RcvGroupFeature, - is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature - is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { - is RcvGroupEvent.UserRole, - is RcvGroupEvent.UserDeleted, - is RcvGroupEvent.GroupDeleted, - is RcvGroupEvent.MemberCreatedContact, - is RcvGroupEvent.NewMemberPendingReview -> - null - else -> CIMergeCategory.RcvGroupEvent - } - is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { - is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null - else -> CIMergeCategory.SndGroupEvent - } - else -> { - if (meta.itemDeleted == null) { - null - } else { - if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + get() = if (meta.itemDeleted != null) { + if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + } else { + when (content) { + is CIContent.RcvChatFeature, + is CIContent.SndChatFeature, + is CIContent.RcvGroupFeature, + is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature + is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { + is RcvGroupEvent.UserRole, + is RcvGroupEvent.UserDeleted, + is RcvGroupEvent.GroupDeleted, + is RcvGroupEvent.MemberCreatedContact, + is RcvGroupEvent.NewMemberPendingReview -> + null + else -> CIMergeCategory.RcvGroupEvent } + is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null + else -> CIMergeCategory.SndGroupEvent + } + else -> + null } } @@ -2888,6 +3025,8 @@ data class ChatItem ( } else { null } + } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.ChannelRcv) { + null } else { null } @@ -2924,6 +3063,7 @@ data class ChatItem ( is CIContent.RcvCall -> false // notification is shown on CallInvitation instead is CIContent.RcvIntegrityError -> false is CIContent.RcvDecryptionError -> false + is CIContent.RcvMsgErrorContent -> false is CIContent.RcvGroupInvitation -> true is CIContent.SndGroupInvitation -> false is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) { @@ -3229,6 +3369,7 @@ sealed class CIDirection { @Serializable @SerialName("directRcv") class DirectRcv: CIDirection() @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection() @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection() + @Serializable @SerialName("channelRcv") class ChannelRcv: CIDirection() @Serializable @SerialName("localSnd") class LocalSnd: CIDirection() @Serializable @SerialName("localRcv") class LocalRcv: CIDirection() @@ -3237,6 +3378,7 @@ sealed class CIDirection { is DirectRcv -> false is GroupSnd -> true is GroupRcv -> false + is ChannelRcv -> false is LocalSnd -> true is LocalRcv -> false } @@ -3595,7 +3737,8 @@ sealed class CIForwardedFrom { enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), @SerialName("internalMark") cidmInternalMark("internalMark"), - @SerialName("broadcast") cidmBroadcast("broadcast"); + @SerialName("broadcast") cidmBroadcast("broadcast"), + @SerialName("history") cidmHistory("history"); } interface ItemContent { @@ -3616,6 +3759,7 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvMsgError") class RcvMsgErrorContent(val rcvMsgError: RcvMsgError): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null } @@ -3641,7 +3785,9 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("chatBanner") object ChatBanner: CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null } - override val text: String get() = when (this) { + override val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is SndMsgContent -> msgContent.text is RcvMsgContent -> msgContent.text is SndDeleted -> generalGetString(MR.strings.deleted_description) @@ -3650,11 +3796,12 @@ sealed class CIContent: ItemContent { is RcvCall -> status.text(duration) is RcvIntegrityError -> msgError.text is RcvDecryptionError -> msgDecryptError.text + is RcvMsgErrorContent -> rcvMsgError.text is RcvGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text is RcvDirectEventContent -> rcvDirectEvent.text - is RcvGroupEventContent -> rcvGroupEvent.text - is SndGroupEventContent -> sndGroupEvent.text + is RcvGroupEventContent -> rcvGroupEvent.text(isChannel) + is SndGroupEventContent -> sndGroupEvent.text(isChannel) is RcvConnEventContent -> rcvConnEvent.text is SndConnEventContent -> sndConnEvent.text is RcvChatFeature -> featureText(feature, enabled.text, param) @@ -3670,8 +3817,8 @@ sealed class CIContent: ItemContent { is RcvBlocked -> generalGetString(MR.strings.blocked_by_admin_item_description) is SndDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo) is RcvDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo) - is SndGroupE2EEInfo -> e2eeInfoNoPQStr - is RcvGroupE2EEInfo -> e2eeInfoNoPQStr + is SndGroupE2EEInfo -> groupE2EEInfoStr(e2eeInfo) + is RcvGroupE2EEInfo -> groupE2EEInfoStr(e2eeInfo) is ChatBanner -> "" is InvalidJSON -> "invalid data" } @@ -3690,6 +3837,7 @@ sealed class CIContent: ItemContent { is RcvCall -> true is RcvIntegrityError -> true is RcvDecryptionError -> true + is RcvMsgErrorContent -> true is RcvGroupInvitation -> true is RcvModerated -> true is RcvBlocked -> true @@ -3707,6 +3855,9 @@ sealed class CIContent: ItemContent { private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short) + fun groupE2EEInfoStr(e2EEInfo: E2EEInfo): String = + if (e2EEInfo.public == true) generalGetString(MR.strings.e2ee_info_no_e2ee) else e2eeInfoNoPQStr + fun featureText(feature: Feature, enabled: String, param: Int?, role: GroupMemberRole? = null): String = (if (feature.hasParam) { "${feature.text}: ${timeText(param)}" @@ -3777,6 +3928,7 @@ class CIQuote ( is CIDirection.DirectRcv -> null is CIDirection.GroupSnd -> membership?.displayName ?: generalGetString(MR.strings.sender_you_pronoun) is CIDirection.GroupRcv -> chatDir.groupMember.displayName + is CIDirection.ChannelRcv -> null is CIDirection.LocalSnd -> generalGetString(MR.strings.sender_you_pronoun) is CIDirection.LocalRcv -> null null -> null @@ -4178,7 +4330,7 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent() - @Serializable(with = MsgContentSerializer::class) class MCChat(override val text: String, val chatLink: MsgChatLink): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCChat(override val text: String, val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig? = null): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val isVoice: Boolean get() = @@ -4232,7 +4384,7 @@ enum class CIGroupInvitationStatus { } @Serializable -class E2EEInfo (val pqEnabled: Boolean?) {} +class E2EEInfo (val pqEnabled: Boolean?, val public: Boolean? = null) {} object MsgContentSerializer : KSerializer { override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) { @@ -4299,7 +4451,8 @@ object MsgContentSerializer : KSerializer { } "chat" -> { val chatLink = decoder.json.decodeFromString(json["chatLink"].toString()) - MsgContent.MCChat(text, chatLink) + val ownerSig = json["ownerSig"]?.let { decoder.json.decodeFromJsonElement(it) } + MsgContent.MCChat(text, chatLink, ownerSig) } else -> MsgContent.MCUnknown(t, text, json) } @@ -4360,6 +4513,7 @@ object MsgContentSerializer : KSerializer { put("type", "chat") put("text", value.text) put("chatLink", json.encodeToJsonElement(value.chatLink)) + value.ownerSig?.let { put("ownerSig", json.encodeToJsonElement(it)) } } is MsgContent.MCUnknown -> value.json } @@ -4367,16 +4521,51 @@ object MsgContentSerializer : KSerializer { } } -@Serializable -enum class MsgContentTag { - @SerialName("text") Text, - @SerialName("link") Link, - @SerialName("image") Image, - @SerialName("video") Video, - @SerialName("voice") Voice, - @SerialName("file") File, - @SerialName("report") Report, - @SerialName("chat") Chat, +@Serializable(with = MsgContentTagSerializer::class) +sealed class MsgContentTag { + @Serializable @SerialName("text") object Text: MsgContentTag() + @Serializable @SerialName("link") object Link: MsgContentTag() + @Serializable @SerialName("image") object Image: MsgContentTag() + @Serializable @SerialName("video") object Video: MsgContentTag() + @Serializable @SerialName("voice") object Voice: MsgContentTag() + @Serializable @SerialName("file") object File: MsgContentTag() + @Serializable @SerialName("report") object Report: MsgContentTag() + @Serializable @SerialName("chat") object Chat: MsgContentTag() + @Serializable @SerialName("unknown") data class Unknown(val type: String): MsgContentTag() + + val cmdString: String get() = when (this) { + is Text -> "text" + is Link -> "link" + is Image -> "image" + is Video -> "video" + is Voice -> "voice" + is File -> "file" + is Report -> "report" + is Chat -> "chat" + is Unknown -> type + } +} + +object MsgContentTagSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("MsgContentTag", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): MsgContentTag = + when (val s = decoder.decodeString()) { + "text" -> MsgContentTag.Text + "link" -> MsgContentTag.Link + "image" -> MsgContentTag.Image + "video" -> MsgContentTag.Video + "voice" -> MsgContentTag.Voice + "file" -> MsgContentTag.File + "report" -> MsgContentTag.Report + "chat" -> MsgContentTag.Chat + "liveText" -> MsgContentTag.Text + else -> MsgContentTag.Unknown(s) + } + + override fun serialize(encoder: Encoder, value: MsgContentTag) { + encoder.encodeString(value.cmdString) + } } @Serializable @@ -4384,8 +4573,82 @@ sealed class MsgChatLink { @Serializable @SerialName("contact") data class Contact(val connLink: String, val profile: Profile, val business: Boolean) : MsgChatLink() @Serializable @SerialName("invitation") data class Invitation(val invLink: String, val profile: Profile) : MsgChatLink() @Serializable @SerialName("group") data class Group(val connLink: String, val groupProfile: GroupProfile) : MsgChatLink() + + val isPublicGroup: Boolean + get() = (this as? Group)?.groupProfile?.publicGroup != null + + val connLinkStr: String + get() = when (this) { + is Group -> connLink + is Contact -> connLink + is Invitation -> invLink + } + + val image: String? + get() = when (this) { + is Group -> groupProfile.image + is Contact -> profile.image + is Invitation -> profile.image + } + + val displayName: String + get() = when (this) { + is Group -> groupProfile.displayName + is Contact -> profile.displayName + is Invitation -> profile.displayName + } + + val fullName: String + get() = when (this) { + is Group -> groupProfile.fullName + is Contact -> profile.fullName + is Invitation -> profile.fullName + } + + val shortDescription: String? + get() { + val s = when (this) { + is Group -> groupProfile.shortDescr + is Contact -> profile.shortDescr + is Invitation -> profile.shortDescr + } + return s?.trim()?.ifEmpty { null } + } + + val iconRes: ImageResource + get() = when (this) { + is Group -> if (groupProfile.isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled + is Contact -> if (business) MR.images.ic_work_filled_padded else MR.images.ic_account_circle_filled + is Invitation -> MR.images.ic_account_circle_filled + } + + val smallIconRes: ImageResource + get() = when (this) { + is Group -> if (groupProfile.isChannel) MR.images.ic_bigtop_updates else MR.images.ic_group + is Contact -> if (business) MR.images.ic_work else MR.images.ic_person + is Invitation -> MR.images.ic_person + } + + fun infoLine(signed: Boolean): String { + var s = when (this) { + is Group -> if (groupProfile.isChannel) generalGetString(MR.strings.chat_link_channel) else generalGetString(MR.strings.chat_link_group) + is Contact -> if (business) generalGetString(MR.strings.chat_link_business_address) else generalGetString(MR.strings.chat_link_contact_address) + is Invitation -> generalGetString(MR.strings.chat_link_one_time) + } + if (signed) { + s += " " + if (isPublicGroup) generalGetString(MR.strings.chat_link_from_owner) else generalGetString(MR.strings.chat_link_signed) + } + return s + } } +@Serializable +data class LinkOwnerSig( + val ownerId: String? = null, + val chatBinding: String, + val ownerSig: String +) + @Serializable class FormattedText(val text: String, val format: Format? = null) { val linkUri: String? get() = @@ -4407,6 +4670,7 @@ sealed class Format { @Serializable @SerialName("strikeThrough") class StrikeThrough: Format() @Serializable @SerialName("snippet") class Snippet: Format() @Serializable @SerialName("secret") class Secret: Format() + @Serializable @SerialName("small") class Small: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() @@ -4428,6 +4692,7 @@ sealed class Format { is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough) is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace) is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor) + is Small -> SpanStyle(fontSize = MaterialTheme.typography.body2.fontSize, color = MaterialTheme.colors.secondary) is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle is HyperLink -> linkStyle @@ -4598,6 +4863,17 @@ sealed class MsgErrorType() { } } +@Serializable +sealed class RcvMsgError() { + @Serializable @SerialName("dropped") class Dropped(val attempts: Int): RcvMsgError() + @Serializable @SerialName("parseError") class ParseError(val parseError: String): RcvMsgError() + + val text: String get() = when (this) { + is Dropped -> String.format(generalGetString(MR.strings.rcv_msg_error_dropped), attempts) + is ParseError -> String.format(generalGetString(MR.strings.rcv_msg_error_parse), parseError) + } +} + @Serializable sealed class RcvDirectEvent() { @Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent() @@ -4648,7 +4924,9 @@ sealed class RcvGroupEvent() { @Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent() @Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent() - val text: String get() = when (this) { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName) is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected) is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName) @@ -4663,8 +4941,8 @@ sealed class RcvGroupEvent() { is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text) is MemberDeleted -> String.format(generalGetString(MR.strings.rcv_group_event_member_deleted), profile.profileViewName) is UserDeleted -> generalGetString(MR.strings.rcv_group_event_user_deleted) - is GroupDeleted -> generalGetString(MR.strings.rcv_group_event_group_deleted) - is GroupUpdated -> generalGetString(MR.strings.rcv_group_event_updated_group_profile) + is GroupDeleted -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_channel_deleted else MR.strings.rcv_group_event_group_deleted) + is GroupUpdated -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_updated_channel_profile else MR.strings.rcv_group_event_updated_group_profile) is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link) is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact) is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile) @@ -4696,7 +4974,9 @@ sealed class SndGroupEvent() { @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() @Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent() - val text: String get() = when (this) { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text) is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text) is MemberBlocked -> if (blocked) { @@ -4706,7 +4986,7 @@ sealed class SndGroupEvent() { } is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName) is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left) - is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated) + is GroupUpdated -> generalGetString(if (isChannel) MR.strings.snd_channel_event_channel_profile_updated else MR.strings.snd_group_event_group_profile_updated) is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted) is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt index 6ef56a9124..60f5c9e2ca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -20,6 +20,7 @@ sealed class WriteFileResult { } * */ +// Spec: spec/services/files.md#writeCryptoFile fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized") val buffer = ByteBuffer.allocateDirect(data.size) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 6a80e50285..a31dc145a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -27,12 +28,14 @@ import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.defaultConditionsLink import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration @@ -46,12 +49,18 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.* import kotlinx.serialization.builtins.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* +import java.util.concurrent.atomic.AtomicBoolean +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle import java.util.Date typealias ChatCtrl = Long @@ -82,6 +91,14 @@ enum class SimplexLinkMode { } } +enum class CloseBehavior { + Ask, Quit, MinimizeToTray; + companion object { val default = Ask } +} + +class HintPref(val reset: () -> Unit, val isUnchanged: () -> Boolean) + +// Spec: spec/state.md#AppPreferences class AppPreferences { // deprecated, remove in 2024 private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true) @@ -89,6 +106,7 @@ class AppPreferences { SHARED_PREFS_NOTIFICATIONS_MODE, if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default ) { NotificationsMode.values().firstOrNull { it.name == this } } + val closeBehavior: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default) val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name) val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true) val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) @@ -203,7 +221,7 @@ class AppPreferences { val shouldImportAppSettings = mkBoolPreference(SHARED_PREFS_SHOULD_IMPORT_APP_SETTINGS, false) val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM_THEME_NAME) - val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.themeName) + val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.DARK.themeName) val currentThemeIds = mkMapPreference(SHARED_PREFS_CURRENT_THEME_IDs, mapOf(), encode = { json.encodeToString(MapSerializer(String.serializer(), String.serializer()), it) }, decode = { @@ -247,16 +265,23 @@ class AppPreferences { val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true) val chatBottomBar = mkBoolPreference(SHARED_PREFS_CHAT_BOTTOM_BAR, true) - val hintPreferences: List, Boolean>> = listOf( - laNoticeShown to false, - oneHandUICardShown to false, - addressCreationCardShown to false, - liveMessageAlertShown to false, - showHiddenProfilesNotice to true, - showMuteProfileAlert to true, - showReportsInSupportChatAlert to true, - showDeleteConversationNotice to true, - showDeleteContactNotice to true, + val hintPreferences: List = listOf( + hintPref(laNoticeShown, false), + hintPref(oneHandUICardShown, false), + hintPref(addressCreationCardShown, false), + hintPref(liveMessageAlertShown, false), + hintPref(showHiddenProfilesNotice, true), + hintPref(showMuteProfileAlert, true), + hintPref(showReportsInSupportChatAlert, true), + hintPref(showDeleteConversationNotice, true), + hintPref(showDeleteContactNotice, true), + hintPref(privacyLinkPreviewsShowAlert, true), + hintPref(closeBehavior, CloseBehavior.default), + ) + + private fun hintPref(pref: SharedPreference, default: T) = HintPref( + reset = { pref.set(default) }, + isUnchanged = { pref.state.value == default }, ) private fun mkIntPreference(prefName: String, default: Int) = @@ -468,6 +493,7 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" + private const val SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR = "DesktopCloseBehavior" private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" @@ -483,6 +509,7 @@ private const val MESSAGE_TIMEOUT: Int = 300_000_000 object ChatController { private var chatCtrl: ChatCtrl? = -1 + // Spec: spec/state.md#appPrefs val appPrefs: AppPreferences by lazy { AppPreferences() } val messagesChannel: Channel = Channel() @@ -646,6 +673,7 @@ object ChatController { chatModel.updateChatTags(rhId) } + // Spec: spec/api.md#startReceiver private fun startReceiver() { Log.d(TAG, "ChatController startReceiver") if (receiverJob != null || chatCtrl == null) return @@ -711,14 +739,20 @@ object ChatController { val alert = if (r is API.Error) retryableNetworkErrorAlert(r.err) else null if ((inProgress == null || inProgress.value) && alert != null) { return suspendCancellableCoroutine { cont -> + val resumed = AtomicBoolean(false) + fun safeResume(result: Result) { + if (resumed.compareAndSet(false, true)) { + cont.resumeWith(result) + } + } showRetryAlert( alert, onCancel = { - cont.resumeWith(Result.success(null)) + safeResume(Result.success(null)) }, onRetry = { withLongRunningApi { - cont.resumeWith( + safeResume( runCatching { coroutineScope { sendCmdWithRetry(rhId, cmd, inProgress = inProgress, retryNum = retryNum + 1) @@ -730,7 +764,7 @@ object ChatController { ) cont.invokeOnCancellation { - cont.resumeWith(Result.success(null)) + safeResume(Result.success(null)) } } } else { @@ -789,6 +823,7 @@ object ChatController { return null } + // Spec: spec/api.md#sendCmd suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, retryNum: Int = 0, log: Boolean = true): API { val ctrl = otherCtrl ?: chatCtrl ?: throw Exception("Controller is not initialized") @@ -813,6 +848,7 @@ object ChatController { } } + // Spec: spec/api.md#recvMsg fun recvMsg(ctrl: ChatCtrl): API? { val rStr = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) return if (rStr == "") { @@ -1028,6 +1064,14 @@ object ChatController { return null } + suspend fun apiGetChatContentTypes(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): List? { + val r = sendCmd(rh, CC.ApiGetChatContentTypes(type, id, scope)) + if (r is API.Result && r.res is CR.ChatContentTypes) return r.res.contentTypes.filter { it !is MsgContentTag.Unknown } + Log.e(TAG, "apiGetChatContentTypes bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_loading_details), "${r.responseType}: ${r.details}") + return null + } + suspend fun apiCreateChatTag(rh: Long?, tag: ChatTagData): List? { val r = sendCmd(rh, CC.ApiCreateChatTag(tag)) if (r is API.Result && r.res is CR.ChatTags) return r.res.userTags @@ -1050,8 +1094,8 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, scope, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) } @@ -1109,11 +1153,18 @@ object ChatController { return null } - suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { - val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl) + suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, sendAsGroup: Boolean = false, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { + val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl) return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } + suspend fun apiShareChatMsgContent(rh: Long?, shareChatType: ChatType, shareChatId: Long, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, sendAsGroup: Boolean): MsgContent? { + val r = sendCmd(rh, CC.ApiShareChatMsgContent(shareChatType, shareChatId, toChatType, toChatId, toScope, sendAsGroup)) + if (r is API.Result && r.res is CR.ChatMsgContent) return r.res.msgContent + apiErrorAlert("apiShareChatMsgContent", generalGetString(MR.strings.error_sharing_channel), r) + return null + } + suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, chatItemIds: List): CR.ForwardPlan? { val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, fromScope, chatItemIds)) if (r is API.Result && r.res is CR.ForwardPlan) return r.res @@ -1162,14 +1213,14 @@ object ChatController { suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List, mode: CIDeleteMode): List? { val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode)) if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions - Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") + apiErrorAlert("apiDeleteChatItems", generalGetString(MR.strings.error_deleting_message), r) return null } suspend fun apiDeleteMemberChatItems(rh: Long?, groupId: Long, itemIds: List): List? { val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, itemIds)) if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions - Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") + apiErrorAlert("apiDeleteMemberChatItems", generalGetString(MR.strings.error_deleting_message), r) return null } @@ -1195,6 +1246,14 @@ object ChatController { throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}") } + suspend fun testChatRelay(rh: Long?, address: String): Pair { + val userId = currentUserId("testChatRelay") + val r = sendCmd(rh, CC.APITestChatRelay(userId, address)) + if (r is API.Result && r.res is CR.ChatRelayTestResult) return r.res.relayProfile to r.res.relayTestFailure + Log.e(TAG, "testChatRelay bad response: ${r.responseType} ${r.details}") + throw Exception("testChatRelay bad response: ${r.responseType} ${r.details}") + } + suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiGetServerOperators()) if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions @@ -1229,10 +1288,10 @@ object ChatController { return false } - suspend fun validateServers(rh: Long?, userServers: List): List? { + suspend fun validateServers(rh: Long?, userServers: List): Pair, List>? { val userId = currentUserId("validateServers") val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers)) - if (r is API.Result && r.res is CR.UserServersValidation) return r.res.serverErrors + if (r is API.Result && r.res is CR.UserServersValidation) return Pair(r.res.serverErrors, r.res.serverWarnings) Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") return null } @@ -1322,6 +1381,12 @@ object ChatController { suspend fun apiSetMemberSettings(rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = sendCommandOkResp(rh, CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiGetUpdatedGroupLinkData(rh: Long?, groupId: Long): GroupInfo? { + val r = sendCmd(rh, CC.ApiGetUpdatedGroupLinkData(groupId)) + if (r is API.Result && r.res is CR.CRGroupInfo) return r.res.groupInfo + return null + } + suspend fun apiContactInfo(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIContactInfo(contactId)) if (r is API.Result && r.res is CR.ContactInfo) return r.res.connectionStats_ to r.res.customUserProfile @@ -1450,9 +1515,9 @@ object ChatController { return null } - suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState): Pair? { + suspend fun apiConnectPlan(rh: Long?, connLink: String, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState): Pair? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } - val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink), inProgress = inProgress) + val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, linkOwnerSig), inProgress = inProgress) if (r is API.Result && r.res is CR.CRConnectionPlan) return r.res.connLink to r.res.connectionPlan if (inProgress.value && r != null) apiConnectResponseAlert(r) return null @@ -1522,6 +1587,23 @@ object ChatController { } } + fun connErrorText(e: ChatError): String = when { + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.InvalidConnReq -> + generalGetString(MR.strings.invalid_connection_link) + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.UnsupportedConnReq -> + generalGetString(MR.strings.unsupported_connection_link) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH -> + generalGetString(MR.strings.connection_error_auth) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.BLOCKED -> + "${generalGetString(MR.strings.connection_error_blocked)}: ${e.agentError.smpErr.blockInfo.reason.text}" + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.QUOTA -> + generalGetString(MR.strings.connection_reached_limit_of_undelivered_messages) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.BROKER -> + generalGetString(MR.strings.network_error) + else -> + "${generalGetString(MR.strings.error_prefix)}: ${e.string}" + } + suspend fun apiPrepareContact(rh: Long?, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData): Chat? { val userId = try { currentUserId("apiPrepareContact") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.APIPrepareContact(userId, connLink, contactShortLinkData)) @@ -1531,9 +1613,9 @@ object ChatController { return null } - suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData): Chat? { + suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, directLink: Boolean, groupShortLinkData: GroupShortLinkData): Chat? { val userId = try { currentUserId("apiPrepareGroup") } catch (e: Exception) { return null } - val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, groupShortLinkData)) + val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, directLink, groupShortLinkData)) if (r is API.Result && r.res is CR.NewPreparedChat) return r.res.chat Log.e(TAG, "apiPrepareGroup bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_group), "${r.responseType}: ${r.details}") @@ -1566,9 +1648,9 @@ object ChatController { return null } - suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): GroupInfo? { + suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): Pair>? { val r = sendCmdWithRetry(rh, CC.APIConnectPreparedGroup(groupId, incognito, msg)) - if (r is API.Result && r.res is CR.StartedConnectionToGroup) return r.res.groupInfo + if (r is API.Result && r.res is CR.StartedConnectionToGroup) return Pair(r.res.groupInfo, r.res.relayResults) if (r != null) { Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}") apiConnectResponseAlert(r) @@ -1726,6 +1808,11 @@ object ChatController { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } val r = sendCmdWithRetry(rh, CC.ApiCreateMyAddress(userId)) if (r is API.Result && r.res is CR.UserContactLinkCreated) return r.res.connLinkContact + if (r is API.Error && r.err is ChatError.ChatErrorAgent && r.err.agentError is AgentErrorType.NOTICE) { + val e = r.err.agentError + showClientNoticeAlert(e.server, e.preset, e.expiresAt) + return null + } if (r == null) return null if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) @@ -1859,13 +1946,6 @@ object ChatController { return r.result is CR.CmdOk } - suspend fun apiGetNetworkStatuses(rh: Long?): List? { - val r = sendCmd(rh, CC.ApiGetNetworkStatuses()) - if (r is API.Result && r.res is CR.NetworkStatuses) return r.res.networkStatuses - Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") - return null - } - suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { val r = sendCmd(rh, CC.ApiChatRead(type, id, scope = null)) if (r.result is CR.CmdOk) return true @@ -2078,6 +2158,39 @@ object ChatController { return null } + sealed class PublicGroupCreationResult { + data class Created(val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): PublicGroupCreationResult() + data class CreationFailed(val addRelayResults: List): PublicGroupCreationResult() + } + + suspend fun apiNewPublicGroup(rh: Long?, incognito: Boolean, relayIds: List, groupProfile: GroupProfile): PublicGroupCreationResult? { + val userId = kotlin.runCatching { currentUserId("apiNewPublicGroup") }.getOrElse { return null } + val r = sendCmdWithRetry(rh, CC.ApiNewPublicGroup(userId, incognito, relayIds, groupProfile)) + if (r is API.Result && r.res is CR.PublicGroupCreated) return PublicGroupCreationResult.Created(r.res.groupInfo, r.res.groupLink, r.res.groupRelays) + if (r is API.Result && r.res is CR.PublicGroupCreationFailed) return PublicGroupCreationResult.CreationFailed(r.res.addRelayResults) + if (r != null) throw Exception("${r.responseType}: ${r.details}") + return null + } + + suspend fun apiGetGroupRelays(groupId: Long): List { + val r = sendCmd(null, CC.ApiGetGroupRelays(groupId)) + if (r is API.Result && r.res is CR.GroupRelays) return r.res.groupRelays + return emptyList() + } + + sealed class AddGroupRelaysResult { + data class Added(val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): AddGroupRelaysResult() + data class AddFailed(val addRelayResults: List): AddGroupRelaysResult() + } + + suspend fun apiAddGroupRelays(groupId: Long, relayIds: List): AddGroupRelaysResult? { + val r = sendCmdWithRetry(null, CC.ApiAddGroupRelays(groupId, relayIds)) + if (r is API.Result && r.res is CR.GroupRelaysAdded) return AddGroupRelaysResult.Added(r.res.groupInfo, r.res.groupLink, r.res.groupRelays) + if (r is API.Result && r.res is CR.GroupRelaysAddFailed) return AddGroupRelaysResult.AddFailed(r.res.addRelayResults) + if (r != null) throw Exception("${r.responseType}: ${r.details}") + return null + } + suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member @@ -2129,7 +2242,7 @@ object ChatController { return null } - suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): Pair>? { + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean): Pair>? { val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members if (!(networkErrorAlert(r))) { @@ -2170,18 +2283,19 @@ object ChatController { return emptyList() } - suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? { + suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile, isChannel: Boolean): GroupInfo? { val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile)) + val errorTitle = if (isChannel) MR.strings.error_saving_channel_profile else MR.strings.error_saving_group_profile return when { r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup r is API.Error -> { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.err") + AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "$r.err") null } else -> { Log.e(TAG, "apiUpdateGroup bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.error_saving_group_profile), + generalGetString(errorTitle), "${r.responseType}: ${r.details}" ) null @@ -2192,6 +2306,11 @@ object ChatController { suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? { val r = sendCmdWithRetry(rh, CC.APICreateGroupLink(groupId, memberRole)) if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.groupLink + if (r is API.Error && r.err is ChatError.ChatErrorAgent && r.err.agentError is AgentErrorType.NOTICE) { + val e = r.err.agentError + showClientNoticeAlert(e.server, e.preset, e.expiresAt) + return null + } if (r == null) return null if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) @@ -2540,6 +2659,7 @@ object ChatController { AlertManager.shared.showAlertMsg(title, errMsg) } + // Spec: spec/api.md#processReceivedMsg private suspend fun processReceivedMsg(msg: API) { lastMsgReceivedTimestamp = System.currentTimeMillis() val rhId = msg.rhId @@ -2563,12 +2683,15 @@ object ChatController { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") chatModel.chatsContext.removeChat(rhId, conn.id) } + if (r.contact.id == chatModel.chatId.value && conn != null) { + chatModel.chatAgentConnId.value = conn.agentConnId + chatModel.chatSubStatus.value = SubscriptionStatus.Active + } } } if (r.contact.directOrUsed) { ntfManager.notifyContactConnected(r.user, r.contact) } - chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { @@ -2593,7 +2716,6 @@ object ChatController { } } } - chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) } is CR.ReceivedContactRequest -> { val contactRequest = r.contactRequest @@ -2603,7 +2725,7 @@ object ChatController { if (chatModel.chatsContext.hasChat(rhId, r.chat_.id)) { chatModel.chatsContext.updateChatInfo(rhId, r.chat_.chatInfo) } else { - chatModel.chatsContext.addChat(r.chat_) + chatModel.chatsContext.addChat(r.chat_.copy(remoteHostId = rhId)) } } else { val cInfo = ChatInfo.ContactRequest(contactRequest) @@ -2635,45 +2757,14 @@ object ChatController { } } } - is CR.ContactsMerged -> { - if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.mergedContact.id)) { - if (chatModel.chatId.value == r.mergedContact.id) { - chatModel.chatId.value = r.intoContact.id - } + is CR.SubscriptionStatusEvt -> { + val chatAgentConnId = chatModel.chatAgentConnId.value + if (chatAgentConnId != null && r.connections.contains(chatAgentConnId)) { withContext(Dispatchers.Main) { - chatModel.chatsContext.removeChat(rhId, r.mergedContact.id) + chatModel.chatSubStatus.value = r.subscriptionStatus } } } - // ContactsSubscribed, ContactsDisconnected and ContactSubSummary are only used in CLI, - // They have to be used here for remote desktop to process these status updates. - is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) - is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) - is CR.ContactSubSummary -> { - for (sub in r.contactSubscriptions) { - if (active(r.user)) { - withContext(Dispatchers.Main) { - chatModel.chatsContext.updateContact(rhId, sub.contact) - } - } - val err = sub.contactError - if (err == null) { - chatModel.setContactNetworkStatus(sub.contact, NetworkStatus.Connected()) - } else { - processContactSubError(sub.contact, sub.contactError) - } - } - } - is CR.NetworkStatusResp -> { - for (cId in r.connections) { - chatModel.networkStatuses[cId] = r.networkStatus - } - } - is CR.NetworkStatuses -> { - for (s in r.networkStatuses) { - chatModel.networkStatuses[s.agentConnId] = s.networkStatus - } - } is CR.ChatInfoUpdated -> if (active(r.user)) { withContext(Dispatchers.Main) { @@ -2816,6 +2907,7 @@ object ChatController { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.hostMember) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") @@ -2930,6 +3022,7 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.hostMember) } if ( chatModel.chatId.value == r.groupInfo.id @@ -2958,9 +3051,6 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } - if (r.memberContact != null) { - chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected()) - } } is CR.GroupUpdated -> if (active(r.user)) { @@ -2968,6 +3058,23 @@ object ChatController { chatModel.chatsContext.updateGroup(rhId, r.toGroup) } } + is CR.GroupLinkDataUpdated -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + val relaysModel = ChannelRelaysModel + if (relaysModel.groupId.value == r.groupInfo.groupId) { + relaysModel.set(r.groupInfo.groupId, r.groupRelays) + } + } + } + is CR.GroupRelayUpdated -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + ChannelRelaysModel.updateRelay(r.groupInfo, r.groupRelay) + } + } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { withContext(Dispatchers.Main) { @@ -3273,12 +3380,6 @@ object ChatController { m.users.clear() m.users.addAll(users) getUserChatData(null) - val statuses = apiGetNetworkStatuses(null) - if (statuses != null) { - chatModel.networkStatuses.clear() - val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() - chatModel.networkStatuses.putAll(ss) - } } private fun activeUser(rhId: Long?, user: UserLike): Boolean = @@ -3391,27 +3492,6 @@ object ChatController { } } - private fun updateContactsStatus(contactRefs: List, status: NetworkStatus) { - for (c in contactRefs) { - chatModel.networkStatuses[c.agentConnId] = status - } - } - - private fun processContactSubError(contact: Contact, chatError: ChatError) { - val e = chatError - val err: String = - if (e is ChatError.ChatErrorAgent) { - val a = e.agentError - when { - a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network" - a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted" - else -> e.string - } - } - else e.string - chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) - } - suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded { // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null @@ -3438,12 +3518,6 @@ object ChatController { chatModel.secondaryChatsContext.value?.popChatCollector?.clear() } } - val statuses = apiGetNetworkStatuses(rhId) - if (statuses != null) { - chatModel.networkStatuses.clear() - val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() - chatModel.networkStatuses.putAll(ss) - } getUserChatData(rhId) } @@ -3565,6 +3639,7 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { } // ChatCommand +// Spec: spec/api.md#CC sealed class CC { class Console(val cmd: String): CC() class ShowActiveUser: CC() @@ -3596,8 +3671,9 @@ sealed class CC { class ApiGetChatTags(val userId: Long): CC() class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() + class ApiGetChatContentTypes(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3613,8 +3689,12 @@ sealed class CC { class ApiChatItemReaction(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List): CC() - class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() + class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val sendAsGroup: Boolean, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() + class ApiShareChatMsgContent(val shareChatType: ChatType, val shareChatId: Long, val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val sendAsGroup: Boolean): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() + class ApiNewPublicGroup(val userId: Long, val incognito: Boolean, val relayIds: List, val groupProfile: GroupProfile): CC() + class ApiGetGroupRelays(val groupId: Long): CC() + class ApiAddGroupRelays(val groupId: Long, val relayIds: List): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() @@ -3634,6 +3714,7 @@ sealed class CC { class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() class APIAcceptMemberContact(val contactId: Long): CC() class APITestProtoServer(val userId: Long, val server: String): CC() + class APITestChatRelay(val userId: Long, val address: String): CC() class ApiGetServerOperators(): CC() class ApiSetServerOperators(val operators: List): CC() class ApiGetUserServers(val userId: Long): CC() @@ -3652,6 +3733,7 @@ sealed class CC { class ReconnectAllServers: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC() + class ApiGetUpdatedGroupLinkData(val groupId: Long): CC() class APIContactInfo(val contactId: Long): CC() class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class APIContactQueueInfo(val contactId: Long): CC() @@ -3669,9 +3751,9 @@ sealed class CC { class APIAddContact(val userId: Long, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() - class APIConnectPlan(val userId: Long, val connLink: String): CC() + class APIConnectPlan(val userId: Long, val connLink: String, val linkOwnerSig: LinkOwnerSig? = null): CC() class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData): CC() - class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val groupShortLinkData: GroupShortLinkData): CC() + class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData): CC() class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC() class APIChangePreparedGroupUser(val groupId: Long, val newUserId: Long): CC() class APIConnectPreparedContact(val contactId: Long, val incognito: Boolean, val msg: MsgContent?): CC() @@ -3702,7 +3784,6 @@ sealed class CC { class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() class ApiEndCall(val contact: Contact): CC() class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() - class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() @@ -3777,15 +3858,16 @@ sealed class CC { val tag = if (contentTag == null) { "" } else { - " content=${contentTag.name.lowercase()}" + " content=${contentTag.cmdString}" } "/_get chat ${chatRef(type, id, scope)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") } + is ApiGetChatContentTypes -> "/_get content types ${chatRef(type, id, scope)}" is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id, scope)} $itemId" is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id, scope)} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" @@ -3806,12 +3888,18 @@ sealed class CC { is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { val ttlStr = if (ttl != null) "$ttl" else "default" - "/_forward ${chatRef(toChatType, toChatId, toScope)} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" + "/_forward ${chatRef(toChatType, toChatId, toScope)}${if (sendAsGroup) " as_group=on" else ""} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" + } + is ApiShareChatMsgContent -> { + "/_share chat content ${chatRef(shareChatType, shareChatId, null)} ${chatRef(toChatType, toChatId, toScope)}${if (sendAsGroup) "(as_group=on)" else ""}" } is ApiPlanForwardChatItems -> { "/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}" } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" + is ApiNewPublicGroup -> "/_public group $userId incognito=${onOff(incognito)} ${relayIds.joinToString(",")} ${json.encodeToString(groupProfile)}" + is ApiGetGroupRelays -> "/_get relays #$groupId" + is ApiAddGroupRelays -> "/_add relays #$groupId ${relayIds.joinToString(",")}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" @@ -3831,6 +3919,7 @@ sealed class CC { is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" is APIAcceptMemberContact -> "/_accept member contact @$contactId" is APITestProtoServer -> "/_server test $userId $server" + is APITestChatRelay -> "/_relay test $userId $address" is ApiGetServerOperators -> "/_operators" is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}" is ApiGetUserServers -> "/_servers $userId" @@ -3849,6 +3938,7 @@ sealed class CC { is ReconnectAllServers -> "/reconnect" is APISetChatSettings -> "/_settings ${chatRef(type, id, scope = null)} ${json.encodeToString(chatSettings)}" is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" + is ApiGetUpdatedGroupLinkData -> "/_get group link data #$groupId" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is APIContactQueueInfo -> "/_queue info @$contactId" @@ -3866,9 +3956,12 @@ sealed class CC { is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is ApiChangeConnectionUser -> "/_set conn user :$connId $userId" - is APIConnectPlan -> "/_connect plan $userId $connLink" + is APIConnectPlan -> { + val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else "" + "/_connect plan $userId $connLink$sigStr" + } is APIPrepareContact -> "/_prepare contact $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(contactShortLinkData)}" - is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(groupShortLinkData)}" + is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} direct=${onOff(directLink)} ${json.encodeToString(groupShortLinkData)}" is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId" is APIChangePreparedGroupUser -> "/_set group user #$groupId $newUserId" is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)}${maybeContent(msg)}" @@ -3901,7 +3994,6 @@ sealed class CC { is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" - is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id, scope)}" is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id, scope)} ${itemIds.joinToString(",")}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id, scope = null)} ${onOff(unreadChat)}" @@ -3968,6 +4060,7 @@ sealed class CC { is ApiGetChatTags -> "apiGetChatTags" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" + is ApiGetChatContentTypes -> "apiGetChatContentTypes" is ApiGetChatItemInfo -> "apiGetChatItemInfo" is ApiSendMessages -> "apiSendMessages" is ApiCreateChatTag -> "apiCreateChatTag" @@ -3985,8 +4078,12 @@ sealed class CC { is ApiChatItemReaction -> "apiChatItemReaction" is ApiGetReactionMembers -> "apiGetReactionMembers" is ApiForwardChatItems -> "apiForwardChatItems" + is ApiShareChatMsgContent -> "apiShareChatMsgContent" is ApiPlanForwardChatItems -> "apiPlanForwardChatItems" is ApiNewGroup -> "apiNewGroup" + is ApiNewPublicGroup -> "apiNewPublicGroup" + is ApiGetGroupRelays -> "apiGetGroupRelays" + is ApiAddGroupRelays -> "apiAddGroupRelays" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" is ApiAcceptMember -> "apiAcceptMember" @@ -4006,6 +4103,7 @@ sealed class CC { is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" is APIAcceptMemberContact -> "apiAcceptMemberContact" is APITestProtoServer -> "testProtoServer" + is APITestChatRelay -> "apiTestChatRelay" is ApiGetServerOperators -> "apiGetServerOperators" is ApiSetServerOperators -> "apiSetServerOperators" is ApiGetUserServers -> "apiGetUserServers" @@ -4024,6 +4122,7 @@ sealed class CC { is ReconnectAllServers -> "reconnectAllServers" is APISetChatSettings -> "apiSetChatSettings" is ApiSetMemberSettings -> "apiSetMemberSettings" + is ApiGetUpdatedGroupLinkData -> "apiGetUpdatedGroupLinkData" is APIContactInfo -> "apiContactInfo" is APIGroupMemberInfo -> "apiGroupMemberInfo" is APIContactQueueInfo -> "apiContactQueueInfo" @@ -4076,7 +4175,6 @@ sealed class CC { is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" is ApiEndCall -> "apiEndCall" is ApiCallStatus -> "apiCallStatus" - is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiChatRead -> "apiChatRead" is ApiChatItemsRead -> "apiChatItemsRead" is ApiChatUnread -> "apiChatUnread" @@ -4159,7 +4257,8 @@ fun onOff(b: Boolean): String = if (b) "on" else "off" @Serializable data class NewUser( val profile: Profile?, - val pastTimestamp: Boolean + val pastTimestamp: Boolean, + val userChatRelay: Boolean = false ) sealed class ChatPagination { @@ -4196,9 +4295,11 @@ class UpdatedMessage(val msgContent: MsgContent, val mentions: Map @Serializable class ChatTagData(val emoji: String?, val text: String) +// Spec: spec/api.md#ArchiveConfig @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) +// Spec: spec/database.md#DBEncryptionConfig @Serializable class DBEncryptionConfig(val currentKey: String, val newKey: String) @@ -4410,7 +4511,8 @@ data class ServerRoles( data class UserOperatorServers( val operator: ServerOperator?, val smpServers: List, - val xftpServers: List + val xftpServers: List, + val chatRelays: List = emptyList() ) { val id: String get() = operator?.operatorId?.toString() ?: "nil operator" @@ -4449,19 +4551,22 @@ sealed class UserServersError { @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + @Serializable @SerialName("duplicateChatRelayAddress") data class DuplicateChatRelayAddress(val duplicateChatRelay: String, val duplicateAddress: String): UserServersError() val globalError: String? get() = when (this.protocol_) { ServerProtocol.SMP -> globalSMPError ServerProtocol.XFTP -> globalXFTPError + null -> null } - private val protocol_: ServerProtocol + private val protocol_: ServerProtocol? get() = when (this) { is NoServers -> this.protocol is StorageMissing -> this.protocol is ProxyMissing -> this.protocol is DuplicateServer -> this.protocol + is DuplicateChatRelayAddress -> null } val globalSMPError: String? @@ -4505,6 +4610,40 @@ sealed class UserServersError { } } +@Serializable +sealed class UserServersWarning { + @Serializable @SerialName("noChatRelays") data class NoChatRelays(val user: UserRef? = null): UserServersWarning() + + val globalWarning: String? + get() = when (this) { + is NoChatRelays -> { + val text = generalGetString(MR.strings.no_chat_relays_enabled) + if (user != null) { + String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text + } else text + } + } +} + +@Serializable +data class RelayConnectionResult( + val relayMember: GroupMember, + val relayError: ChatError? = null +) + +@Serializable +data class AddRelayResult( + val relay: UserChatRelay, + val relayError: ChatError? = null +) + +@Serializable +data class GroupShortLinkInfo( + val direct: Boolean, + val groupRelays: List, + val publicGroupId: String? = null +) + @Serializable data class UserServer( val remoteHostId: Long?, @@ -4633,6 +4772,44 @@ data class ProtocolTestFailure( } } +@Serializable +enum class RelayTestStep { + @SerialName("getLink") GetLink, + @SerialName("decodeLink") DecodeLink, + @SerialName("connect") Connect, + @SerialName("waitResponse") WaitResponse, + @SerialName("verify") Verify; + + val text: String get() = when (this) { + GetLink -> generalGetString(MR.strings.relay_test_step_get_link) + DecodeLink -> generalGetString(MR.strings.relay_test_step_decode_link) + Connect -> generalGetString(MR.strings.relay_test_step_connect) + WaitResponse -> generalGetString(MR.strings.relay_test_step_wait_response) + Verify -> generalGetString(MR.strings.relay_test_step_verify) + } +} + +@Serializable +data class RelayTestFailure( + val rtfStep: RelayTestStep, + val rtfError: ChatError +) { + val localizedDescription: String get() { + val err = String.format(generalGetString(MR.strings.error_relay_test_failed_at_step), rtfStep.text) + return when { + rtfError is ChatError.ChatErrorAgent && + rtfError.agentError is AgentErrorType.SMP && rtfError.agentError.smpErr is SMPErrorType.AUTH -> + err + " " + generalGetString(MR.strings.error_relay_test_server_auth) + rtfError is ChatError.ChatErrorAgent && + rtfError.agentError is AgentErrorType.BROKER && rtfError.agentError.brokerErr is BrokerErrorType.NETWORK && + rtfError.agentError.brokerErr.networkError is NetworkError.UnknownCAError -> + err + " " + generalGetString(MR.strings.error_smp_test_certificate) + else -> + err + " " + String.format(generalGetString(MR.strings.error_with_info), rtfError.string) + } + } +} + @Serializable data class ServerAddress( val serverProtocol: ServerProtocol, @@ -5547,7 +5724,8 @@ enum class GroupFeature: Feature { @SerialName("files") Files, @SerialName("simplexLinks") SimplexLinks, @SerialName("reports") Reports, - @SerialName("history") History; + @SerialName("history") History, + @SerialName("support") Support; override val hasParam: Boolean get() = when(this) { TimedMessages -> true @@ -5565,10 +5743,12 @@ enum class GroupFeature: Feature { SimplexLinks -> true Reports -> false History -> false + Support -> false } - override val text: String - get() = when(this) { + override val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when(this) { TimedMessages -> generalGetString(MR.strings.timed_messages) DirectMessages -> generalGetString(MR.strings.direct_messages) FullDelete -> generalGetString(MR.strings.full_deletion) @@ -5576,8 +5756,9 @@ enum class GroupFeature: Feature { Voice -> generalGetString(MR.strings.voice_messages) Files -> generalGetString(MR.strings.files_and_media) SimplexLinks -> generalGetString(MR.strings.simplex_links) - Reports -> generalGetString(MR.strings.group_reports_member_reports) + Reports -> generalGetString(if (isChannel) MR.strings.group_reports_subscriber_reports else MR.strings.group_reports_member_reports) History -> generalGetString(MR.strings.recent_history) + Support -> generalGetString(MR.strings.chat_with_admins) } val icon: Painter @@ -5591,6 +5772,7 @@ enum class GroupFeature: Feature { SimplexLinks -> painterResource(MR.images.ic_link) Reports -> painterResource(MR.images.ic_flag) History -> painterResource(MR.images.ic_schedule) + Support -> painterResource(MR.images.ic_help) } @Composable @@ -5604,9 +5786,10 @@ enum class GroupFeature: Feature { SimplexLinks -> painterResource(MR.images.ic_link) Reports -> painterResource(MR.images.ic_flag_filled) History -> painterResource(MR.images.ic_schedule_filled) + Support -> painterResource(MR.images.ic_help_filled) } - fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String = + fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean, isChannel: Boolean = false): String = if (canEdit) { when(this) { TimedMessages -> when(enabled) { @@ -5614,8 +5797,8 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_disappearing) } DirectMessages -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_direct_messages) - GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_direct_messages) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.allow_direct_messages_channel else MR.strings.allow_direct_messages) + GroupFeatureEnabled.OFF -> generalGetString(if (isChannel) MR.strings.prohibit_direct_messages_channel else MR.strings.prohibit_direct_messages) } FullDelete -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_delete_messages) @@ -5642,47 +5825,55 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_member_reports) } History -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_recent_history) - GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_recent_history) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.enable_sending_recent_history_channel else MR.strings.enable_sending_recent_history) + GroupFeatureEnabled.OFF -> generalGetString(if (isChannel) MR.strings.disable_sending_recent_history_channel else MR.strings.disable_sending_recent_history) + } + Support -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.allow_chat_with_admins_channel else MR.strings.allow_chat_with_admins) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_chat_with_admins) } } } else { when(this) { TimedMessages -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_disappearing) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.group_members_can_send_disappearing_channel else MR.strings.group_members_can_send_disappearing) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disappearing_messages_are_prohibited) } DirectMessages -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_dms) - GroupFeatureEnabled.OFF -> generalGetString(MR.strings.direct_messages_are_prohibited) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.group_members_can_send_dms_channel else MR.strings.group_members_can_send_dms) + GroupFeatureEnabled.OFF -> generalGetString(if (isChannel) MR.strings.direct_messages_are_prohibited_channel else MR.strings.direct_messages_are_prohibited) } FullDelete -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_delete) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.group_members_can_delete_channel else MR.strings.group_members_can_delete) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.message_deletion_prohibited_in_chat) } Reactions -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_add_message_reactions) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.group_members_can_add_message_reactions_channel else MR.strings.group_members_can_add_message_reactions) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.message_reactions_are_prohibited) } Voice -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_voice) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.group_members_can_send_voice_channel else MR.strings.group_members_can_send_voice) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.voice_messages_are_prohibited) } Files -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_files) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.group_members_can_send_files_channel else MR.strings.group_members_can_send_files) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.files_are_prohibited_in_group) } SimplexLinks -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_simplex_links) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.group_members_can_send_simplex_links_channel else MR.strings.group_members_can_send_simplex_links) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.simplex_links_are_prohibited_in_group) } Reports -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_reports) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.group_members_can_send_reports_channel else MR.strings.group_members_can_send_reports) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.member_reports_are_prohibited) } History -> when(enabled) { - GroupFeatureEnabled.ON -> generalGetString(MR.strings.recent_history_is_sent_to_new_members) - GroupFeatureEnabled.OFF -> generalGetString(MR.strings.recent_history_is_not_sent_to_new_members) + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.recent_history_is_sent_to_new_members_channel else MR.strings.recent_history_is_sent_to_new_members) + GroupFeatureEnabled.OFF -> generalGetString(if (isChannel) MR.strings.recent_history_is_not_sent_to_new_members_channel else MR.strings.recent_history_is_not_sent_to_new_members) + } + Support -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.members_can_chat_with_admins_channel else MR.strings.members_can_chat_with_admins) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.chat_with_admins_is_prohibited) } } } @@ -5809,6 +6000,7 @@ data class FullGroupPreferences( val simplexLinks: RoleGroupPreference, val reports: GroupPreference, val history: GroupPreference, + val support: GroupPreference, val commands: List, ) { fun toGroupPreferences(): GroupPreferences = @@ -5822,6 +6014,7 @@ data class FullGroupPreferences( simplexLinks = simplexLinks, reports = reports, history = history, + support = support, commands = commands, ) @@ -5836,6 +6029,7 @@ data class FullGroupPreferences( simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), + support = GroupPreference(GroupFeatureEnabled.ON), commands = listOf() ) } @@ -5852,6 +6046,7 @@ data class GroupPreferences( val simplexLinks: RoleGroupPreference? = null, val reports: GroupPreference? = null, val history: GroupPreference? = null, + val support: GroupPreference? = null, val commands: List? = null ) { companion object { @@ -6006,6 +6201,7 @@ val yaml = Yaml(configuration = YamlConfiguration( codePointLimit = 5500000, )) +// Spec: spec/api.md#API @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @Serializable(with = APISerializer::class) sealed class API { @@ -6145,6 +6341,7 @@ private fun decodeObject(deserializer: DeserializationStrategy, obj: Json runCatching { json.decodeFromJsonElement(deserializer, obj!!) }.getOrNull() // ChatResponse +// Spec: spec/api.md#CR @Serializable sealed class CR { @Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR() @@ -6154,16 +6351,19 @@ sealed class CR { @Serializable @SerialName("chatStopped") class ChatStopped: CR() @Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR() + @Serializable @SerialName("chatContentTypes") class ChatContentTypes(val contentTypes: List): CR() @Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() + @Serializable @SerialName("chatRelayTestResult") class ChatRelayTestResult(val user: UserRef, val relayProfile: RelayProfile? = null, val relayTestFailure: RelayTestFailure? = null): CR() @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() @Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List): CR() - @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List): CR() + @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List, val serverWarnings: List = emptyList()): CR() @Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR() @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR() + @Serializable @SerialName("groupInfo") class CRGroupInfo(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats? = null): CR() @Serializable @SerialName("queueInfo") class QueueInfoR(val user: UserRef, val rcvMsgInfo: RcvMsgInfo?, val queueInfo: ServerQueueInfo): CR() @Serializable @SerialName("contactSwitchStarted") class ContactSwitchStarted(val user: UserRef, val contact: Contact, val connectionStats: ConnectionStats): CR() @@ -6190,7 +6390,7 @@ sealed class CR { @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("startedConnectionToContact") class StartedConnectionToContact(val user: UserRef, val contact: Contact): CR() - @Serializable @SerialName("startedConnectionToGroup") class StartedConnectionToGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("startedConnectionToGroup") class StartedConnectionToGroup(val user: UserRef, val groupInfo: GroupInfo, val relayResults: List = emptyList()): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @@ -6216,15 +6416,10 @@ sealed class CR { @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef, val contactRequest: UserContactRequest, val contact_: Contact?): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() @Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR() - // TODO remove below - @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List): CR() - // TODO remove above - @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() - @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() + @Serializable @SerialName("subscriptionStatus") class SubscriptionStatusEvt(val subscriptionStatus: SubscriptionStatus, val connections: List): CR() @Serializable @SerialName("chatInfoUpdated") class ChatInfoUpdated(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List): CR() + @Serializable @SerialName("chatMsgContent") class ChatMsgContent(val user: UserRef, val msgContent: MsgContent): CR() @Serializable @SerialName("chatItemsStatusesUpdated") class ChatItemsStatusesUpdated(val user: UserRef, val chatItems: List): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR() @@ -6235,6 +6430,11 @@ sealed class CR { @Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List, val forwardConfirmation: ForwardConfirmation? = null): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): CR() + @Serializable @SerialName("publicGroupCreationFailed") class PublicGroupCreationFailed(val user: UserRef, val addRelayResults: List): CR() + @Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List): CR() + @Serializable @SerialName("groupRelaysAdded") class GroupRelaysAdded(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): CR() + @Serializable @SerialName("groupRelaysAddFailed") class GroupRelaysAddFailed(val user: UserRef, val addRelayResults: List): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @@ -6257,11 +6457,12 @@ sealed class CR { @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember, val withMessages: Boolean): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: UserRef, val intoContact: Contact, val mergedContact: Contact): CR() - @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR() + @Serializable @SerialName("groupLinkDataUpdated") class GroupLinkDataUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List, val relaysChanged: Boolean): CR() + @Serializable @SerialName("groupRelayUpdated") class GroupRelayUpdated(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val groupRelay: GroupRelay): CR() @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR() @Serializable @SerialName("groupLink") class CRGroupLink(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR() @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: UserRef, val groupInfo: GroupInfo): CR() @@ -6342,9 +6543,11 @@ sealed class CR { is ChatStopped -> "chatStopped" is ApiChats -> "apiChats" is ApiChat -> "apiChat" + is ChatContentTypes -> "chatContentTypes" is ChatTags -> "chatTags" is ApiChatItemInfo -> "chatItemInfo" is ServerTestResult -> "serverTestResult" + is ChatRelayTestResult -> "chatRelayTestResult" is ServerOperatorConditions -> "serverOperatorConditions" is UserServers -> "userServers" is UserServersValidation -> "userServersValidation" @@ -6352,6 +6555,7 @@ sealed class CR { is ChatItemTTL -> "chatItemTTL" is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" + is CRGroupInfo -> "groupInfo" is GroupMemberInfo -> "groupMemberInfo" is QueueInfoR -> "queueInfo" is ContactSwitchStarted -> "contactSwitchStarted" @@ -6404,13 +6608,10 @@ sealed class CR { is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" is GroupMemberUpdated -> "groupMemberUpdated" - is ContactsSubscribed -> "contactsSubscribed" - is ContactsDisconnected -> "contactsDisconnected" - is ContactSubSummary -> "contactSubSummary" - is NetworkStatusResp -> "networkStatus" - is NetworkStatuses -> "networkStatuses" + is SubscriptionStatusEvt -> "subscriptionStatus" is ChatInfoUpdated -> "chatInfoUpdated" is NewChatItems -> "newChatItems" + is ChatMsgContent -> "chatMsgContent" is ChatItemsStatusesUpdated -> "chatItemsStatusesUpdated" is ChatItemUpdated -> "chatItemUpdated" is ChatItemNotChanged -> "chatItemNotChanged" @@ -6420,6 +6621,11 @@ sealed class CR { is GroupChatItemsDeleted -> "groupChatItemsDeleted" is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" + is PublicGroupCreated -> "publicGroupCreated" + is PublicGroupCreationFailed -> "publicGroupCreationFailed" + is GroupRelays -> "groupRelays" + is GroupRelaysAdded -> "groupRelaysAdded" + is GroupRelaysAddFailed -> "groupRelaysAddFailed" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" is GroupLinkConnecting -> "groupLinkConnecting" @@ -6442,11 +6648,12 @@ sealed class CR { is DeletedMember -> "deletedMember" is LeftMember -> "leftMember" is GroupDeleted -> "groupDeleted" - is ContactsMerged -> "contactsMerged" is UserJoinedGroup -> "userJoinedGroup" is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" is GroupUpdated -> "groupUpdated" + is GroupLinkDataUpdated -> "groupLinkDataUpdated" + is GroupRelayUpdated -> "groupRelayUpdated" is GroupLinkCreated -> "groupLinkCreated" is CRGroupLink -> "groupLink" is GroupLinkDeleted -> "groupLinkDeleted" @@ -6520,9 +6727,11 @@ sealed class CR { is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChat -> withUser(user, "remoteHostId: ${chat.remoteHostId}\nchatInfo: ${chat.chatInfo}\nchatStats: ${chat.chatStats}\nnavInfo: ${navInfo}\nchatItems: ${chat.chatItems}") + is ChatContentTypes -> "content types: ${json.encodeToString(contentTypes)}" is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") + is ChatRelayTestResult -> withUser(user, "relayProfile: $relayProfile\ntestFailure: $relayTestFailure") is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}") is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}") @@ -6530,6 +6739,7 @@ sealed class CR { is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") + is CRGroupInfo -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}") is GroupMemberInfo -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") is QueueInfoR -> withUser(user, "rcvMsgInfo: ${json.encodeToString(rcvMsgInfo)}\nqueueInfo: ${json.encodeToString(queueInfo)}\n") is ContactSwitchStarted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") @@ -6582,13 +6792,10 @@ sealed class CR { is ContactRequestRejected -> withUser(user, "contactRequest: ${json.encodeToString(contactRequest)}\ncontact_: ${json.encodeToString(contact_)}") is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember") - is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) - is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" - is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) + is SubscriptionStatusEvt -> "subscriptionStatus $subscriptionStatus\nconnections: $connections" is ChatInfoUpdated -> withUser(user, json.encodeToString(chatInfo)) is NewChatItems -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) + is ChatMsgContent -> withUser(user, msgContent.toString()) is ChatItemsStatusesUpdated -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem)) is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem)) @@ -6598,6 +6805,11 @@ sealed class CR { is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_") is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) + is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays") + is PublicGroupCreationFailed -> withUser(user, "addRelayResults: $addRelayResults") + is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays") + is GroupRelaysAdded -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays") + is GroupRelaysAddFailed -> withUser(user, "addRelayResults: $addRelayResults") is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") @@ -6620,11 +6832,12 @@ sealed class CR { is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember\nwithMessages: ${withMessages}") is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") - is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact") is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) + is GroupLinkDataUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays\nrelaysChanged: $relaysChanged") + is GroupRelayUpdated -> withUser(user, "groupInfo: $groupInfo\nmember: $member\ngroupRelay: $groupRelay") is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is CRGroupLink -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) @@ -6740,6 +6953,12 @@ fun simplexChatLink(uri: String): String = if (uri.startsWith("simplex:/")) uri.replace("simplex:/", "https://simplex.chat/") else uri +@Serializable +sealed class OwnerVerification { + @Serializable @SerialName("verified") object Verified : OwnerVerification() + @Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification() +} + @Serializable sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() @@ -6750,7 +6969,7 @@ sealed class ConnectionPlan { @Serializable sealed class InvitationLinkPlan { - @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null): InvitationLinkPlan() + @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): InvitationLinkPlan() @Serializable @SerialName("ownLink") object OwnLink: InvitationLinkPlan() @Serializable @SerialName("connecting") class Connecting(val contact_: Contact? = null): InvitationLinkPlan() @Serializable @SerialName("known") class Known(val contact: Contact): InvitationLinkPlan() @@ -6758,7 +6977,7 @@ sealed class InvitationLinkPlan { @Serializable sealed class ContactAddressPlan { - @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null): ContactAddressPlan() + @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): ContactAddressPlan() @Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() @@ -6768,11 +6987,12 @@ sealed class ContactAddressPlan { @Serializable sealed class GroupLinkPlan { - @Serializable @SerialName("ok") class Ok(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() + @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null): GroupLinkPlan() @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() @Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan() + @Serializable @SerialName("noRelays") class NoRelays(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() } abstract class TerminalItem { @@ -6810,7 +7030,8 @@ class ConnectionStats( val rcvQueuesInfo: List, val sndQueuesInfo: List, val ratchetSyncState: RatchetSyncState, - val ratchetSyncSupported: Boolean + val ratchetSyncSupported: Boolean, + var subStatus: SubscriptionStatus? ) { val ratchetSyncAllowed: Boolean get() = ratchetSyncSupported && listOf(RatchetSyncState.Allowed, RatchetSyncState.Required).contains(ratchetSyncState) @@ -6825,8 +7046,10 @@ class ConnectionStats( @Serializable class RcvQueueInfo( val rcvServer: String, + var status: QueueStatus, val rcvSwitchStatus: RcvSwitchStatus?, - var canAbortSwitch: Boolean + var canAbortSwitch: Boolean, + var subStatus: SubscriptionStatus ) @Serializable @@ -6840,6 +7063,7 @@ enum class RcvSwitchStatus { @Serializable class SndQueueInfo( val sndServer: String, + var status: QueueStatus, val sndSwitchStatus: SndSwitchStatus? ) @@ -6877,6 +7101,39 @@ enum class RatchetSyncState { @SerialName("agreed") Agreed } +@Serializable +enum class QueueStatus { + @SerialName("new") New, + @SerialName("confirmed") Confirmed, + @SerialName("secured") Secured, + @SerialName("active") Active, + @SerialName("disabled") Disabled +} + +@Serializable +sealed class SubscriptionStatus { + @Serializable @SerialName("active") object Active: SubscriptionStatus() + @Serializable @SerialName("pending") object Pending: SubscriptionStatus() + @Serializable @SerialName("removed") class Removed(val subError: String): SubscriptionStatus() + @Serializable @SerialName("noSub") object NoSub: SubscriptionStatus() + + val statusString: String get() = + when (this) { + is Active -> generalGetString(MR.strings.server_connected) + is Pending -> generalGetString(MR.strings.server_connecting) + is Removed -> generalGetString(MR.strings.server_error) + is NoSub -> generalGetString(MR.strings.server_no_sub) + } + + val statusExplanation: String get() = + when (this) { + is Active -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) + is Pending -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) + is Removed -> String.format(generalGetString(MR.strings.error_connecting_to_server_to_receive_messages), subError) + is NoSub -> generalGetString(MR.strings.not_connected_to_server_to_receive_messages_no_sub) + } +} + interface SimplexAddress { val connLinkContact: CreatedConnLink val shortLinkDataSet: Boolean @@ -6981,6 +7238,7 @@ data class RemoteFile( val fileSource: CryptoFile ) +// Spec: spec/api.md#ChatError @Serializable sealed class ChatError { val string: String get() = when (this) { @@ -7022,6 +7280,7 @@ sealed class ChatErrorType { is UserUnknown -> "userUnknown" is ActiveUserExists -> "activeUserExists" is UserExists -> "userExists" + is ChatRelayExists -> "chatRelayExists" is DifferentActiveUser -> "differentActiveUser" is CantDeleteActiveUser -> "cantDeleteActiveUser" is CantDeleteLastUser -> "cantDeleteLastUser" @@ -7063,7 +7322,6 @@ sealed class ChatErrorType { is FileCancelled -> "fileCancelled" is FileCancel -> "fileCancel" is FileAlreadyExists -> "fileAlreadyExists" - is FileRead -> "fileRead" is FileWrite -> "fileWrite $message" is FileSend -> "fileSend" is FileRcvChunk -> "fileRcvChunk" @@ -7092,6 +7350,7 @@ sealed class ChatErrorType { is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited" is ConnectionUserChangeProhibited -> "connectionUserChangeProhibited" is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible" + is RelayTestError -> "relayTestError $message" is InternalError -> "internalError" is CEException -> "exception $message" } @@ -7103,6 +7362,7 @@ sealed class ChatErrorType { @Serializable @SerialName("userUnknown") object UserUnknown: ChatErrorType() @Serializable @SerialName("activeUserExists") object ActiveUserExists: ChatErrorType() @Serializable @SerialName("userExists") class UserExists(val contactName: String): ChatErrorType() + @Serializable @SerialName("chatRelayExists") object ChatRelayExists: ChatErrorType() @Serializable @SerialName("differentActiveUser") class DifferentActiveUser(val commandUserId: Long, val activeUserId: Long): ChatErrorType() @Serializable @SerialName("cantDeleteActiveUser") class CantDeleteActiveUser(val userId: Long): ChatErrorType() @Serializable @SerialName("cantDeleteLastUser") class CantDeleteLastUser(val userId: Long): ChatErrorType() @@ -7144,7 +7404,6 @@ sealed class ChatErrorType { @Serializable @SerialName("fileCancelled") class FileCancelled(val message: String): ChatErrorType() @Serializable @SerialName("fileCancel") class FileCancel(val fileId: Long, val message: String): ChatErrorType() @Serializable @SerialName("fileAlreadyExists") class FileAlreadyExists(val filePath: String): ChatErrorType() - @Serializable @SerialName("fileRead") class FileRead(val filePath: String, val message: String): ChatErrorType() @Serializable @SerialName("fileWrite") class FileWrite(val filePath: String, val message: String): ChatErrorType() @Serializable @SerialName("fileSend") class FileSend(val fileId: Long, val agentError: String): ChatErrorType() @Serializable @SerialName("fileRcvChunk") class FileRcvChunk(val message: String): ChatErrorType() @@ -7173,6 +7432,7 @@ sealed class ChatErrorType { @Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType() @Serializable @SerialName("connectionUserChangeProhibited") object ConnectionUserChangeProhibited: ChatErrorType() @Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType() + @Serializable @SerialName("relayTestError") class RelayTestError(val message: String): ChatErrorType() @Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType() @Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType() } @@ -7183,6 +7443,7 @@ sealed class StoreError { get() = when (this) { is DuplicateName -> "duplicateName" is UserNotFound -> "userNotFound $userId" + is RelayUserNotFound -> "relayUserNotFound" is UserNotFoundByName -> "userNotFoundByName $contactName" is UserNotFoundByContactId -> "userNotFoundByContactId $contactId" is UserNotFoundByGroupId -> "userNotFoundByGroupId $groupId" @@ -7206,6 +7467,7 @@ sealed class StoreError { is MemberContactGroupMemberNotFound -> "memberContactGroupMemberNotFound $contactId" is GroupWithoutUser -> "groupWithoutUser" is DuplicateGroupMember -> "duplicateGroupMember" + is DuplicateMemberId -> "duplicateMemberId" is GroupAlreadyJoined -> "groupAlreadyJoined" is GroupInvitationNotFound -> "groupInvitationNotFound" is NoteFolderAlreadyExists -> "noteFolderAlreadyExists $noteFolderId" @@ -7246,6 +7508,9 @@ sealed class StoreError { is HostMemberIdNotFound -> "hostMemberIdNotFound $groupId" is ContactNotFoundByFileId -> "contactNotFoundByFileId $fileId" is NoGroupSndStatus -> "noGroupSndStatus $itemId $groupMemberId" + is UserChatRelayNotFound -> "userChatRelayNotFound $chatRelayId" + is GroupRelayNotFound -> "groupRelayNotFound $groupRelayId" + is GroupRelayNotFoundByMemberId -> "groupRelayNotFoundByMemberId $groupMemberId" is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" is RemoteHostUnknown -> "remoteHostUnknown" @@ -7261,6 +7526,7 @@ sealed class StoreError { @Serializable @SerialName("duplicateName") object DuplicateName: StoreError() @Serializable @SerialName("userNotFound") class UserNotFound(val userId: Long): StoreError() + @Serializable @SerialName("relayUserNotFound") object RelayUserNotFound: StoreError() @Serializable @SerialName("userNotFoundByName") class UserNotFoundByName(val contactName: String): StoreError() @Serializable @SerialName("userNotFoundByContactId") class UserNotFoundByContactId(val contactId: Long): StoreError() @Serializable @SerialName("userNotFoundByGroupId") class UserNotFoundByGroupId(val groupId: Long): StoreError() @@ -7284,6 +7550,7 @@ sealed class StoreError { @Serializable @SerialName("memberContactGroupMemberNotFound") class MemberContactGroupMemberNotFound(val contactId: Long): StoreError() @Serializable @SerialName("groupWithoutUser") object GroupWithoutUser: StoreError() @Serializable @SerialName("duplicateGroupMember") object DuplicateGroupMember: StoreError() + @Serializable @SerialName("duplicateMemberId") object DuplicateMemberId: StoreError() @Serializable @SerialName("groupAlreadyJoined") object GroupAlreadyJoined: StoreError() @Serializable @SerialName("groupInvitationNotFound") object GroupInvitationNotFound: StoreError() @Serializable @SerialName("noteFolderAlreadyExists") class NoteFolderAlreadyExists(val noteFolderId: Long): StoreError() @@ -7324,6 +7591,9 @@ sealed class StoreError { @Serializable @SerialName("hostMemberIdNotFound") class HostMemberIdNotFound(val groupId: Long): StoreError() @Serializable @SerialName("contactNotFoundByFileId") class ContactNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("noGroupSndStatus") class NoGroupSndStatus(val itemId: Long, val groupMemberId: Long): StoreError() + @Serializable @SerialName("userChatRelayNotFound") class UserChatRelayNotFound(val chatRelayId: Long): StoreError() + @Serializable @SerialName("groupRelayNotFound") class GroupRelayNotFound(val groupRelayId: Long): StoreError() + @Serializable @SerialName("groupRelayNotFoundByMemberId") class GroupRelayNotFoundByMemberId(val groupMemberId: Long): StoreError() @Serializable @SerialName("duplicateGroupMessage") class DuplicateGroupMessage(val groupId: Long, val sharedMsgId: String, val authorGroupMemberId: Long?, val forwardedByGroupMemberId: Long?): StoreError() @Serializable @SerialName("remoteHostNotFound") class RemoteHostNotFound(val remoteHostId: Long): StoreError() @Serializable @SerialName("remoteHostUnknown") object RemoteHostUnknown: StoreError() @@ -7371,6 +7641,7 @@ sealed class AgentErrorType { is RCP -> "RCP ${rcpErr.string}" is BROKER -> "BROKER ${brokerErr.string}" is AGENT -> "AGENT ${agentErr.string}" + is NOTICE -> "NOTICE $server $expiresAt" is INTERNAL -> "INTERNAL $internalErr" is CRITICAL -> "CRITICAL $offerRestart $criticalErr" is INACTIVE -> "INACTIVE" @@ -7384,6 +7655,7 @@ sealed class AgentErrorType { @Serializable @SerialName("RCP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() + @Serializable @SerialName("NOTICE") class NOTICE(val server: String, val preset: Boolean, val expiresAt: Instant?): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @Serializable @SerialName("CRITICAL") data class CRITICAL(val offerRestart: Boolean, val criticalErr: String): AgentErrorType() @Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType() @@ -7664,6 +7936,7 @@ sealed class RCErrorType { @Serializable @SerialName("syntax") data class SYNTAX(val syntaxErr: String): RCErrorType() } +// Spec: spec/database.md#ArchiveError @Serializable sealed class ArchiveError { val string: String get() = when (this) { @@ -7745,6 +8018,7 @@ sealed class RemoteCtrlError { @Serializable @SerialName("protocolError") object ProtocolError: RemoteCtrlError() } +// Spec: spec/services/notifications.md#NotificationsMode enum class NotificationsMode() { OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */; @@ -7768,6 +8042,7 @@ data class AppSettings( var privacyAskToApproveRelays: Boolean? = null, var privacyAcceptImages: Boolean? = null, var privacyLinkPreviews: Boolean? = null, + var privacySanitizeLinks: Boolean? = null, var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = null, var privacyShowChatPreviews: Boolean? = null, var privacySaveLastDraft: Boolean? = null, @@ -7804,6 +8079,7 @@ data class AppSettings( if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages } if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews } + if (privacySanitizeLinks != def.privacySanitizeLinks) { empty.privacySanitizeLinks = privacySanitizeLinks } if (privacyChatListOpenLinks != def.privacyChatListOpenLinks) { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews } if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft } @@ -7851,6 +8127,7 @@ data class AppSettings( privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) } privacyAcceptImages?.let { def.privacyAcceptImages.set(it) } privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) } + privacySanitizeLinks?.let { def.privacySanitizeLinks.set(it) } privacyChatListOpenLinks?.let { def.privacyChatListOpenLinks.set(it) } privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) } privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) } @@ -7888,6 +8165,7 @@ data class AppSettings( privacyAskToApproveRelays = true, privacyAcceptImages = true, privacyLinkPreviews = true, + privacySanitizeLinks = false, privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK, privacyShowChatPreviews = true, privacySaveLastDraft = true, @@ -7926,6 +8204,7 @@ data class AppSettings( privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(), privacyAcceptImages = def.privacyAcceptImages.get(), privacyLinkPreviews = def.privacyLinkPreviews.get(), + privacySanitizeLinks = def.privacySanitizeLinks.get(), privacyChatListOpenLinks = def.privacyChatListOpenLinks.get(), privacyShowChatPreviews = def.privacyShowChatPreviews.get(), privacySaveLastDraft = def.privacySaveLastDraft.get(), @@ -8111,3 +8390,34 @@ enum class MsgType { @SerialName("quota") QUOTA } + +fun showClientNoticeAlert(server: String, preset: Boolean, expiresAt: Instant?) { + var message = "Server: $server.\nConditions of use violation notice received from ${if (preset) "preset" else "this"} server.\nNo ID shared, see How it works." + if (expiresAt != null) { + val tz = TimeZone.currentSystemDefault() + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + message += "\n\nNew addresses can be created after ${expiresAt.toLocalDateTime(tz).toJavaLocalDateTime().format(formatter)}." + } + AlertManager.shared.showAlertDialogButtonsColumn(title = "Not allowed", text = AnnotatedString(message)) { + val uriHandler = LocalUriHandler.current + Column { + SectionItemView({ AlertManager.shared.hideAlert() }) { + Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + if (preset) { + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(defaultConditionsLink) + }) { + Text(generalGetString(MR.strings.operator_conditions_of_use), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(contentModerationPostLink) + }) { + Text(generalGetString(MR.strings.how_it_works), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index d0ce703033..3805a8e8b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -14,12 +14,14 @@ import java.io.File import java.nio.ByteBuffer // ghc's rts +// Spec: spec/architecture.md#initHS external fun initHS() // android-support external fun pipeStdOutToSocket(socketName: String) : Int // SimpleX API typealias ChatCtrl = Long +// Spec: spec/architecture.md#chatMigrateInit external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array external fun chatCloseStore(ctrl: ChatCtrl): String external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String @@ -45,6 +47,7 @@ val appPreferences: AppPreferences val chatController: ChatController = ChatController +// Spec: spec/architecture.md#initChatControllerOnStart fun initChatControllerOnStart() { withLongRunningApi { if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) { @@ -55,6 +58,7 @@ fun initChatControllerOnStart() { } } +// Spec: spec/architecture.md#initChatController suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: () -> CompletableDeferred = { CompletableDeferred(true) }) { Log.d(TAG, "initChatController") try { @@ -158,11 +162,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } else if (startChat().await()) { val savedOnboardingStage = appPreferences.onboardingStage.get() val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { - if (appPlatform.isAndroid) { - OnboardingStage.Step4_SetNotificationsMode - } else { - OnboardingStage.OnboardingComplete - } + OnboardingStage.Step4_NetworkCommitments } else { savedOnboardingStage } @@ -182,6 +182,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } +// Spec: spec/architecture.md#chatInitTemporaryDatabase fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: MigrationConfirmation = MigrationConfirmation.Error): Pair { val dbKey = key ?: randomDatabasePassword() Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath") @@ -193,6 +194,7 @@ fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: return res to migrated[1] as ChatCtrl } +// Spec: spec/architecture.md#chatInitControllerRemovingDatabases fun chatInitControllerRemovingDatabases() { val dbPath = dbAbsolutePrefixPath // Remove previous databases, otherwise, can be .errorNotADatabase with null controller diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 0a4f670fe0..cdd4140e3f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -14,6 +14,7 @@ import java.net.URLEncoder import java.nio.file.Files import java.nio.file.StandardCopyOption +// Spec: spec/services/files.md#dataDir expect val dataDir: File expect val tmpDir: File expect val filesDir: File @@ -41,7 +42,9 @@ expect fun desktopOpenDir(dir: File) fun createURIFromPath(absolutePath: String): URI = URI.create(URLEncoder.encode(absolutePath, "UTF-8")) -fun URI.toFile(): File = File(URLDecoder.decode(rawPath, "UTF-8").removePrefix("file:")) +fun URI.toFile(): File = + if (scheme == "file") File(this) + else File(URLDecoder.decode(rawPath, "UTF-8").removePrefix("file:")) fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { try { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index 19e40ab0a2..d7a241d5ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -1,7 +1,15 @@ package chat.simplex.common.platform +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale import boofcv.struct.image.GrayU8 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URI @@ -23,3 +31,23 @@ expect fun isImage(uri: URI): Boolean expect fun isAnimImage(uri: URI, drawable: Any?): Boolean expect fun loadImageBitmap(inputStream: InputStream): ImageBitmap + +@Composable +fun Base64AsyncImage( + base64ImageString: String, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier = Modifier +) { + val imageBitmap by produceState(initialValue = null, base64ImageString) { + value = withContext(Dispatchers.IO) { base64ToBitmap(base64ImageString) } + } + imageBitmap?.let { + Image( + bitmap = it, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index d906ef7baf..385120f18b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -13,6 +13,7 @@ enum class NotificationAction { ACCEPT_CONTACT_REQUEST } +// Spec: spec/services/notifications.md#ntfManager lateinit var ntfManager: NtfManager abstract class NtfManager { @@ -43,7 +44,7 @@ abstract class NtfManager { chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId) ) { - displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) + displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem, cInfo.isChannel)) } } @@ -118,7 +119,7 @@ abstract class NtfManager { } } - private fun hideSecrets(cItem: ChatItem): String { + private fun hideSecrets(cItem: ChatItem, isChannel: Boolean = false): String { val md = cItem.formattedText return if (md != null) { var res = "" @@ -129,9 +130,9 @@ abstract class NtfManager { } else { val mc = cItem.content.msgContent if (mc is MsgContent.MCReport) { - generalGetString(MR.strings.notification_group_report).format(cItem.text.ifEmpty { mc.reason.text }) + generalGetString(MR.strings.notification_group_report).format(cItem.text(isChannel).ifEmpty { mc.reason.text }) } else { - cItem.text + cItem.text(isChannel) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt index c50ea5c349..3df780ae24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt @@ -3,16 +3,16 @@ package chat.simplex.common.ui.theme import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.graphics.* -import chat.simplex.common.views.helpers.mixWith -import kotlin.math.min +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import kotlin.math.cos +import kotlin.math.sin + +fun oklch(L: Float, C: Float, H: Float, alpha: Float = 1f): Color { + val hRad = H * (Math.PI.toFloat() / 180f) + return Color(L, C * cos(hRad), C * sin(hRad), alpha, ColorSpaces.Oklab) +} -val Purple200 = Color(0xFFBB86FC) -val Purple500 = Color(0xFF6200EE) -val Purple700 = Color(0xFF3700B3) -val Teal200 = Color(0xFF03DAC5) -val Gray = Color(0x22222222) val Indigo = Color(0xFF9966FF) val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files val SimplexGreen = Color(77, 218, 103, 255) @@ -29,8 +29,8 @@ val GroupDark = Color(80, 80, 80, 60) val IncomingCallLight = Color(239, 237, 236, 255) val WarningOrange = Color(255, 127, 0, 255) val WarningYellow = Color(255, 192, 0, 255) -val FileLight = Color(183, 190, 199, 255) -val FileDark = Color(101, 101, 106, 255) +val FileLight = Color(191, 194, 199, 255) +val FileDark = Color(94, 94, 98, 255) val MenuTextColor: Color @Composable get () = if (isInDarkTheme()) LocalContentColor.current.copy(alpha = 0.8f) else Color.Black val NoteFolderIconColor: Color @Composable get() = MaterialTheme.appColors.primaryVariant2 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index df9af7fbf6..1de47df7ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -22,6 +22,7 @@ import chat.simplex.res.MR import kotlinx.serialization.Transient import java.util.UUID +// Spec: spec/services/theme.md#DefaultTheme enum class DefaultTheme { LIGHT, DARK, SIMPLEX, BLACK; @@ -47,6 +48,7 @@ enum class DefaultThemeMode { @SerialName("dark") DARK } +// Spec: spec/services/theme.md#AppColors @Stable class AppColors( title: Color, @@ -99,6 +101,7 @@ class AppColors( } } +// Spec: spec/services/theme.md#AppWallpaper @Stable class AppWallpaper( background: Color? = null, @@ -133,6 +136,7 @@ class AppWallpaper( } } +// Spec: spec/services/theme.md#ThemeColor enum class ThemeColor { PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, SENT_QUOTE, RECEIVED_MESSAGE, RECEIVED_QUOTE, PRIMARY_VARIANT2, WALLPAPER_BACKGROUND, WALLPAPER_TINT; @@ -174,6 +178,7 @@ enum class ThemeColor { } } +// Spec: spec/services/theme.md#ThemeColors @Serializable data class ThemeColors( @SerialName("accent") @@ -214,6 +219,7 @@ data class ThemeColors( } } +// Spec: spec/services/theme.md#ThemeWallpaper @Serializable data class ThemeWallpaper ( val preset: String? = null, @@ -293,6 +299,7 @@ data class ThemesFile( val themes: List = emptyList() ) +// Spec: spec/services/theme.md#ThemeOverrides @Serializable data class ThemeOverrides ( val themeId: String = UUID.randomUUID().toString(), @@ -463,6 +470,7 @@ fun List.skipDuplicates(): List { return res } +// Spec: spec/services/theme.md#ThemeModeOverrides @Serializable data class ThemeModeOverrides ( val light: ThemeModeOverride? = null, @@ -474,6 +482,7 @@ data class ThemeModeOverrides ( } } +// Spec: spec/services/theme.md#ThemeModeOverride @Serializable data class ThemeModeOverride ( val mode: DefaultThemeMode = CurrentColors.value.base.mode, @@ -617,6 +626,7 @@ val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp val DEFAULT_MIN_SECTION_ITEM_HEIGHT = 50.dp val DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL = 15.dp +val DEFAULT_WINDOW_WIDTH = 1366.dp val DEFAULT_START_MODAL_WIDTH = 388.dp val DEFAULT_MIN_CENTER_MODAL_WIDTH = 590.dp val DEFAULT_END_MODAL_WIDTH = 388.dp @@ -714,6 +724,7 @@ val BlackColorPaletteApp = AppColors( var systemInDarkThemeCurrently: Boolean = isInNightMode() +// Spec: spec/services/theme.md#CurrentColors val CurrentColors: MutableStateFlow = MutableStateFlow(ThemeManager.currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get())) @Composable @@ -758,6 +769,7 @@ fun reactOnDarkThemeChanges(isDark: Boolean) { } } +// Spec: spec/services/theme.md#SimpleXTheme @Composable fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) { // TODO: Fix preview working with dark/light theme diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index 07f2b678cf..7d8c79b4a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -53,6 +53,7 @@ object ThemeManager { ?: ThemeWallpaper.from(PresetWallpaper.SCHOOL.toType(CurrentColors.value.base), null, null)) } + // Spec: spec/services/theme.md#currentColors fun currentColors(themeOverridesForType: WallpaperType?, perChatTheme: ThemeModeOverride?, perUserTheme: ThemeModeOverrides?, appSettingsTheme: List): ActiveTheme { val themeName = appPrefs.currentTheme.get()!! val nonSystemThemeName = nonSystemThemeName() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt index 9acfffb3ac..9b0f89c36d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt @@ -10,7 +10,7 @@ val Typography = Typography( h1 = TextStyle( fontFamily = Inter, fontWeight = FontWeight.Bold, - fontSize = 32.sp, + fontSize = 33.5.sp, ), h2 = TextStyle( fontFamily = Inter, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 6ec124048c..3e4b8ce1db 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -4,6 +4,7 @@ import SectionTextFooter import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.* import androidx.compose.material.MaterialTheme.colors @@ -11,28 +12,44 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.* +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.migration.MigrateToDeviceView +import chat.simplex.common.views.migration.MigrationToState +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.onboarding.* +import chat.simplex.common.views.usersettings.DeleteImageButton +import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import java.net.URI const val MAX_BIO_LENGTH_BYTES = 160 @@ -46,18 +63,63 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { val scrollState = rememberScrollState() val keyboardState by getKeyboardState() var savedKeyboardState by remember { mutableStateOf(keyboardState) } - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 20.dp) - ) { - val displayName = rememberSaveable { mutableStateOf("") } - val shortDescr = rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val displayName = rememberSaveable { mutableStateOf("") } + val shortDescr = rememberSaveable { mutableStateOf("") } + val chosenImage = rememberSaveable { mutableStateOf(null) } + val profileImage = rememberSaveable { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + ModalBottomSheetLayout( + scrimColor = Color.Black.copy(alpha = 0.12F), + modifier = Modifier.imePadding(), + sheetContent = { + GetImageBottomSheet( + chosenImage, + onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) }, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + }) + }, + sheetState = bottomSheetModalState, + sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING_HALF) + Row( + Modifier + .fillMaxWidth() + .padding(vertical = DEFAULT_PADDING_HALF), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = if (BuildConfigCommon.SIMPLEX_ASSETS) Modifier.padding(horizontal = 3.dp) else Modifier, + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(128.dp, image = profileImage.value) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } + } + } + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) MR.images.create_profile_light else MR.images.create_profile), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.height(140.dp) + ) + } + } Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { - AppBarTitle(stringResource(MR.strings.create_profile), withPadding = false, bottomPadding = DEFAULT_PADDING) Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( stringResource(MR.strings.display_name), @@ -100,9 +162,9 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { iconColor = MaterialTheme.colors.primary, click = { if (chatModel.localUserCreated.value == true) { - createProfileInProfiles(chatModel, displayName.value, shortDescr.value, close) + createProfileInProfiles(chatModel, displayName.value, shortDescr.value, profileImage.value, close) } else { - createProfileInNoProfileSetup(displayName.value, close) + createProfileInNoProfileSetup(displayName.value, profileImage.value, close) } }, ) @@ -123,49 +185,119 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { } } } + } } @Composable fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val keyboardState by getKeyboardState() - var savedKeyboardState by remember { mutableStateOf(keyboardState) } - CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView({ - if (chatModel.users.none { !it.user.hidden }) { - appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) - } else { - close() + if (appPlatform.isDesktop) { + CreateFirstProfileDesktop(chatModel, close) + } else { + CreateFirstProfileMobile(chatModel, close) + } +} + +@Composable +private fun RowScope.MigrateButton(refocusTrigger: MutableState) { + val focusManager = LocalFocusManager.current + TextButton( + onClick = { + focusManager.clearFocus() + if (chatModel.migrationState.value == null) { + chatModel.migrationState.value = MigrationToState.PasteOrScanLink } - }) { - ColumnWithScrollBar { - val displayName = rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } - Column(if (appPlatform.isAndroid) Modifier.fillMaxSize().padding(start = DEFAULT_ONBOARDING_HORIZONTAL_PADDING * 2, end = DEFAULT_ONBOARDING_HORIZONTAL_PADDING * 2, bottom = DEFAULT_PADDING) else Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { - Box(Modifier.align(Alignment.CenterHorizontally)) { - AppBarTitle(stringResource(MR.strings.create_your_profile), bottomPadding = DEFAULT_PADDING, withPadding = false) - } - ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) - Spacer(Modifier.height(DEFAULT_PADDING)) - ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) - Spacer(Modifier.height(DEFAULT_PADDING)) - ProfileNameField(displayName, stringResource(MR.strings.display_name), { it.trim() == mkValidName(it) }, focusRequester) + ModalManager.fullscreen.showCustomModal(animated = false, forceAnimated = appPlatform.isDesktop) { close -> + MigrateToDeviceView { + close() + refocusTrigger.value++ } - Spacer(Modifier.fillMaxHeight().weight(1f)) - Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + } + }, + modifier = Modifier.padding(end = DEFAULT_PADDING_HALF) + ) { + Icon(painterResource(MR.images.ic_download), null, Modifier.size(22.dp), tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(4.dp)) + Text( + stringResource(if (appPlatform.isDesktop) MR.strings.migrate_from_another_device else MR.strings.migrate), + color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium + ) + } +} + +private fun onboardingBackAction(chatModel: ChatModel, close: () -> Unit) { + if (chatModel.users.none { !it.user.hidden }) { + appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } else { + close() + } +} + +@Composable +private fun CreateFirstProfileMobile(chatModel: ChatModel, close: () -> Unit) { + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + val focusRequester = remember { FocusRequester() } + val refocusTrigger = remember { mutableStateOf(0) } + ModalView( + close = { onboardingBackAction(chatModel, close) }, + endButtons = { MigrateButton(refocusTrigger) } + ) { + val displayName = rememberSaveable { mutableStateOf("") } + val keyboardState by getKeyboardState() + val imageHeightModifier = if (keyboardState == KeyboardState.Opened) { + Modifier.heightIn(max = 100.dp) + } else { + Modifier + } + ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING), horizontalAlignment = Alignment.CenterHorizontally, maxIntrinsicSize = true) { + Spacer(Modifier.weight(1f)) + + OnboardingImage( + MR.images.your_profile, MR.images.your_profile_light, MR.images.ic_person, + modifier = Modifier + .then(if (keyboardState != KeyboardState.Opened) Modifier.fillMaxWidth() else Modifier) + .then(imageHeightModifier) + ) + + Text( + stringResource(MR.strings.onboarding_your_profile), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) + Text( + stringResource(MR.strings.onboarding_on_your_phone), + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, + lineHeight = 25.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 14.dp) + ) + Text( + stringResource(MR.strings.onboarding_no_account), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + lineHeight = 20.sp, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + ProfileNameField(displayName, stringResource(MR.strings.enter_profile_name), { it.trim() == mkValidName(it) }, focusRequester) + + Spacer(Modifier.weight(1f)) + + Column(Modifier.widthIn(max = 450.dp).padding(bottom = DEFAULT_PADDING * 2).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton( - if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.create_profile_button, + Modifier.fillMaxWidth(), + labelId = MR.strings.create_profile, onboarding = null, enabled = canCreateProfile(displayName.value), - onclick = { createProfileOnboarding(chat.simplex.common.platform.chatModel, displayName.value, close) } + onclick = { createProfileOnboarding(chatModel, displayName.value, close) } ) - // Reserve space - TextButtonBelowOnboardingButton("", null) } - LaunchedEffect(Unit) { + LaunchedEffect(refocusTrigger.value) { delay(300) focusRequester.requestFocus() } @@ -173,21 +305,57 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { LaunchedEffect(Unit) { setLastVersionDefault(chatModel) } - if (savedKeyboardState != keyboardState) { - LaunchedEffect(keyboardState) { - scope.launch { - savedKeyboardState = keyboardState - scrollState.animateScrollTo(scrollState.maxValue) - } - } - } } } } -fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) { +@Composable +private fun CreateFirstProfileDesktop(chatModel: ChatModel, close: () -> Unit) { + val focusRequester = remember { FocusRequester() } + val refocusTrigger = remember { mutableStateOf(0) } + val displayName = rememberSaveable { mutableStateOf("") } + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView( + close = { onboardingBackAction(chatModel, close) }, + endButtons = { MigrateButton(refocusTrigger) } + ) { + ColumnWithScrollBar(horizontalAlignment = Alignment.CenterHorizontally) { + Column(Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + AppBarTitle(stringResource(MR.strings.onboarding_your_profile), bottomPadding = DEFAULT_PADDING, withPadding = false, overrideTitleColor = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, lineHeight = 42.sp) + } + Text(stringResource(MR.strings.onboarding_on_your_phone), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Medium, color = MaterialTheme.colors.secondary, lineHeight = 25.sp, textAlign = TextAlign.Center) + Spacer(Modifier.height(DEFAULT_PADDING)) + ReadableText(MR.strings.onboarding_no_account, TextAlign.Center, style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.secondary)) + Spacer(Modifier.height(DEFAULT_PADDING)) + ProfileNameField(displayName, stringResource(MR.strings.enter_profile_name), { it.trim() == mkValidName(it) }, focusRequester) + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + Column(Modifier.widthIn(max = 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton( + Modifier.widthIn(min = 300.dp), + labelId = MR.strings.create_profile, + onboarding = null, + enabled = canCreateProfile(displayName.value), + onclick = { createProfileOnboarding(chatModel, displayName.value, close) } + ) + TextButtonBelowOnboardingButton("", null) + } + } + LaunchedEffect(Unit) { + setLastVersionDefault(chatModel) + } + } + } + LaunchedEffect(refocusTrigger.value) { + delay(300) + focusRequester.requestFocus() + } +} + +fun createProfileInNoProfileSetup(displayName: String, image: String? = null, close: () -> Unit) { withBGApi { - val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null, null)) ?: return@withBGApi + val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null, image)) ?: return@withBGApi if (!chatModel.connectedToRemote()) { chatModel.localUserCreated.value = true } @@ -198,16 +366,16 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) { } } -fun createProfileInProfiles(chatModel: ChatModel, displayName: String, shortDescr: String, close: () -> Unit) { +fun createProfileInProfiles(chatModel: ChatModel, displayName: String, shortDescr: String, image: String? = null, close: () -> Unit) { withBGApi { val rhId = chatModel.remoteHostId() val user = chatModel.controller.apiCreateActiveUser( - rhId, Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, null) + rhId, Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image) ) ?: return@withBGApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) - chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_NetworkCommitments) } else { val users = chatModel.controller.listUsers(rhId) chatModel.users.clear() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt index 8f5aba138d..7a92bc8c39 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt @@ -6,6 +6,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import kotlinx.coroutines.* +// Spec: spec/services/calls.md#ActiveCallView @Composable expect fun ActiveCallView() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 4d8c1fae46..563f4c3b83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -22,6 +22,7 @@ import chat.simplex.common.views.usersettings.ProfilePreview import chat.simplex.res.MR import kotlinx.datetime.Clock +// Spec: spec/services/calls.md#IncomingCallAlertView @Composable fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) { val cm = chatModel.callManager diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 705fc6a28f..6fa99283d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -46,6 +46,7 @@ data class Call( get() = localMediaSources.hasVideo || peerMediaSources.hasVideo } +// Spec: spec/services/calls.md#CallState enum class CallState { WaitCapabilities, InvitationSent, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index c7b8cb2c81..061ea71016 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -72,9 +72,6 @@ fun ChatInfoView( val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null && currentUser != null) { - val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) { - mutableStateOf(chatModel.contactNetworkStatus(contact)) - } val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } val chatItemTTL = remember(contact.id) { mutableStateOf(if (contact.chatItemTTL != null) ChatItemTTL.fromSeconds(contact.chatItemTTL) else null) } @@ -101,7 +98,6 @@ fun ChatInfoView( setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, connStats = connStats, - contactNetworkStatus.value, customUserProfile, localAlias, connectionCode, @@ -524,7 +520,6 @@ fun ChatInfoLayout( chatItemTTL: MutableState, setChatItemTTL: (ChatItemTTL?) -> Unit, connStats: MutableState, - contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, localAlias: String, connectionCode: String?, @@ -643,13 +638,16 @@ fun ChatInfoLayout( if (contact.ready && contact.active) { SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { - SectionItemView({ - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.network_status), - contactNetworkStatus.statusExplanation - ) - }) { - NetworkStatusRow(contactNetworkStatus) + val chatSubStatus = chatModel.chatSubStatus.value + if (chatSubStatus != null) { + SectionItemView({ + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.network_status), + chatSubStatus.statusExplanation + ) + }) { + SubStatusRow(chatSubStatus) + } } if (cStats != null) { SwitchAddressButton( @@ -1063,7 +1061,7 @@ fun InfoViewActionButton( } @Composable -private fun NetworkStatusRow(networkStatus: NetworkStatus) { +fun SubStatusRow(subStatus: SubscriptionStatus) { Row( Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween, @@ -1086,25 +1084,24 @@ private fun NetworkStatusRow(networkStatus: NetworkStatus) { horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - networkStatus.statusString, + subStatus.statusString, color = MaterialTheme.colors.secondary ) - ServerImage(networkStatus) + ServerImage(subStatus) } } } @Composable -private fun ServerImage(networkStatus: NetworkStatus) { +private fun ServerImage(subStatus: SubscriptionStatus) { Box(Modifier.size(18.dp)) { - when (networkStatus) { - is NetworkStatus.Connected -> + when (subStatus) { + is SubscriptionStatus.Active -> Icon(painterResource(MR.images.ic_circle_filled), stringResource(MR.strings.icon_descr_server_status_connected), tint = Color.Green) - is NetworkStatus.Disconnected -> + is SubscriptionStatus.Pending -> Icon(painterResource(MR.images.ic_pending_filled), stringResource(MR.strings.icon_descr_server_status_disconnected), tint = MaterialTheme.colors.secondary) - is NetworkStatus.Error -> + is SubscriptionStatus.Removed, SubscriptionStatus.NoSub -> Icon(painterResource(MR.images.ic_error_filled), stringResource(MR.strings.icon_descr_server_status_error), tint = MaterialTheme.colors.secondary) - else -> Icon(painterResource(MR.images.ic_circle), stringResource(MR.strings.icon_descr_server_status_pending), tint = MaterialTheme.colors.secondary) } } } @@ -1455,7 +1452,6 @@ fun PreviewChatInfoLayout() { connectionCode = "123", developerTools = false, connStats = remember { mutableStateOf(null) }, - contactNetworkStatus = NetworkStatus.Connected(), onLocalAliasChanged = {}, customUserProfile = null, openPreferences = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index ed40150cb1..6562d40cec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -27,11 +27,12 @@ suspend fun apiLoadMessages( chatType: ChatType, apiId: Long, pagination: ChatPagination, + contentTag: MsgContentTag? = null, search: String = "", openAroundItemId: Long? = null, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), contentTag ?: chatsCtx.contentTag, pagination, search) ?: return@coroutineScope // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes /** When [openAroundItemId] is provided, chatId can be different too */ if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null) @@ -87,7 +88,8 @@ suspend fun processLoadedChat( val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( - unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed + unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed, + selectionActive = chatState.selectionActive ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) @@ -176,13 +178,14 @@ private fun removeDuplicatesAndModifySplitsOnBeforePagination( newItems: SnapshotStateList, newIds: Set, splits: StateFlow>, - visibleItemIndexesNonReversed: () -> IntRange + visibleItemIndexesNonReversed: () -> IntRange, + selectionActive: Boolean = false ): ModifiedSplits { var oldUnreadSplitIndex: Int = -1 var newUnreadSplitIndex: Int = -1 val visibleItemIndexes = visibleItemIndexesNonReversed() var lastSplitIndexTrimmed = -1 - var allowedTrimming = true + var allowedTrimming = !selectionActive var index = 0 /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index d98c041478..f9d32892ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -202,7 +202,8 @@ data class ActiveChatState ( // exclusive val unreadAfter: MutableStateFlow = MutableStateFlow(0), // exclusive - val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0), + @Volatile var selectionActive: Boolean = false ) { fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List) { toItemId ?: return diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index b69c98887d..f42969a73f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -45,6 +45,7 @@ import chat.simplex.common.views.newchat.ContactConnectionInfoView import chat.simplex.common.views.newchat.alertProfileImageSize import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.datetime.* @@ -92,6 +93,7 @@ fun ConnectInProgressView(s: String) { @Composable // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts +// Spec: spec/client/chat-view.md#ChatView fun ChatView( chatsCtx: ChatModel.ChatsContext, staleChatId: State, @@ -102,6 +104,7 @@ fun ChatView( val chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } + val chatRh = remoteHostId.value val activeChat = remember { derivedStateOf { var chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } val chatInfo = chat?.chatInfo @@ -120,6 +123,8 @@ fun ChatView( if (chat == null || chatInfo == null || user == null) { LaunchedEffect(Unit) { chatModel.chatId.value = null + chatModel.chatAgentConnId.value = null + chatModel.chatSubStatus.value = null ModalManager.end.closeModals() } } else { @@ -141,6 +146,10 @@ fun ChatView( val scope = rememberCoroutineScope() val selectedChatItems = rememberSaveable { mutableStateOf(null as Set?) } val showCommandsMenu = rememberSaveable { mutableStateOf(false) } + val contentFilter = rememberSaveable { mutableStateOf(null) } + val availableContent = remember { mutableStateOf>(ContentFilter.initialList) } + val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null + if (appPlatform.isAndroid) { DisposableEffect(Unit) { onDispose { @@ -161,10 +170,57 @@ fun ChatView( } if (chatsCtx.secondaryContextFilter == null) { markUnreadChatAsRead(chatId) + chatModel.groupMembers.value = emptyList() + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false } showSearch.value = false searchText.value = "" + contentFilter.value = null + availableContent.value = ContentFilter.initialList selectedChatItems.value = null + selectionManager?.clearSelection() + val cInfo = activeChat.value?.chatInfo + if (chatsCtx.secondaryContextFilter == null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group || cInfo is ChatInfo.Local)) { + updateAvailableContent(chatRh, activeChat, availableContent) + } + if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.activeConn != null) { + withBGApi { + val r = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) + if (r != null) { + val contactStats = r.first + if (contactStats != null) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, chat.chatInfo.contact, contactStats) + chatModel.chatAgentConnId.value = chat.chatInfo.contact.activeConn.agentConnId + chatModel.chatSubStatus.value = contactStats.subStatus + } + } + } + } else { + withContext(Dispatchers.Main) { + chatModel.chatAgentConnId.value = null + chatModel.chatSubStatus.value = null + } + } + if (cInfo is ChatInfo.Group && cInfo.groupInfo.useRelays) { + withBGApi { + setGroupMembers(chatRh, cInfo.groupInfo, chatModel) + if (cInfo.groupInfo.membership.memberRole == GroupMemberRole.Owner) { + val relays = chatModel.controller.apiGetGroupRelays(cInfo.groupInfo.groupId) + withContext(Dispatchers.Main) { + ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays) + } + } else if (cInfo.groupInfo.membership.memberCurrent) { + val gInfo = chatModel.controller.apiGetUpdatedGroupLinkData(chatRh, cInfo.groupInfo.groupId) + if (gInfo != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(chatRh, gInfo) + } + } + } + } + } } } } @@ -181,7 +237,6 @@ fun ChatView( } } val view = LocalMultiplatformView() - val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { @@ -192,6 +247,7 @@ fun ChatView( val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), + LocalSelectionManager provides selectionManager, ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -205,11 +261,11 @@ fun ChatView( val sameText = searchText.value == value // showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it // (required on Android to have this check to prevent call to search with old text) - val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null + val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null && contentFilter.value == null val c = chatModel.getChat(chatInfo.id) - if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged + if ((sameText && contentFilter.value == null) || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { - apiFindMessages(chatsCtx, c, value) + apiFindMessages(chatsCtx, c, contentFilter.value?.contentTag, value) searchText.value = value } } @@ -261,6 +317,7 @@ fun ChatView( itemIds.sorted(), questionText = questionText, forAll = canDeleteForAll, + editorial = publicGroupEditor(chatInfo), deleteMessages = { ids, forAll -> deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) { selectedChatItems.value = null @@ -323,6 +380,7 @@ fun ChatView( chatModel.groupMembers.value = emptyList() chatModel.groupMembersIndexes.value = emptyMap() chatModel.membersLoaded.value = false + ChannelRelaysModel.reset() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -353,7 +411,7 @@ fun ChatView( if (chatInfo is ChatInfo.Direct) { var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } var code: String? by remember { mutableStateOf(preloadedCode) } - KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) { + KeyChangeEffect(chatInfo.id) { contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) preloadedContactInfo = contactInfo code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second @@ -453,7 +511,7 @@ fun ChatView( } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close) } } } @@ -462,7 +520,7 @@ fun ChatView( val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, searchText.value, null, visibleItemIndexes) + apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, contentFilter.value?.contentTag, searchText.value, null, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> @@ -718,14 +776,26 @@ fun ChatView( changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, onSearchValueChanged = onSearchValueChanged, closeSearch = { + if (chatModel.openAroundItemId.value == null) { + onSearchValueChanged("") + } showSearch.value = false searchText.value = "" + contentFilter.value = null + // Update available content types when search closes + val cInfo = activeChat.value?.chatInfo + if (chatsCtx.secondaryContextFilter == null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group || cInfo is ChatInfo.Local)) { + updateAvailableContent(chatRh, activeChat, availableContent) + } }, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), showSearch = showSearch, - showCommandsMenu = showCommandsMenu + showCommandsMenu = showCommandsMenu, + contentFilter = contentFilter, + availableContent = availableContent, + searchPlaceholder = contentFilter.value?.searchPlaceholder?.let { generalGetString(it) } ) } } @@ -761,6 +831,21 @@ fun ChatView( } } +fun updateAvailableContent(chatRh: Long?, activeChat: State, availableContent: MutableState>) { + withBGApi { + val chatInfo = activeChat.value?.chatInfo + if (chatInfo == null || chatInfo !is ChatInfo.Direct && chatInfo !is ChatInfo.Group && chatInfo !is ChatInfo.Local) return@withBGApi + val types = chatModel.controller.apiGetChatContentTypes(chatRh, chatInfo.chatType, chatInfo.apiId, null) + if (activeChat.value?.chatInfo?.id != chatInfo.id) return@withBGApi + if (types == null) { + availableContent.value = ContentFilter.entries + } else { + val typeSet: Set = types.union(ContentFilter.alwaysShow) + availableContent.value = ContentFilter.entries.filter { it -> typeSet.contains(it.contentTag) } + } + } +} + private fun connectingText(chatInfo: ChatInfo): String? { return when (chatInfo) { is ChatInfo.Direct -> @@ -780,10 +865,14 @@ private fun connectingText(chatInfo: ChatInfo): String? { } is ChatInfo.Group -> - when (chatInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemUnknown -> if (chatInfo.groupInfo.preparedGroup?.connLinkStartedConnection == true) generalGetString(MR.strings.group_connection_pending) else null - GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) - else -> null + if (chatInfo.groupInfo.useRelays) { + null + } else { + when (chatInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemUnknown -> if (chatInfo.groupInfo.preparedGroup?.connLinkStartedConnection == true) generalGetString(MR.strings.group_connection_pending) else null + GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) + else -> null + } } else -> null @@ -855,7 +944,10 @@ fun ChatLayout( developerTools: Boolean, showViaProxy: Boolean, showSearch: MutableState, - showCommandsMenu: MutableState + showCommandsMenu: MutableState, + contentFilter: MutableState, + availableContent: State>, + searchPlaceholder: String? ) { val chatInfo = remember { derivedStateOf { chat.value?.chatInfo } } val scope = rememberCoroutineScope() @@ -895,17 +987,26 @@ fun ChatLayout( val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chat != null) { + val selectionManager = LocalSelectionManager.current + if (selectionManager != null) { + LaunchedEffect(selectionManager) { + snapshotFlow { selectionManager.selectionState != SelectionState.Idle } + .collect { chatsCtx.chatState.selectionActive = it } + } + } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble - CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { - override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f - }) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides object : BringIntoViewSpec { + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f + } + ) { ChatItemsList( chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, - setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, + setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, contentFilter, ) } if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { @@ -928,6 +1029,13 @@ fun ChatLayout( CommandsMenuView(chatsCtx, chat, composeState, showCommandsMenu) } } + // Copy button inside TopStart-aligned wrapper — above messages, + // behind compose (ABPL paints compose after) and toolbars (outer Box paints after ABPL) + if (appPlatform.isDesktop) { + Box(Modifier.matchParentSize()) { + SelectionCopyButton() + } + } } } if (chatsCtx.contentTag == MsgContentTag.Report) { @@ -1039,7 +1147,7 @@ fun ChatLayout( Box { if (selectedChatItems.value == null) { if (chatInfo != null) { - ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch, contentFilter, availableContent, searchPlaceholder) } } else { SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) @@ -1072,10 +1180,15 @@ fun BoxScope.ChatInfoToolbar( openGroupLink: (GroupInfo) -> Unit, changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, - showSearch: MutableState + showSearch: MutableState, + contentFilter: MutableState, + availableContent: State>, + searchPlaceholder: String? ) { val scope = rememberCoroutineScope() val showMenu = rememberSaveable { mutableStateOf(false) } + val showContentFilterMenu = rememberSaveable { mutableStateOf(false) } + val showCallMenu = rememberSaveable { mutableStateOf(false) } val onBackClicked = { if (!showSearch.value) { @@ -1083,6 +1196,7 @@ fun BoxScope.ChatInfoToolbar( } else { onSearchValueChanged("") showSearch.value = false + contentFilter.value = null } } if (appPlatform.isAndroid && chatsCtx.secondaryContextFilter == null) { @@ -1091,100 +1205,128 @@ fun BoxScope.ChatInfoToolbar( val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val menuItems = arrayListOf<@Composable () -> Unit>() val activeCall by remember { chatModel.activeCall } - if (chatInfo is ChatInfo.Local) { - barButtons.add { - IconButton( - { - showMenu.value = false - showSearch.value = true - }, enabled = chatInfo.noteFolder.ready - ) { - Icon( - painterResource(MR.images.ic_search), - stringResource(MR.strings.search_verb).capitalize(Locale.current), - tint = if (chatInfo.noteFolder.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - } - } else { - menuItems.add { - ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { - showMenu.value = false - showSearch.value = true - }) - } - } - if (chatInfo is ChatInfo.Direct && chatInfo.contact.mergedPreferences.calls.enabled.forUser) { - if (activeCall == null) { + val showContentFilterButton = availableContent.value.isNotEmpty() + val canStartCall = chatInfo is ChatInfo.Direct && + chatInfo.contact.mergedPreferences.calls.enabled.forUser && + chatInfo.contact.ready && + chatInfo.contact.active && + activeCall == null + + // Chat-type specific buttons + when (chatInfo) { + is ChatInfo.Local -> { barButtons.add { - IconButton({ - showMenu.value = false - startCall(CallMediaType.Audio) - }, enabled = chatInfo.contact.ready && chatInfo.contact.active + IconButton( + { + showMenu.value = false + showSearch.value = true + }, enabled = chatInfo.noteFolder.ready ) { Icon( - painterResource(MR.images.ic_call_500), - stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), - tint = if (chatInfo.contact.ready && chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - } - } else if (activeCall?.contact?.id == chatInfo.id && appPlatform.isDesktop) { - barButtons.add { - val call = remember { chatModel.activeCall }.value - val connectedAt = call?.connectedAt - if (connectedAt != null) { - val time = remember { mutableStateOf(durationText(0)) } - LaunchedEffect(Unit) { - while (true) { - time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt()) - delay(250) - } - } - val sp50 = with(LocalDensity.current) { 50.sp.toDp() } - Text(time.value, Modifier.widthIn(min = sp50)) - } - } - barButtons.add { - IconButton({ - showMenu.value = false - endCall() - }) { - Icon( - painterResource(MR.images.ic_call_end_filled), - null, - tint = MaterialTheme.colors.error + painterResource(MR.images.ic_search), + stringResource(MR.strings.search_verb).capitalize(Locale.current), + tint = if (chatInfo.noteFolder.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary ) } } } - if (chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) { + is ChatInfo.Direct -> { + if (activeCall?.contact?.id == chatInfo.id) { + if (appPlatform.isDesktop) { + barButtons.add { + val call = remember { chatModel.activeCall }.value + val connectedAt = call?.connectedAt + if (connectedAt != null) { + val time = remember { mutableStateOf(durationText(0)) } + LaunchedEffect(Unit) { + while (true) { + time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt()) + delay(250) + } + } + val sp50 = with(LocalDensity.current) { 50.sp.toDp() } + Text(time.value, Modifier.widthIn(min = sp50)) + } + } + } + barButtons.add { + IconButton({ + showMenu.value = false + endCall() + }) { + Icon( + painterResource(MR.images.ic_call_end_filled), + null, + tint = MaterialTheme.colors.error + ) + } + } + } + // Call button always in toolbar; tap opens Audio/Video call submenu + if (canStartCall) { + barButtons.add(0) { + IconButton({ showCallMenu.value = true }) { + Icon(painterResource(MR.images.ic_call_500), null, tint = MaterialTheme.colors.primary) + } + } + } menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { + ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { showMenu.value = false - startCall(CallMediaType.Video) + showSearch.value = true }) } } - } else if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canAddMembers) { - if (!chatInfo.incognito) { - barButtons.add { - IconButton({ - showMenu.value = false - addMembers(chatInfo.groupInfo) - }) { - Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + is ChatInfo.Group -> { + // Add members / group link moved to menu + if (chatInfo.groupInfo.canAddMembers) { + if (!chatInfo.incognito && !chatInfo.groupInfo.useRelays) { + menuItems.add { + ItemAction(stringResource(MR.strings.icon_descr_add_members), painterResource(MR.images.ic_person_add_500), onClick = { + showMenu.value = false + addMembers(chatInfo.groupInfo) + }) + } + } else { + menuItems.add { + ItemAction( + stringResource(if (chatInfo.groupInfo.useRelays) MR.strings.channel_link else MR.strings.group_link), + painterResource(if (chatInfo.groupInfo.useRelays) MR.images.ic_link else MR.images.ic_add_link), + onClick = { + showMenu.value = false + openGroupLink(chatInfo.groupInfo) + } + ) + } } } - } else { - barButtons.add { - IconButton({ + menuItems.add { + ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { showMenu.value = false - openGroupLink(chatInfo.groupInfo) - }) { - Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary) - } + showSearch.value = true + }) + } + } + else -> {} + } + + // Content filter button: always in bar on desktop and for groups; on Android for direct chats it + // goes into the three-dots menu UNLESS calls are unavailable, in which case it appears in the bar. + // Must be after chat-type buttons so call buttons appear before filter during active call. + if (showContentFilterButton && (appPlatform.isDesktop || chatInfo is ChatInfo.Group || + (appPlatform.isAndroid && chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null))) { + val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready + barButtons.add { + IconButton( + { showContentFilterMenu.value = true }, + enabled = enabled + ) { + Icon( + painterResource(MR.images.ic_photo_library), + null, + tint = MaterialTheme.colors.primary + ) } } } @@ -1209,6 +1351,53 @@ fun BoxScope.ChatInfoToolbar( } } + // Android only: for direct/local chats where the filter bar button is NOT shown, filter options go in the three-dots menu separated by a divider + if (appPlatform.isAndroid && chatInfo !is ChatInfo.Group && showContentFilterButton && + !(chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null)) { + menuItems.add { Divider() } + availableContent.value.forEach { filter -> + menuItems.add { + val isSelected = contentFilter.value == filter + ItemAction( + stringResource(filter.label), + painterResource(if (isSelected) filter.iconFilled else filter.icon), + color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified, + onClick = { + showMenu.value = false + if (contentFilter.value == filter) return@ItemAction + contentFilter.value = filter + showSearch.value = true + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, filter.contentTag, "") + } + } + } + ) + } + } + if (showSearch.value) { + menuItems.add { + ItemAction( + stringResource(MR.strings.content_filter_all_messages), + painterResource(MR.images.ic_forum), + onClick = { + showMenu.value = false + contentFilter.value = null + showSearch.value = false + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, null, "") + } + } + } + ) + } + } + } + if (menuItems.isNotEmpty()) { barButtons.add { IconButton({ showMenu.value = true }) { @@ -1218,13 +1407,27 @@ fun BoxScope.ChatInfoToolbar( } val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val searchTrailingContent: @Composable (() -> Unit)? = if (showContentFilterButton) {{ + IconButton({ showContentFilterMenu.value = true }) { + Icon( + painterResource(if (contentFilter.value == null) MR.images.ic_photo_library else MR.images.ic_photo_library_filled), + null, + Modifier.padding(4.dp), + tint = MaterialTheme.colors.primary + ) + } + }} else null + DefaultAppBar( navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } }, title = { ChatInfoToolbarTitle(chatInfo) }, onTitleClick = if (chatInfo is ChatInfo.Local) null else info, showSearch = showSearch.value, + searchAlwaysVisible = contentFilter.value != null, onTop = !oneHandUI.value || !chatBottomBar.value, + searchPlaceholder = searchPlaceholder, onSearchValueChanged = onSearchValueChanged, + searchTrailingContent = searchTrailingContent, buttons = { barButtons.forEach { it() } } ) Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) { @@ -1245,9 +1448,104 @@ fun BoxScope.ChatInfoToolbar( menuItems.forEach { it() } } } + val contentFilterWidth = remember { mutableStateOf(250.dp) } + val contentFilterHeight = remember { mutableStateOf(0.dp) } + DefaultDropdownMenu( + showContentFilterMenu, + modifier = Modifier.onSizeChanged { with(density) { + contentFilterWidth.value = it.width.toDp().coerceAtLeast(250.dp) + if (oneHandUI.value && chatBottomBar.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) contentFilterHeight.value = it.height.toDp() + } }, + offset = DpOffset(-contentFilterWidth.value, if (oneHandUI.value && chatBottomBar.value) -contentFilterHeight.value else AppBarHeight) + ) { + val contentFilterMenuItems: List<@Composable () -> Unit> = buildList { + availableContent.value.forEach { filter -> + val isSelected = contentFilter.value == filter + add { + ItemAction( + stringResource(filter.label), + painterResource(if (isSelected) filter.iconFilled else filter.icon), + color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified, + onClick = { + showContentFilterMenu.value = false + if (contentFilter.value == filter) return@ItemAction + contentFilter.value = filter + showSearch.value = true + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, filter.contentTag, "") + } + } + } + ) + } + } + if (showSearch.value) { + add { + ItemAction( + stringResource(MR.strings.content_filter_all_messages), + painterResource(MR.images.ic_forum), + onClick = { + showContentFilterMenu.value = false + contentFilter.value = null + showSearch.value = false + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, null, "") + } + } + } + ) + } + } + } + if (oneHandUI.value && chatBottomBar.value) { + contentFilterMenuItems.asReversed().forEach { it() } + } else { + contentFilterMenuItems.forEach { it() } + } + } + val callMenuWidth = remember { mutableStateOf(250.dp) } + val callMenuHeight = remember { mutableStateOf(0.dp) } + DefaultDropdownMenu( + showCallMenu, + modifier = Modifier.onSizeChanged { with(density) { + callMenuWidth.value = it.width.toDp().coerceAtLeast(250.dp) + if (oneHandUI.value && chatBottomBar.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) callMenuHeight.value = it.height.toDp() + } }, + offset = DpOffset(-callMenuWidth.value, if (oneHandUI.value && chatBottomBar.value) -callMenuHeight.value else AppBarHeight) + ) { + if (chatInfo is ChatInfo.Direct) { + val callMenuItems: List<@Composable () -> Unit> = buildList { + add { + ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { + showCallMenu.value = false + startCall(CallMediaType.Audio) + }) + } + add { + ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { + showCallMenu.value = false + startCall(CallMediaType.Video) + }) + } + } + if (oneHandUI.value && chatBottomBar.value) { + callMenuItems.asReversed().forEach { it() } + } else { + callMenuItems.forEach { it() } + } + } + } } } +fun subscriberCountStr(count: Long): String = + if (count == 1L) String.format(generalGetString(MR.strings.channel_subscriber_count_singular), count) + else String.format(generalGetString(MR.strings.channel_subscriber_count_plural), count) + @Composable fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { Row( @@ -1277,10 +1575,63 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo maxLines = 1, overflow = TextOverflow.Ellipsis ) } + val channelSubscriberCount = (cInfo as? ChatInfo.Group)?.let { g -> + if (g.groupInfo.useRelays) g.groupInfo.groupSummary.publicMemberCount?.takeIf { it > 0 } else null + } + if (channelSubscriberCount != null) { + Text( + subscriberCountStr(channelSubscriberCount), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } + } + val chatSubStatus = chatModel.chatSubStatus.value + if ( + cInfo is ChatInfo.Direct && + cInfo.contact.ready && + cInfo.contact.active && + chatSubStatus != null && + chatSubStatus != SubscriptionStatus.Active + ) { + Box( + Modifier.padding(start = 10.dp) + ) { + SubStatusView(chatSubStatus) + } } } } +@Composable +fun SubStatusView(status: SubscriptionStatus) { + when (status) { + SubscriptionStatus.Active -> + Box {} + SubscriptionStatus.Pending -> + SubProgressView() + is SubscriptionStatus.Removed, SubscriptionStatus.NoSub -> + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(19.sp.toDp()) + ) + } +} + +@Composable +private fun SubProgressView() { + CircularProgressIndicator( + Modifier + .size(15.sp.toDp()), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) +} + @Composable private fun SupportChatsCountToolbar( chatInfo: ChatInfo, @@ -1407,7 +1758,8 @@ fun BoxScope.ChatItemsList( closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, - showViaProxy: Boolean + showViaProxy: Boolean, + contentFilter: State = remember { mutableStateOf(null) } ) { val chatInfo = chat.chatInfo val loadingTopItems = remember { mutableStateOf(false) } @@ -1427,7 +1779,7 @@ fun BoxScope.ChatItemsList( } } val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } - val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } + val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() || contentFilter.value != null } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state val mergedItems = remember { @@ -1462,7 +1814,17 @@ fun BoxScope.ChatItemsList( val hoveredItemId = remember { mutableStateOf(null as Long?) } val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { val openAroundItemId = chatModel.openAroundItemId.value - val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } + val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: run { + // scroll to first unread after last viewed item (items reversed: 0 = newest) + val viewedIdx = mergedItems.value.items.indexOfFirst { !it.hasUnread() } + if (viewedIdx > 0) { + viewedIdx - 1 + } else if (viewedIdx < 0) { + mergedItems.value.items.indexOfLast { it.hasUnread() } + } else { + 0 // viewed is bottom item, scroll to bottom + } + } val reportsState = reportsListState if (openAroundItemId != null) { highlightedItems.value += openAroundItemId @@ -1505,7 +1867,7 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val scope = rememberCoroutineScope() val scrollToItem: (Long) -> Unit = remember { - scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + scrollToItem(chatsCtx, remoteHostIdUpdated, searchValue, contentFilter, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, scrollToItemId) } if (chatsCtx.secondaryContextFilter == null) { @@ -1558,7 +1920,7 @@ fun BoxScope.ChatItemsList( } @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f) { tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { @@ -1572,7 +1934,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp, swipeOffset = swipeOffset) } } @@ -1592,7 +1954,7 @@ fun BoxScope.ChatItemsList( } false } - val swipeableModifier = SwipeToDismissModifier( + val swipeableModifier = if (appPlatform.isDesktop || !chatInfo.sendMsgEnabled) Modifier else SwipeToDismissModifier( state = dismissState, directions = setOf(DismissDirection.EndToStart), swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, @@ -1693,7 +2055,7 @@ fun BoxScope.ChatItemsList( MemberImage(member) } Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { - ChatItemViewShortHand(cItem, itemSeparation, range, false) + ChatItemViewShortHand(cItem, itemSeparation, range, false, dismissState.offset.value) } } } @@ -1707,6 +2069,89 @@ fun BoxScope.ChatItemsList( Item() } } + } else { + ChatItemBox { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + Row( + Modifier + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) + .then(swipeableOrSelectionModifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) + } + } + } + } else if (cItem.chatDir is CIDirection.ChannelRcv) { + if (showAvatar) { + Column( + Modifier + .padding(top = 8.dp) + .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .fillMaxWidth() + .then(swipeableModifier), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + @Composable + fun ChannelNameAndRole() { + Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { + Text( + chatInfo.groupInfo.chatViewName, + Modifier + .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) + .weight(1f, false), + fontSize = 13.5.sp, + color = MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + Text( + generalGetString(MR.strings.channel_role_label), + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), + fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, + maxLines = 1 + ) + } + } + + @Composable + fun Item() { + ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { + androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier, cItem.id, selectedChatItems) + } + Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + Box(Modifier.clickable { showChatInfo() }) { + ProfileImage( + MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier, + chatInfo.groupInfo.image, + chatInfo.groupInfo.chatIconName, + backgroundColor = MaterialTheme.colors.background + ) + } + Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { + ChatItemViewShortHand(cItem, itemSeparation, range, false) + } + } + } + } + if (cItem.content.showMemberName) { + DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { + ChannelNameAndRole() + Item() + } + } else { + Item() + } + } } else { ChatItemBox { AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { @@ -1733,7 +2178,7 @@ fun BoxScope.ChatItemsList( .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .then(if (selectionVisible) Modifier else swipeableModifier) ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) } } } @@ -1751,7 +2196,7 @@ fun BoxScope.ChatItemsList( .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) } } } @@ -1795,13 +2240,14 @@ fun BoxScope.ChatItemsList( val groupInfo = chatInfo.groupInfo when (groupInfo.businessChat?.chatType) { null -> { + val isChannel = groupInfo.useRelays if (groupInfo.nextConnectPrepared) { - generalGetString(MR.strings.chat_banner_join_group) + generalGetString(if (isChannel) MR.strings.chat_banner_join_channel else MR.strings.chat_banner_join_group) } else { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> generalGetString(MR.strings.chat_banner_join_group) - GroupMemberStatus.MemCreator -> generalGetString(MR.strings.chat_banner_your_group) - else -> generalGetString(MR.strings.chat_banner_group) + GroupMemberStatus.MemInvited -> generalGetString(if (isChannel) MR.strings.chat_banner_join_channel else MR.strings.chat_banner_join_group) + GroupMemberStatus.MemCreator -> generalGetString(if (isChannel) MR.strings.chat_banner_your_channel else MR.strings.chat_banner_your_group) + else -> generalGetString(if (isChannel) MR.strings.chat_banner_channel else MR.strings.chat_banner_group) } } } @@ -1893,8 +2339,11 @@ fun BoxScope.ChatItemsList( } } + val manager = LocalSelectionManager.current + val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, revealedItems, linkMode) else Modifier + LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), + modifier.align(Alignment.BottomCenter), state = listState.value, contentPadding = PaddingValues( top = topPaddingToContent, @@ -1948,8 +2397,10 @@ fun BoxScope.ChatItemsList( itemSeparation = getItemSeparation(item, null) prevItemSeparationLargeGap = false } - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { - if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) { + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + } } if (last != null) { @@ -2645,7 +3096,10 @@ private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State, de } private fun scrollToItem( + chatsCtx: ChatModel.ChatsContext, + remoteHostId: State, searchValue: State, + contentFilter: State, loadingMoreItems: MutableState, animatedScrollingInProgress: MutableState, highlightedItems: MutableState>, @@ -2660,8 +3114,13 @@ private fun scrollToItem( withApi { try { var index = mergedItems.value.indexInParentItems[itemId] ?: -1 - // Don't try to load messages while in search - if (index == -1 && searchValue.value.isNotBlank()) return@withApi + if (index == -1 && (searchValue.value.isNotBlank() || contentFilter.value != null)) { + val ci = chatInfo.value + apiLoadMessages(chatsCtx, remoteHostId.value, ci.chatType, ci.apiId, + ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2), + openAroundItemId = itemId) + return@withApi + } // setting it to 'loading' even if the item is loaded because in rare cases when the resulting item is near the top, scrolling to // it will trigger loading more items and will scroll to incorrect position (because of trimming) loadingMoreItems.value = true @@ -2746,7 +3205,7 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: ( val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId) close?.invoke() ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null) + GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } } } @@ -2893,7 +3352,9 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List +fun publicGroupEditor(chatInfo: ChatInfo): Boolean = + chatInfo is ChatInfo.Group && chatInfo.groupInfo.useRelays && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator + private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds()) private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { @@ -3255,6 +3718,8 @@ private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSepa val sameMemberAndDirection = if (prevItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == prevItem.chatDir.groupMember.groupMemberId + } else if (chatItem.chatDir is CIDirection.ChannelRcv && prevItem.chatDir is CIDirection.ChannelRcv) { + true } else chatItem.chatDir.sent == prevItem.chatDir.sent val largeGap = !sameMemberAndDirection || (abs(prevItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) @@ -3272,12 +3737,26 @@ private fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): val sameMemberAndDirection = if (nextItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == nextItem.chatDir.groupMember.groupMemberId + } else if (chatItem.chatDir is CIDirection.ChannelRcv && nextItem.chatDir is CIDirection.ChannelRcv) { + true } else chatItem.chatDir.sent == nextItem.chatDir.sent return !sameMemberAndDirection || (abs(nextItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) } -private fun shouldShowAvatar(current: ChatItem, older: ChatItem?) = - current.chatDir is CIDirection.GroupRcv && (older == null || (older.chatDir !is CIDirection.GroupRcv || older.chatDir.groupMember.memberId != current.chatDir.groupMember.memberId)) +private fun shouldShowAvatar(current: ChatItem, older: ChatItem?): Boolean { + val oldIsGroupRcv = older?.chatDir is CIDirection.GroupRcv || older?.chatDir is CIDirection.ChannelRcv + val sameMember = when { + older?.chatDir is CIDirection.GroupRcv && current.chatDir is CIDirection.GroupRcv -> + older.chatDir.groupMember.memberId == current.chatDir.groupMember.memberId + older?.chatDir is CIDirection.ChannelRcv && current.chatDir is CIDirection.ChannelRcv -> true + else -> false + } + return when { + current.chatDir is CIDirection.GroupRcv -> older == null || !oldIsGroupRcv || !sameMember + current.chatDir is CIDirection.ChannelRcv -> older == null || !oldIsGroupRcv || !sameMember + else -> false + } +} @Preview/*( @@ -3359,7 +3838,10 @@ fun PreviewChatLayout() { developerTools = false, showViaProxy = false, showSearch = remember { mutableStateOf(false) }, - showCommandsMenu = remember { mutableStateOf(false) } + showCommandsMenu = remember { mutableStateOf(false) }, + contentFilter = remember { mutableStateOf(null) }, + availableContent = remember { mutableStateOf(ContentFilter.initialList) }, + searchPlaceholder = null ) } } @@ -3439,7 +3921,30 @@ fun PreviewGroupChatLayout() { developerTools = false, showViaProxy = false, showSearch = remember { mutableStateOf(false) }, - showCommandsMenu = remember { mutableStateOf(false) } + showCommandsMenu = remember { mutableStateOf(false) }, + contentFilter = remember { mutableStateOf(null) }, + availableContent = remember { mutableStateOf(ContentFilter.initialList) }, + searchPlaceholder = null ) } } + +enum class ContentFilter( + val contentTag: MsgContentTag, + val label: StringResource, + val searchPlaceholder: StringResource, + val icon: ImageResource, + val iconFilled: ImageResource +) { + Images(MsgContentTag.Image, MR.strings.content_filter_images, MR.strings.placeholder_search_images, MR.images.ic_image, MR.images.ic_image_filled), + Videos(MsgContentTag.Video, MR.strings.content_filter_videos, MR.strings.placeholder_search_videos, MR.images.ic_videocam, MR.images.ic_videocam_filled), + Voice(MsgContentTag.Voice, MR.strings.content_filter_voice_messages, MR.strings.placeholder_search_voice_messages, MR.images.ic_mic, MR.images.ic_mic_filled), + Files(MsgContentTag.File, MR.strings.content_filter_files, MR.strings.placeholder_search_files, MR.images.ic_draft, MR.images.ic_draft_filled), + Links(MsgContentTag.Link, MR.strings.content_filter_links, MR.strings.placeholder_search_links, MR.images.ic_link, MR.images.ic_link); + + companion object { + val alwaysShow: Set = setOf(MsgContentTag.Image, MsgContentTag.Link) + + val initialList: List = listOf(ContentFilter.Images, ContentFilter.Files, ContentFilter.Links) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt new file mode 100644 index 0000000000..14edea3ed6 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt @@ -0,0 +1,53 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.MsgChatLink +import chat.simplex.common.ui.theme.appColors +import chat.simplex.common.views.helpers.ProfileImage +import dev.icerock.moko.resources.compose.painterResource +import chat.simplex.res.MR + +@Composable +fun ComposeChatLinkView( + chatLink: MsgChatLink, + cancelEnabled: Boolean, + cancelPreview: () -> Unit +) { + val sentColor = MaterialTheme.appColors.sentMessage + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .background(sentColor) + .padding(start = 8.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileImage(size = 54.dp, image = chatLink.image, icon = chatLink.iconRes) + Column( + Modifier.fillMaxWidth().weight(1f).padding(horizontal = 8.dp) + ) { + Text(chatLink.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) + chatLink.shortDescription?.let { descr -> + Text( + descr, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + ) + } + } + if (cancelEnabled) { + IconButton(onClick = cancelPreview) { + Icon(painterResource(MR.images.ic_close), null, tint = MaterialTheme.colors.primary) + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt index 5c8ac3bc5d..21799ff820 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt @@ -162,7 +162,6 @@ fun acceptMemberContact( chatModel.chatsContext.updateContact(rhId, contact) inProgress?.value = false } - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) close?.invoke(chat) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index 3c3f99ad94..be82a5d2d3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -45,7 +45,7 @@ fun ComposeContextPendingMemberActionsView( .fillMaxHeight() .weight(1F) .clickable { - rejectMemberDialog(rhId, member, chatModel, close = { ModalManager.end.closeModal() }) + rejectMemberDialog(rhId, groupInfo, member, chatModel, close = { ModalManager.end.closeModal() }) }, verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally @@ -70,12 +70,12 @@ fun ComposeContextPendingMemberActionsView( } } -fun rejectMemberDialog(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun rejectMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.reject_pending_member_alert_title), confirmText = generalGetString(MR.strings.reject_pending_member_button), onConfirm = { - removeMember(rhId, member, chatModel, close) + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) }, destructive = true, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index b01d07d9b8..d874079238 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1,6 +1,7 @@ @file:UseSerializers(UriSerializer::class, ComposeMessageSerializer::class) package chat.simplex.common.views.chat +import SectionItemView import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -19,6 +20,8 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -30,8 +33,13 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.group.hostFromRelayLink +import chat.simplex.common.views.chat.group.relayConnStatus import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.RelayProgressIndicator +import chat.simplex.common.views.newchat.RelayStatusIndicator +import chat.simplex.common.views.newchat.relayDisplayName import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.* @@ -47,10 +55,12 @@ import kotlin.math.min const val MAX_NUMBER_OF_MENTIONS = 3 +// Spec: spec/client/compose.md#ComposePreview @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() + @Serializable class ChatLinkPreview(val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig? = null): ComposePreview() @Serializable class MediaPreview(val images: List, val content: List): ComposePreview() @Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() @Serializable class FilePreview(val fileName: String, val uri: URI): ComposePreview() @@ -92,6 +102,7 @@ object ComposeMessageSerializer : KSerializer { decoder.decodeLong().let { value -> TextRange(unpackInt1(value), unpackInt2(value)) } } +// Spec: spec/client/compose.md#ComposeState @Serializable data class ComposeState( val message: ComposeMessage = ComposeMessage(), @@ -105,7 +116,12 @@ data class ComposeState( val mentions: MentionedMembers = emptyMap() ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( - ComposeMessage(editingItem.content.text), + ComposeMessage( + when (val mc = editingItem.content.msgContent) { + is MsgContent.MCChat -> stripTextLink(mc.text, mc.chatLink.connLinkStr) + else -> editingItem.content.text + } + ), editingItem.formattedText ?: FormattedText.plain(editingItem.content.text), liveMessage, chatItemPreview(editingItem), @@ -156,6 +172,7 @@ data class ComposeState( val hasContent = when (preview) { is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true + is ComposePreview.ChatLinkPreview -> true is ComposePreview.FilePreview -> true else -> !whitespaceOnly || forwarding || liveMessage != null || submittingValidReport } @@ -167,6 +184,7 @@ data class ComposeState( val linkPreviewAllowed: Boolean get() = when (preview) { + is ComposePreview.ChatLinkPreview -> false is ComposePreview.MediaPreview -> false is ComposePreview.VoicePreview -> false is ComposePreview.FilePreview -> false @@ -193,6 +211,7 @@ data class ComposeState( get() = when (preview) { ComposePreview.NoPreview -> false is ComposePreview.CLinkPreview -> false + is ComposePreview.ChatLinkPreview -> false is ComposePreview.MediaPreview -> preview.content.isNotEmpty() is ComposePreview.VoicePreview -> false is ComposePreview.FilePreview -> true @@ -259,6 +278,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { } } +// Spec: spec/client/compose.md#AttachmentSelection @Composable expect fun AttachmentSelection( composeState: MutableState, @@ -341,6 +361,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: } } +// Spec: spec/client/compose.md#ComposeView @Composable fun ComposeView( rhId: Long?, @@ -381,20 +402,46 @@ fun ComposeView( val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } + suspend fun fetchAndUpdateLinkPreview(url: String) { + composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) + val lp = getLinkPreview(url) + if (lp != null && pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp)) + pendingLinkUrl.value = null + } else if (pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + pendingLinkUrl.value = null + } + } + fun loadLinkPreview(url: String, wait: Long? = null) { if (pendingLinkUrl.value == url) { - composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) withLongRunningApi(slow = 60_000) { if (wait != null) delay(wait) - val lp = getLinkPreview(url) - if (lp != null && pendingLinkUrl.value == url) { - chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false) // to avoid showing alert to current users, show alert in v6.5 - composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp)) - pendingLinkUrl.value = null - } else if (pendingLinkUrl.value == url) { - composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - pendingLinkUrl.value = null + if (pendingLinkUrl.value != url) return@withLongRunningApi + if (chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.get()) { + val socksEnabled = chatModel.controller.appPrefs.networkUseSocksProxy.get() + showLinkPreviewsConfirmAlert(socksEnabled) { enable -> + if (enable != null) { + chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false) + chatModel.controller.appPrefs.privacyLinkPreviews.set(enable) + if (enable) { + withLongRunningApi(slow = 60_000) { fetchAndUpdateLinkPreview(url) } + } else if (pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + pendingLinkUrl.value = null + } + } else { + cancelledLinks.add(url) + if (pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + pendingLinkUrl.value = null + } + } + } + return@withLongRunningApi } + fetchAndUpdateLinkPreview(url) } } } @@ -459,6 +506,7 @@ fun ComposeView( is SharedContent.File -> listOf(shared.uri.toString()) is SharedContent.Text -> emptyList() is SharedContent.Forward -> emptyList() + is SharedContent.ChatLink -> emptyList() } // When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing chatModel.filesToDelete.removeAll { file -> @@ -486,6 +534,7 @@ fun ComposeView( type = cInfo.chatType, id = cInfo.apiId, scope = cInfo.groupChatScope(), + sendAsGroup = cInfo.sendAsGroup, live = live, ttl = ttl, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) @@ -533,7 +582,6 @@ fun ComposeView( withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) clearState() - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } } else { composeState.value = composeState.value.copy(inProgress = false) @@ -554,7 +602,6 @@ fun ComposeView( withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) clearState() - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } } else { composeState.value = composeState.value.copy(inProgress = false) @@ -586,15 +633,19 @@ fun ComposeView( val mc = checkLinkPreview() sending() val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() - val groupInfo = chatModel.controller.apiConnectPreparedGroup( + val result = chatModel.controller.apiConnectPreparedGroup( rh = chat.remoteHostId, groupId = chat.chatInfo.apiId, incognito = incognito, msg = mc ) - if (groupInfo != null) { + if (result != null) { + val (groupInfo, relayResults) = result withContext(Dispatchers.Main) { chatsCtx.updateGroup(chat.remoteHostId, groupInfo) + chatModel.channelRelayHostnames.remove(groupInfo.groupId) + chatModel.groupMembers.value = relayResults.map { it.relayMember } + chatModel.populateGroupMembersIndexes() clearState() } } else { @@ -614,6 +665,7 @@ fun ComposeView( toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, toScope = chat.chatInfo.groupChatScope(), + sendAsGroup = chat.chatInfo.sendAsGroup, fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, fromScope = fromChatInfo.groupChatScope(), @@ -659,8 +711,11 @@ fun ComposeView( is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) is MsgContent.MCFile -> MsgContent.MCFile(msgText) is MsgContent.MCReport -> MsgContent.MCReport(msgText, reason = msgContent.reason) - // TODO [short links] update chat link - is MsgContent.MCChat -> MsgContent.MCChat(msgText, chatLink = msgContent.chatLink) + is MsgContent.MCChat -> { + val linkStr = msgContent.chatLink.connLinkStr + val text = if (msgText.isEmpty()) linkStr else "$msgText\n$linkStr" + MsgContent.MCChat(text, chatLink = msgContent.chatLink, ownerSig = msgContent.ownerSig) + } is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) } } @@ -747,6 +802,11 @@ fun ComposeView( when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) + is ComposePreview.ChatLinkPreview -> { + val linkStr = preview.chatLink.connLinkStr + val text = if (msgText.isEmpty()) linkStr else "$msgText\n$linkStr" + msgs.add(MsgContent.MCChat(text, preview.chatLink, preview.ownerSig)) + } is ComposePreview.MediaPreview -> { // TODO batch send: batch media previews preview.content.forEachIndexed { index, it -> @@ -1047,6 +1107,11 @@ fun ComposeView( ::cancelLinkPreview, cancelEnabled = !composeState.value.inProgress ) + is ComposePreview.ChatLinkPreview -> ComposeChatLinkView( + chatLink = preview.chatLink, + cancelEnabled = !composeState.value.inProgress, + cancelPreview = { composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) } + ) is ComposePreview.MediaPreview -> ComposeImageView( preview, ::cancelImages, @@ -1115,8 +1180,10 @@ fun ComposeView( } } - val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled) - val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason) + val ownerRelayState = ownerRelayState(chat, chatModel) + + val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason(ownerRelayState?.noActiveRelays == true)) + val sendMsgEnabled = rememberUpdatedState(userCantSendReason.value == null) val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) @Composable @@ -1237,6 +1304,8 @@ fun ComposeView( composeState.value = cs.copy(inProgress = false, progressByTimeout = false) } else if (!cs.empty) { if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + recState.value = RecordingState.NotStarted + RecorderInterface.stopRecording?.invoke() composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) } if (saveLastDraft) { @@ -1294,7 +1363,7 @@ fun ComposeView( sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = placeholder ?: composeState.value.placeholder, + placeholder = if (userCantSendReason.value != null) "" else placeholder ?: composeState.value.placeholder, sendMessage = { ttl -> sendMessage(ttl) resetLinkPreview() @@ -1351,7 +1420,7 @@ fun ComposeView( icon: ImageResource, connect: () -> Unit ) { - var modifier = Modifier.height(60.dp).fillMaxWidth() + var modifier = Modifier.height(57.dp).fillMaxWidth() modifier = if (composeState.value.inProgress) modifier else modifier.clickable(onClick = { connect() }) Box( modifier, @@ -1372,7 +1441,7 @@ fun ComposeView( color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } - if (composeState.value.progressByTimeout) { + if (composeState.value.progressByTimeout && chat.chatInfo.groupInfo_?.useRelays != true) { Box( Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING_HALF), contentAlignment = Alignment.CenterEnd @@ -1425,6 +1494,22 @@ fun ComposeView( contextItem = ComposeContextItem.ForwardingItems(shared.chatItems, shared.fromChatInfo), preview = if (composeState.value.preview is ComposePreview.CLinkPreview) composeState.value.preview else ComposePreview.NoPreview ) + is SharedContent.ChatLink -> { + val cInfo = chat.chatInfo + val sendAsGroup = cInfo.sendAsGroup + withBGApi { + val mc = chatModel.controller.apiShareChatMsgContent( + chat.remoteHostId, ChatType.Group, shared.groupInfo.groupId, + cInfo.chatType, cInfo.apiId, + cInfo.groupChatScope(), sendAsGroup + ) + if (mc is MsgContent.MCChat) { + composeState.value = composeState.value.copy( + preview = ComposePreview.ChatLinkPreview(mc.chatLink, mc.ownerSig) + ) + } + } + } null -> {} } chatModel.sharedContent.value = null @@ -1440,9 +1525,11 @@ fun ComposeView( composeState.value = composeState.value.copy(progressByTimeout = newProgressByTimeout) } + val relayListExpanded = remember { mutableStateOf(false) } + Column { val currentUser = chatModel.currentUser.value - if (chat.chatInfo.nextConnectPrepared && currentUser != null) { + if (chat.chatInfo.nextConnectPrepared && !composeState.value.inProgress && currentUser != null) { ComposeContextProfilePickerView( rhId = rhId, chat = chat, @@ -1450,6 +1537,33 @@ fun ComposeView( ) } + val gInfo = (chat.chatInfo as? ChatInfo.Group)?.groupInfo + if (gInfo != null && gInfo.useRelays + && gInfo.membership.memberStatus !in listOf(GroupMemberStatus.MemRejected, GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) + ) { + if (gInfo.membership.memberRole == GroupMemberRole.Owner) { + ownerRelayState?.let { s -> + if (s.relays.isEmpty() || s.activeCount < s.relays.size) { + OwnerChannelRelayBar(chatModel, s.relays, s.activeCount, s.failedCount, s.removedCount, relayListExpanded) + } + } + } else { + val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted() + val relayMembers = chatModel.groupMembers.value + .filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus !in listOf(GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) } + .sortedBy { hostFromRelayLink(it.relayLink ?: "") } + val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress + val removedCount = relayMembers.count { relayMemberRemoved(it.memberStatus) } + val connectedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connStatus == ConnStatus.Ready && it.activeConn?.connFailedErr == null } + val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null } + val resolvedCount = connectedCount + removedCount + failedCount + val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size + if (total == 0 || removedCount + failedCount > 0 || resolvedCount < total) { + SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, showProgress, relayListExpanded) + } + } + } + if ( chat.chatInfo is ChatInfo.Group && chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext @@ -1504,9 +1618,10 @@ fun ComposeView( Divider() if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) { if (chat.chatInfo.groupInfo.businessChat == null) { + val isChannel = chat.chatInfo.groupInfo.useRelays ConnectButtonView( - text = stringResource(MR.strings.compose_view_join_group), - icon = MR.images.ic_group_filled, + text = stringResource(if (isChannel) MR.strings.compose_view_join_channel else MR.strings.compose_view_join_group), + icon = if (isChannel) MR.images.ic_bigtop_updates else MR.images.ic_group_filled, connect = { withApi { connectPreparedGroup() } } ) } else { @@ -1577,9 +1692,339 @@ fun ComposeView( } else { Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { AttachmentAndCommandsButtons() - SendMsgView_(disableSendButton = disableSendButton) + val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi -> + if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast) + else null + } + SendMsgView_(disableSendButton = disableSendButton, placeholder = broadcastPlaceholder) } } } } } + +private fun showLinkPreviewsConfirmAlert(socksEnabled: Boolean, onChoice: (Boolean?) -> Unit) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.link_previews_alert_title), + text = AnnotatedString( + if (socksEnabled) + generalGetString(MR.strings.link_previews_alert_desc) + "\n\n" + generalGetString(MR.strings.link_previews_alert_desc_socks) + else + generalGetString(MR.strings.link_previews_alert_desc) + ), + onDismissRequest = { onChoice(null) }, + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + onChoice(false) + }) { + Text(stringResource(MR.strings.link_previews_alert_disable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + onChoice(true) + }) { + Text(stringResource(MR.strings.link_previews_alert_enable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = if (socksEnabled) MaterialTheme.colors.primary else Color.Red) + } +// SectionItemView({ +// AlertManager.shared.hideAlert() +// onChoice(null) +// }) { +// Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.onBackground) +// } + } + } + ) +} + +@Composable +private fun OwnerChannelRelayBar( + chatModel: ChatModel, + relays: List, + activeCount: Int, + failedCount: Int, + removedCount: Int, + relayListExpanded: MutableState +) { + val total = relays.size + val allBroken = activeCount == 0 && (failedCount + removedCount) == total + val members = chatModel.groupMembers.value.associateBy { it.groupMemberId } + val sorted = relays.map { relay -> relay to members[relay.groupMemberId] }.sortedBy { relayDisplayName(it.first) } + Column(Modifier.background(MaterialTheme.colors.surface)) { + RelayBarHeader(relayListExpanded) { + if (!allBroken && activeCount + failedCount + removedCount < total) { + RelayProgressIndicator(active = activeCount, total = total) + } + if (total == 0) { + Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary) + Icon( + painterResource(MR.images.ic_warning), + contentDescription = null, + tint = WarningOrange, + modifier = Modifier.size(18.dp) + ) + } else if (allBroken) { + val statusText = if (removedCount == total) { + generalGetString(MR.strings.relay_bar_all_relays_removed) + } else if (failedCount == total) { + generalGetString(MR.strings.relay_bar_all_relays_failed) + } else { + generalGetString(MR.strings.relay_bar_no_active_relays) + } + Text(statusText, color = MaterialTheme.colors.secondary) + Icon( + painterResource(MR.images.ic_warning), + contentDescription = null, + tint = WarningOrange, + modifier = Modifier.size(18.dp) + ) + } else if (activeCount + failedCount + removedCount >= total) { + val statusText = if (failedCount > 0 && removedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_relays_not_active), failedCount + removedCount) + } else if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_relays_failed), failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_relays_removed), removedCount) + } + Text(statusText, color = MaterialTheme.colors.secondary) + } else { + val statusText = if (failedCount > 0 && removedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_errors), activeCount, total, failedCount + removedCount) + } else if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_failures), activeCount, total, failedCount) + } else if (removedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_removed), activeCount, total, removedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_active), activeCount, total) + } + Text(statusText, color = MaterialTheme.colors.secondary) + } + } + if (relayListExpanded.value) { + if (allBroken) { + Text( + generalGetString(MR.strings.relay_bar_owner_no_delivery), + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = DEFAULT_PADDING, bottom = 4.dp), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + } + sorted.forEach { (relay, m) -> + val failedErr = m?.activeConn?.connFailedErr + RelayBarDetailRow( + onClick = if (failedErr != null) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + } + } else null + ) { + Text( + relayDisplayName(relay), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + RelayStatusIndicator(relay.relayStatus, connFailed = failedErr != null, memberStatus = m?.memberStatus) + } + } + } + } +} + +@Composable +private fun SubscriberChannelRelayBar( + hostnames: List, + relayMembers: List, + connectedCount: Int, + removedCount: Int, + failedCount: Int, + total: Int, + showProgress: Boolean, + relayListExpanded: MutableState +) { + val errorCount = removedCount + failedCount + val allBroken = connectedCount == 0 && errorCount == total + Column(Modifier.background(MaterialTheme.colors.surface)) { + RelayBarHeader(relayListExpanded) { + if (total == 0) { + Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary) + Icon( + painterResource(MR.images.ic_warning), + contentDescription = null, + tint = WarningOrange, + modifier = Modifier.size(18.dp) + ) + } else if (allBroken) { + val statusText = if (removedCount == total) { + generalGetString(MR.strings.relay_bar_all_relays_removed) + } else if (failedCount == total) { + generalGetString(MR.strings.relay_bar_all_relays_failed) + } else { + generalGetString(MR.strings.relay_bar_no_active_relays) + } + Text(statusText, color = MaterialTheme.colors.secondary) + Icon( + painterResource(MR.images.ic_warning), + contentDescription = null, + tint = WarningOrange, + modifier = Modifier.size(18.dp) + ) + } else if (connectedCount + removedCount + failedCount >= total && errorCount > 0) { + val statusText = if (failedCount > 0 && removedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_relays_not_active), failedCount + removedCount) + } else if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_relays_failed), failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_relays_removed), removedCount) + } + Text(statusText, color = MaterialTheme.colors.secondary) + } else { + if (showProgress && connectedCount + errorCount < total) { + RelayProgressIndicator(active = connectedCount, total = total) + } + val statusText = if (failedCount > 0 && removedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_connected_with_errors), connectedCount, total, errorCount) + } else if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_connected_with_failures), connectedCount, total, failedCount) + } else if (removedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_connected_with_removed), connectedCount, total, removedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_connected), connectedCount, total) + } + Text(statusText, color = MaterialTheme.colors.secondary) + } + } + if (relayListExpanded.value) { + if (allBroken) { + Text( + generalGetString(MR.strings.relay_bar_subscriber_waiting), + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = DEFAULT_PADDING, bottom = 4.dp), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + } + if (relayMembers.isEmpty()) { + hostnames.forEach { relay -> + RelayBarDetailRow { + Text( + String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relay)), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + } + } + } else { + relayMembers.forEach { m -> + val host = m.relayLink?.let { hostFromRelayLink(it) } + val failedErr = m.activeConn?.connFailedErr + RelayBarDetailRow( + onClick = if (failedErr != null) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + } + } else null + ) { + Text( + String.format(generalGetString(MR.strings.via_relay_hostname), host ?: m.chatViewName), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + val (statusText, statusColor) = relayConnStatus(m) + androidx.compose.foundation.Canvas(Modifier.size(8.dp)) { + drawCircle(color = statusColor) + } + Spacer(Modifier.width(4.dp)) + Text(statusText, color = MaterialTheme.colors.secondary, fontSize = 12.sp) + if (failedErr != null) { + Spacer(Modifier.width(4.dp)) + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } + } +} + +@Composable +private fun RelayBarHeader( + expanded: MutableState, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded.value = !expanded.value } + .padding(start = 12.dp, end = DEFAULT_PADDING_HALF, top = 8.dp, bottom = if (expanded.value) 4.dp else 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + content() + Spacer(Modifier.weight(1f)) + Icon( + painterResource(if (expanded.value) MR.images.ic_chevron_down else MR.images.ic_chevron_up), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } +} + +@Composable +private fun RelayBarDetailRow( + onClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit +) { + val modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + Row( + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + +private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState? { + val gInfo = (chat.chatInfo as? ChatInfo.Group)?.groupInfo ?: return null + if (!gInfo.useRelays || gInfo.membership.memberRole != GroupMemberRole.Owner || + gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) + ) return null + val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList() + if (relays.isEmpty()) return OwnerRelayState(emptyList(), 0, 0, 0, true) + val relayMembers = relays.map { relay -> + relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId } + } + val removedCount = relayMembers.count { (_, m) -> relayMemberRemoved(m?.memberStatus) } + val activeCount = relayMembers.count { (relay, m) -> !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == RelayStatus.Active && m?.activeConn?.connFailedErr == null } + val failedCount = relayMembers.count { (_, m) -> !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != null } + val noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.size + return OwnerRelayState(relays, activeCount, failedCount, removedCount, noActiveRelays) +} + +private data class OwnerRelayState( + val relays: List, + val activeCount: Int, + val failedCount: Int, + val removedCount: Int, + val noActiveRelays: Boolean +) + +private fun relayMemberRemoved(status: GroupMemberStatus?): Boolean = + status in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 1501fb7938..e681c4fed7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -39,7 +39,7 @@ fun ContextItemView( val receivedColor = MaterialTheme.appColors.receivedMessage @Composable - fun MessageText(contextItem: ChatItem, attachment: ImageResource?, lines: Int) { + fun MessageText(contextItem: ChatItem, attachment: ImageResource?, lines: Int, prefix: AnnotatedString? = null, stripLink: String? = null) { val inlineContent: Pair Unit, Map>? = if (attachment != null) { remember(contextItem.id) { val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = { @@ -68,24 +68,35 @@ fun ContextItemView( userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null - } + }, + prefix = prefix, + stripLink = stripLink, ) } fun attachment(contextItem: ChatItem): ImageResource? { val fileIsLoaded = getLoadedFilePath(contextItem.file) != null - return when (contextItem.content.msgContent) { + val mc = contextItem.content.msgContent + return when (mc) { is MsgContent.MCFile -> if (fileIsLoaded) MR.images.ic_draft_filled else null is MsgContent.MCImage -> MR.images.ic_image is MsgContent.MCVoice -> if (fileIsLoaded) MR.images.ic_play_arrow_filled else null + is MsgContent.MCChat -> mc.chatLink.smallIconRes else -> null } } @Composable fun ContextMsgPreview(contextItem: ChatItem, lines: Int) { - MessageText(contextItem, remember(contextItem.id) { attachment(contextItem) }, lines) + val mc = contextItem.content.msgContent + if (mc is MsgContent.MCChat) { + val hasText = contextItem.text != mc.chatLink.connLinkStr + val prefix = buildAnnotatedString { append(mc.chatLink.displayName + if (hasText) " - " else "") } + MessageText(contextItem, remember(contextItem.id) { mc.chatLink.smallIconRes }, lines, prefix = prefix, stripLink = mc.chatLink.connLinkStr) + } else { + MessageText(contextItem, remember(contextItem.id) { attachment(contextItem) }, lines) + } } val sent = contextItems[0].chatDir.sent diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 467f1e52af..0948551c7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -24,13 +24,13 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatItem import chat.simplex.common.platform.* -import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* import java.net.URI +// Spec: spec/client/compose.md#SendMsgView @Composable fun SendMsgView( composeState: MutableState, @@ -195,7 +195,7 @@ fun SendMsgView( ) } } - if (timedMessageAllowed) { + if (timedMessageAllowed && !cs.editing) { menuItems.add { ItemAction( generalGetString(MR.strings.disappearing_message), @@ -312,21 +312,19 @@ private fun RecordVoiceView(recState: MutableState, stopRecOnNex LockToCurrentOrientationUntilDispose() StopRecordButton(stopRecordingAndAddAudio) } else { - val startRecording: () -> Unit = out@ { - if (appPlatform.isDesktop) { - return@out showInDevelopingAlert() + val startRecording: () -> Unit = { + val filePath = rec.start { progress: Int?, finished: Boolean -> + val state = recState.value + if (state is RecordingState.Started && progress != null) { + recState.value = if (!finished) + RecordingState.Started(state.filePath, progress) + else + RecordingState.Finished(state.filePath, progress) + } + } + if (filePath.isNotEmpty()) { + recState.value = RecordingState.Started(filePath = filePath) } - recState.value = RecordingState.Started( - filePath = rec.start { progress: Int?, finished: Boolean -> - val state = recState.value - if (state is RecordingState.Started && progress != null) { - recState.value = if (!finished) - RecordingState.Started(state.filePath, progress) - else - RecordingState.Finished(state.filePath, progress) - } - }, - ) } val interactionSource = interactionSourceWithTapDetection( onPress = { if (recState.value is RecordingState.NotStarted) startRecording() }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt new file mode 100644 index 0000000000..d85488cefc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -0,0 +1,550 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.item.itemPrefixText +import chat.simplex.common.views.chat.item.itemSegmentDisplayText +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* + +val SelectionHighlightColor = Color(0x4D0066FF) + +data class ItemContext( + val selectionIndex: Int = -1 +) + +val LocalItemContext = compositionLocalOf { ItemContext() } + +data class SelectionRange( + val startIndex: Int, + val startItemId: Long, + val startOffset: Int, + val endIndex: Int, + val endItemId: Long, + val endOffset: Int +) + +enum class SelectionState { Idle, Selecting, Selected } + +class SelectionManager { + var selectionState by mutableStateOf(SelectionState.Idle) + private set + + var range by mutableStateOf(null) + private set + + var anchorWindowY by mutableStateOf(0f) + private set + var anchorWindowX by mutableStateOf(0f) + private set + var focusWindowY by mutableStateOf(0f) + var focusWindowX by mutableStateOf(0f) + var viewportWidth by mutableStateOf(0f) + var viewportHeight by mutableStateOf(0f) + var viewportTop by mutableStateOf(0f) + var viewportBottom by mutableStateOf(0f) + var viewportPosition by mutableStateOf(Offset.Zero) + var focusCharRect by mutableStateOf(Rect.Zero) // X: absolute window, Y: relative to item + var listState: State? = null + var mergedItemsState: State? = null + var onCopySelection: (() -> Unit)? = null + private var autoScrollJob: Job? = null + + fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) { + val id = mergedItemsState?.value?.items?.getOrNull(startIndex)?.newest()?.item?.id ?: return + range = SelectionRange(startIndex, id, -1, startIndex, id, -1) + selectionState = SelectionState.Selecting + anchorWindowY = anchorY + anchorWindowX = anchorX + } + + fun setAnchorOffset(offset: Int) { + val r = range ?: return + range = r.copy(startOffset = offset) + } + + fun updateFocusIndex(index: Int) { + val r = range ?: return + val id = mergedItemsState?.value?.items?.getOrNull(index)?.newest()?.item?.id ?: return + range = r.copy(endIndex = index, endItemId = id) + } + + fun updateFocusOffset(offset: Int, charRect: Rect = Rect.Zero) { + val r = range ?: return + range = r.copy(endOffset = offset) + focusCharRect = charRect + } + + fun endSelection() { + autoScrollJob?.cancel() + autoScrollJob = null + selectionState = SelectionState.Selected + } + + // Snaps boundary offsets to include full transformed segments (mentions, links with showText). + fun snapSelection(items: List, linkMode: SimplexLinkMode) { + val r = range ?: return + val startCi = items.getOrNull(r.startIndex)?.newest()?.item + val endCi = items.getOrNull(r.endIndex)?.newest()?.item + // expandRight: snap in the direction that grows the selection + val startExpandRight = if (r.startIndex == r.endIndex) r.startOffset > r.endOffset else r.startIndex < r.endIndex + val endExpandRight = if (r.startIndex == r.endIndex) r.endOffset > r.startOffset else r.endIndex < r.startIndex + val snappedStart = if (startCi != null && r.startOffset >= 0) + snapOffset(startCi, r.startOffset, linkMode, expandRight = startExpandRight) + else r.startOffset + val snappedEnd = if (endCi != null && r.endOffset >= 0) + snapOffset(endCi, r.endOffset, linkMode, expandRight = endExpandRight) + else r.endOffset + if (snappedStart != r.startOffset || snappedEnd != r.endOffset) { + range = r.copy(startOffset = snappedStart, endOffset = snappedEnd) + } + } + + fun clearSelection() { + range = null + selectionState = SelectionState.Idle + } + + // Computes copy button position relative to the viewport (called during layout phase). + // Dragging down: button below focus char (top-left at char's bottom-right corner). + // Dragging up: button above focus char (bottom-right at char's top-left corner). + // focusCharRect X is absolute window coords, Y is relative to item. + fun copyButtonOffset(draggingDown: Boolean, gap: Float, buttonSize: IntSize): IntOffset { + val r = range ?: return IntOffset.Zero + val ls = listState?.value ?: return IntOffset.Zero + val itemInfo = ls.layoutInfo.visibleItemsInfo.find { it.index == r.endIndex } + ?: return IntOffset(-10000, -10000) // focus item scrolled off screen + // Item top in viewport coords (reversed layout: viewportEnd - offset - size) + val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat() + val cr = focusCharRect + val vp = viewportPosition + // Convert from window coords to viewport-relative + val charX = (if (draggingDown) cr.right else cr.left) - vp.x + val charY = itemWindowY + (if (draggingDown) cr.bottom else cr.top) - vp.y + // Anchor button corner at char corner with gap + val x = if (draggingDown) charX else (charX - buttonSize.width).coerceAtLeast(0f) + val y = if (draggingDown) charY + gap else charY - buttonSize.height - gap + val clampedX = x.coerceIn(0f, (viewportWidth - buttonSize.width).coerceAtLeast(0f)) + return IntOffset(clampedX.toInt(), y.toInt()) + } + + fun startDragSelection(localStart: Offset, windowStart: Offset, focusRequester: FocusRequester) { + val ls = listState?.value ?: return + val idx = resolveIndexAtY(ls, localStart.y) ?: return + startSelection(idx, windowStart.y, windowStart.x) + focusWindowY = windowStart.y + focusWindowX = windowStart.x + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + + fun updateDragFocus(windowPos: Offset, localY: Float) { + focusWindowY = windowPos.y + focusWindowX = windowPos.x + val ls = listState?.value ?: return + val idx = resolveIndexAtY(ls, localY) ?: return + updateFocusIndex(idx) + } + + fun resyncIndices() { + val r = range ?: return + val items = mergedItemsState?.value?.items ?: return + val newStartIndex = items.indexOfFirst { it.newest().item.id == r.startItemId } + val newEndIndex = items.indexOfFirst { it.newest().item.id == r.endItemId } + if (newStartIndex < 0 || newEndIndex < 0) clearSelection() + else range = r.copy(startIndex = newStartIndex, endIndex = newEndIndex) + } + + fun updateAutoScroll(draggingDown: Boolean, pointerY: Float, scope: CoroutineScope) { + val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop + if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) { + autoScrollJob?.cancel() + autoScrollJob = null + return + } + if (autoScrollJob?.isActive == true) return + val ls = listState ?: return + autoScrollJob = scope.launch { + while (isActive && selectionState == SelectionState.Selecting) { + val curEdge = if (draggingDown) viewportBottom - focusWindowY else focusWindowY - viewportTop + if (curEdge >= AUTO_SCROLL_ZONE_PX) break + val fraction = 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) + val speed = MIN_SCROLL_SPEED + (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED) * fraction + ls.value.scrollBy(if (draggingDown) -speed else speed) + delay(16) + } + } + } + + fun getSelectedCopiedText(items: List, revealedItems: Set, linkMode: SimplexLinkMode): String { + val r = range ?: return "" + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + return (lo..hi).mapNotNull { idx -> + val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + if (ci.meta.itemDeleted != null && (!revealedItems.contains(ci.id) || ci.isDeletedContent)) return@mapNotNull null + val sel = selectedRange(range, idx) ?: return@mapNotNull null + selectedItemCopiedText(ci, sel, linkMode) + }.reversed().joinToString("\n") + } +} + +// Returns the character range selected within a given item. +// Offsets are cursor positions (between characters), so the selected characters +// are those between min and max cursors: range is min..(max - 1). +// In reversed layout: higher index = higher on screen. +// startIndex/startOffset = anchor, endIndex/endOffset = focus. +fun selectedRange(range: SelectionRange?, index: Int): IntRange? { + val r = range ?: return null + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + if (index < lo || index > hi) return null + return when { + // Single-item selection: characters between the two cursor positions + index == r.startIndex && index == r.endIndex -> + if (r.startOffset < 0 || r.endOffset < 0 || r.startOffset == r.endOffset) null + else minOf(r.startOffset, r.endOffset) .. (maxOf(r.startOffset, r.endOffset) - 1) + // Anchor item in multi-item selection: from cursor to end, or from start to cursor + index == r.startIndex -> + if (r.startOffset < 0) null + else if (r.startIndex > r.endIndex) r.startOffset until Int.MAX_VALUE + else 0 until r.startOffset + // Focus item in multi-item selection: symmetric to anchor + index == r.endIndex -> + if (r.endOffset < 0) null + else if (r.endIndex < r.startIndex) 0 until r.endOffset + else r.endOffset until Int.MAX_VALUE + // Interior items: fully selected + else -> 0 until Int.MAX_VALUE + } +} + +// Extracts source text for the selected range within one item. +// Selection offsets are in display-text space (which includes any leading itemPrefixText). +// For transformed segments (mentions, links with showText), the full source is emitted if any part +// is selected. For untransformed segments, partial substring works. +private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String { + val prefix = itemPrefixText(ci) + val sb = StringBuilder() + if (sel.first < prefix.length) { + sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1)) + } + val formattedText = ci.formattedText ?: run { + val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length) + val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length) + if (start < end) sb.append(ci.text, start, end) + return sb.toString() + } + var displayOffset = prefix.length + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + val overlapStart = maxOf(displayOffset, sel.first) + val overlapEnd = minOf(displayEnd, sel.last + 1) + if (overlapStart < overlapEnd) { + if (ft.text.length == segDisplay.length) { + sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + } else { + sb.append(ft.text) + } + } + displayOffset = displayEnd + } + return sb.toString() +} + +// Snaps a boundary offset to include full transformed segments. +private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { + val formattedText = ci.formattedText ?: return offset + var displayOffset = itemPrefixText(ci).length + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + return if (expandRight) displayEnd else displayOffset + } + displayOffset = displayEnd + } + return offset +} + +val LocalSelectionManager = staticCompositionLocalOf { null } + +private const val AUTO_SCROLL_ZONE_PX = 40f +private const val MIN_SCROLL_SPEED = 2f +private const val MAX_SCROLL_SPEED = 20f + +@Composable +fun BoxScope.SelectionHandler( + manager: SelectionManager, + listState: State, + mergedItems: State, + revealedItems: State>, + linkMode: SimplexLinkMode +): Modifier { + val touchSlop = LocalViewConfiguration.current.touchSlop + val clipboard = LocalClipboardManager.current + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + // Re-evaluate focus index on scroll during active drag + LaunchedEffect(manager) { + snapshotFlow { listState.value.firstVisibleItemScrollOffset } + .collect { + if (manager.selectionState == SelectionState.Selecting) { + val idx = resolveIndexAtY(listState.value, manager.focusWindowY - manager.viewportPosition.y) + if (idx != null) manager.updateFocusIndex(idx) + } + } + } + + manager.listState = listState + manager.mergedItemsState = mergedItems + manager.onCopySelection = { + clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, revealedItems.value, linkMode))) + showToast(generalGetString(MR.strings.copied)) + } + + // Resync after the items list mutates (new message arrives, item deleted). + SideEffect { manager.resyncIndices() } + + return Modifier + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { event -> + if (manager.selectionState == SelectionState.Selected + && (event.isCtrlPressed || event.isMetaPressed) + && event.key == Key.C + && event.type == KeyEventType.KeyDown + ) { + manager.onCopySelection?.invoke() + true + } else false + } + .onGloballyPositioned { + val pos = it.positionInWindow() + val bounds = it.boundsInWindow() + manager.viewportTop = bounds.top + manager.viewportBottom = bounds.bottom + manager.viewportWidth = bounds.right - bounds.left + manager.viewportHeight = bounds.bottom - bounds.top + manager.viewportPosition = pos + } + .pointerInput(manager) { + awaitEachGesture { + var initialEvent: PointerInputChange + // Wait for press, skip hovers + do { initialEvent = awaitPointerEvent(PointerEventPass.Initial).changes.first() } while (!initialEvent.pressed) + val localStart = initialEvent.position + val windowStart = localStart + manager.viewportPosition + if (manager.selectionState == SelectionState.Selected) initialEvent.consume() + var totalDrag = Offset.Zero + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial).changes.first() + when (manager.selectionState) { + SelectionState.Idle -> { + if (!event.pressed) return@awaitEachGesture + totalDrag += event.positionChange() + if (totalDrag.getDistance() > touchSlop) { + manager.startDragSelection(localStart, windowStart, focusRequester) + event.consume() + } + } + SelectionState.Selected -> { + if (!event.pressed) { + manager.clearSelection() + return@awaitEachGesture + } + event.consume() + totalDrag += event.positionChange() + if (totalDrag.getDistance() > touchSlop) { + manager.startDragSelection(localStart, windowStart, focusRequester) + } + } + SelectionState.Selecting -> { + if (!event.pressed) { + manager.endSelection() + manager.snapSelection(mergedItems.value.items, linkMode) + return@awaitEachGesture + } + val windowPos = event.position + manager.viewportPosition + manager.updateDragFocus(windowPos, event.position.y) + event.consume() + manager.updateAutoScroll(windowPos.y > windowStart.y, windowPos.y, scope) + } + } + } + } + } +} + +private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { + val reversedY = listState.layoutInfo.viewportEndOffset - localY + val idx = listState.layoutInfo.visibleItemsInfo.find { item -> + reversedY >= item.offset && reversedY < item.offset + item.size + }?.index + return idx +} + +class ItemSelection( + val highlightRange: IntRange?, + val positionModifier: Modifier, + val onTextLayoutResult: ((TextLayoutResult) -> Unit)? +) + +// Sets up selection tracking for a text item: anchor/focus offset resolution, +// highlight range computation, and position/layout result capture. +@Composable +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { + val boundsState = remember { mutableStateOf(null) } + val layoutResultState = remember { mutableStateOf(null) } + + if (selectionManager != null && selectionIndex >= 0 && !isLive) { + val isAnchor = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + val bounds = boundsState.value ?: return@LaunchedEffect + val layout = layoutResultState.value ?: return@LaunchedEffect + val offset = layout.getOffsetForPosition( + Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top) + ) + selectionManager.setAnchorOffset(offset) + } + + val isFocus = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } + .collect { (py, px) -> + val bounds = boundsState.value ?: return@collect + val layout = layoutResultState.value ?: return@collect + val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) + val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val ls = selectionManager.listState?.value + val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } + val charRect = if (ls != null && itemInfo != null) { + val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat() + Rect( + left = bounds.left + charBox.left, + top = bounds.top + charBox.top - itemWindowY, + right = bounds.left + charBox.right, + bottom = bounds.top + charBox.bottom - itemWindowY + ) + } else Rect.Zero + selectionManager.updateFocusOffset(offset, charRect) + } + } + } + } + + val highlightRange = if (selectionManager != null && selectionIndex >= 0) { + remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value + } else null + + val positionModifier = if (selectionManager != null) { + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + } + } else Modifier + + val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { + { layoutResultState.value = it } + } else null + + return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) +} + +// Sets up full-item selection for emoji items (no character-level tracking). +@Composable +fun setupEmojiSelection(selectionManager: SelectionManager?, selectionIndex: Int, textLength: Int): Boolean { + if (selectionManager == null || selectionIndex < 0) return false + + val isAnchor = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + selectionManager.setAnchorOffset(0) + } + + val isFocus = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { selectionManager.focusWindowY } + .collect { selectionManager.updateFocusOffset(textLength) } + } + } + + return remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) != null } }.value +} + +@Composable +fun SelectionCopyButton() { + val manager = LocalSelectionManager.current ?: return + val range = manager.range ?: return + if (manager.selectionState != SelectionState.Selected || manager.focusCharRect == Rect.Zero) return + val draggingDown = range.startIndex > range.endIndex || (range.startIndex == range.endIndex && range.startOffset < range.endOffset) + val gap = with(LocalDensity.current) { 4.dp.toPx() } + var buttonSize by remember { mutableStateOf(IntSize.Zero) } + Row( + Modifier + .offset { manager.copyButtonOffset(draggingDown, gap, buttonSize) } + .onSizeChanged { buttonSize = it } + .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(20.dp)) + .clickable { + manager.onCopySelection?.invoke() + manager.clearSelection() + } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_content_copy), null, Modifier.size(16.dp), tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(6.dp)) + Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt new file mode 100644 index 0000000000..d0c2486069 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt @@ -0,0 +1,237 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionCustomFooter +import SectionDividerSpaced +import SectionItemView +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.chatRelayDisplayName +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.launch + +data class AvailableRelay( + val relayId: Long, + val relay: UserChatRelay, + val operatorName: String? +) + +@Composable +fun AddGroupRelayView( + groupInfo: GroupInfo, + existingRelayIds: Set, + onRelayAdded: () -> Unit, + close: () -> Unit +) { + var availableRelays by remember { mutableStateOf>(emptyList()) } + var selectedRelayIds by remember { mutableStateOf>(emptySet()) } + var isLoading by remember { mutableStateOf(true) } + var isAdding by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + BackHandler(onBack = close) + + LaunchedEffect(Unit) { + try { + val servers = ChatController.getUserServers(null) + if (servers != null) { + val relays = mutableListOf() + for (op in servers) { + if (op.operator != null && op.operator.enabled != true) continue + val opName: String? = if (op.operator?.operatorTag != null) op.operator.tradeName else null + for (relay in op.chatRelays) { + val relayId = relay.chatRelayId + if (relay.enabled && !relay.deleted && relayId != null && relayId !in existingRelayIds) { + relays.add(AvailableRelay(relayId, relay, opName)) + } + } + } + availableRelays = relays + } + } catch (e: Exception) { + Log.e(TAG, "loadAvailableRelays error: ${e.message}") + } + isLoading = false + } + + AddGroupRelayLayout( + availableRelays = availableRelays, + selectedRelayIds = selectedRelayIds, + isLoading = isLoading, + isAdding = isAdding, + onToggleRelay = { relayId -> + selectedRelayIds = if (relayId in selectedRelayIds) selectedRelayIds - relayId else selectedRelayIds + relayId + }, + onAddRelays = { + val relayIds = selectedRelayIds.toList() + if (relayIds.isEmpty()) return@AddGroupRelayLayout + isAdding = true + scope.launch { + addSelectedRelays(groupInfo, relayIds, selectedRelayIds, availableRelays, onRelayAdded, close) { newSelectedIds, newAvailableRelays -> + selectedRelayIds = newSelectedIds + availableRelays = newAvailableRelays + isAdding = false + } + } + } + ) +} + +@Composable +private fun AddGroupRelayLayout( + availableRelays: List, + selectedRelayIds: Set, + isLoading: Boolean, + isAdding: Boolean, + onToggleRelay: (Long) -> Unit, + onAddRelays: () -> Unit +) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.add_relays_title)) + + if (isLoading) { + Box(Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (availableRelays.isEmpty()) { + SectionView { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + generalGetString(MR.strings.no_available_relays), + color = MaterialTheme.colors.secondary + ) + } + } + } else { + SectionView { + AddRelaysButton( + onClick = onAddRelays, + disabled = selectedRelayIds.isEmpty() || isAdding + ) + } + SectionCustomFooter { + val count = selectedRelayIds.size + Text( + if (count == 0) generalGetString(MR.strings.no_relays_selected) + else String.format(generalGetString(MR.strings.num_relays_selected), count), + color = MaterialTheme.colors.secondary, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } + SectionDividerSpaced(maxTopPadding = true) + SectionView(generalGetString(MR.strings.select_relays).uppercase()) { + availableRelays.forEach { item -> + val selected = item.relayId in selectedRelayIds + SectionItemView( + click = { onToggleRelay(item.relayId) }, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + Column(Modifier.weight(1f)) { + Text( + chatRelayDisplayName(item.relay), + maxLines = 1, + color = MaterialTheme.colors.onBackground + ) + if (item.operatorName != null) { + Text( + item.operatorName, + fontSize = 12.sp, + maxLines = 1, + color = MaterialTheme.colors.secondary + ) + } + } + Spacer(Modifier.width(8.dp)) + Icon( + painterResource(if (selected) MR.images.ic_check_circle_filled else MR.images.ic_circle), + contentDescription = null, + tint = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun AddRelaysButton(onClick: () -> Unit, disabled: Boolean) { + SettingsActionItem( + painterResource(MR.images.ic_check), + generalGetString(MR.strings.add_relays_title), + click = onClick, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = disabled, + ) +} + +private suspend fun addSelectedRelays( + groupInfo: GroupInfo, + relayIds: List, + selectedRelayIds: Set, + availableRelays: List, + onRelayAdded: () -> Unit, + close: () -> Unit, + updateState: (Set, List) -> Unit +) { + try { + val result = ChatController.apiAddGroupRelays(groupInfo.groupId, relayIds) + if (result == null) { + updateState(selectedRelayIds, availableRelays) + return + } + when (result) { + is ChatController.AddGroupRelaysResult.Added -> { + ChannelRelaysModel.set(groupId = result.groupInfo.groupId, groupRelays = result.groupRelays) + onRelayAdded() + close() + } + is ChatController.AddGroupRelaysResult.AddFailed -> { + val results = result.addRelayResults + val successIds = results.filter { it.relayError == null }.mapNotNull { it.relay.chatRelayId }.toSet() + var newSelectedIds = selectedRelayIds + var newAvailableRelays = availableRelays + if (successIds.isNotEmpty()) { + newSelectedIds = selectedRelayIds - successIds + newAvailableRelays = availableRelays.filter { it.relayId !in successIds } + onRelayAdded() + } + val errorLines = results.filter { it.relayError != null } + .map { "${chatRelayDisplayName(it.relay)}: ${it.relayError?.let { e -> ChatController.connErrorText(e) } ?: ""}" } + val successNames = results.filter { it.relayError == null } + .map { chatRelayDisplayName(it.relay) } + var msg = errorLines.joinToString("\n") + if (successNames.isNotEmpty()) { + msg += "\n" + String.format(generalGetString(MR.strings.relays_added_format), successNames.joinToString(", ")) + } + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_adding_relays), + text = msg + ) + updateState(newSelectedIds, newAvailableRelays) + } + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_adding_relays), + text = e.message ?: "" + ) + updateState(selectedRelayIds, availableRelays) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt new file mode 100644 index 0000000000..0cf3a3c96f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -0,0 +1,119 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.subscriberCountStr +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR + +@Composable +fun ChannelMembersView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit, + showMemberInfo: (GroupMember) -> Unit +) { + BackHandler(onBack = close) + val members = remember { chatModel.groupMembers }.value + .filter { m -> + m.memberStatus != GroupMemberStatus.MemLeft + && m.memberStatus != GroupMemberStatus.MemRemoved + && m.memberRole != GroupMemberRole.Relay + } + + ColumnWithScrollBar { + val title = if (groupInfo.isOwner) { + generalGetString(MR.strings.channel_members_title_subscribers) + } else { + generalGetString(MR.strings.channel_members_section_owners) + } + AppBarTitle(title) + + if (groupInfo.isOwner) { + val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() + SectionView(title = subscriberCountStr(subscriberCount).uppercase()) { + SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + ChannelMemberRow(groupInfo.membership, user = true, showRole = true) + } + members.forEachIndexed { index, member -> + Divider() + SectionItemView( + click = { showMemberInfo(member) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner) + } + } + } + } else { + val owners = members.filter { it.memberRole >= GroupMemberRole.Owner } + SectionView(title = generalGetString(MR.strings.channel_members_section_owners)) { + owners.forEachIndexed { index, member -> + if (index > 0) { + Divider() + } + SectionItemView( + click = { showMemberInfo(member) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + ChannelMemberRow(member, user = false, showRole = false) + } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boolean) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MemberProfileImage(size = 38.dp, member) + Spacer(Modifier.width(2.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified + ) + } + if (user) { + Text( + generalGetString(MR.strings.channel_member_you), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary + ) + } + } + if (showRole) { + Text( + member.memberRole.text, + color = MaterialTheme.colors.secondary + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt new file mode 100644 index 0000000000..cfe9f0472d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -0,0 +1,226 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewLongClickable +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +fun ChannelRelaysView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit +) { + BackHandler(onBack = close) + val groupRelays = ChannelRelaysModel.groupRelays + + LaunchedEffect(Unit) { + setGroupMembers(rhId, groupInfo, chatModel) + if (groupInfo.isOwner) { + val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId) + ChannelRelaysModel.set(groupId = groupInfo.groupId, groupRelays = relays) + } + } + + ChannelRelaysLayout( + rhId = rhId, + groupInfo = groupInfo, + chatModel = chatModel, + groupRelays = groupRelays, + showMemberInfo = showMemberInfo + ) +} + +@Composable +private fun ChannelRelaysLayout( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + groupRelays: List, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit +) { + val relayMembers = remember { chatModel.groupMembers }.value + .filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus != GroupMemberStatus.MemRemoved && it.memberStatus != GroupMemberStatus.MemGroupDeleted } + + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.channel_relays_title)) + + if (relayMembers.isEmpty()) { + SectionView { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + generalGetString(MR.strings.no_chat_relays), + color = MaterialTheme.colors.secondary + ) + } + } + } else { + SectionView { + relayMembers.forEachIndexed { index, member -> + if (index > 0) { + Divider() + } + val showMenu = remember { mutableStateOf(false) } + SectionItemViewLongClickable( + click = { showMemberInfo(member, groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }) }, + longClick = { showMenu.value = true }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + // TODO [relays] re-enable when relay management ships + /* + if (groupInfo.isOwner && member.canBeRemoved(groupInfo)) { + DefaultDropdownMenu(showMenu) { + ItemAction(generalGetString(MR.strings.button_remove_relay), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + removeMemberAlert(rhId, groupInfo, member) + showMenu.value = false + }) + } + } + */ + val statusText = if (groupInfo.isOwner) { + ownerRelayStatusText(member, groupRelays) + } else { + subscriberRelayStatusText(member) + } + RelayMemberRow(member, statusText) + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages)) + } + // TODO [relays] re-enable when relay management ships + /* + if (groupInfo.isOwner) { + SectionView { + SectionItemView(click = { + // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // regardless of relayStatus, so all current rows must be excluded from the add list. + val existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet() + ModalManager.end.showModalCloseable(true) { close -> + AddGroupRelayView( + groupInfo = groupInfo, + existingRelayIds = existingRelayIds, + onRelayAdded = { withBGApi { setGroupMembers(rhId, groupInfo, chatModel) } }, + close = close + ) + } + }, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Icon( + painterResource(MR.images.ic_add), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + Spacer(Modifier.width(4.dp)) + Text( + generalGetString(MR.strings.add_relay_button), + color = MaterialTheme.colors.primary + ) + } + } + } + */ + SectionBottomSpacer() + } +} + +@Composable +private fun RelayMemberRow(member: GroupMember, statusText: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MemberProfileImage(size = 38.dp, member) + Spacer(Modifier.width(2.dp)) + Column(Modifier.weight(1f)) { + Text( + member.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.onBackground + ) + Text( + statusText, + maxLines = 1, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } +} + +private fun subscriberRelayStatusText(member: GroupMember): String { + return if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else { + relayConnStatus(member).first + } +} + +private fun ownerRelayStatusText(member: GroupMember, groupRelays: List): String { + val relayStatus = groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus + return if (relayStatus == RelayStatus.Rejected) { + generalGetString(MR.strings.relay_status_rejected) + } else if (member.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)) { + relayConnStatus(member).first + } else if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.relay_conn_status_failed) + } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else { + relayStatus?.text ?: relayConnStatus(member).first + } +} + +fun relayConnStatus(member: GroupMember): Pair { + when (member.memberStatus) { + GroupMemberStatus.MemLeft -> return generalGetString(MR.strings.relay_conn_status_removed_by_operator) to Color.Red + GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted -> return member.memberStatus.text to Color.Red + else -> {} + } + return when (member.activeConn?.connStatus) { + is ConnStatus.Ready -> generalGetString(MR.strings.relay_conn_status_connected) to Color.Green + is ConnStatus.Deleted -> generalGetString(MR.strings.relay_conn_status_deleted) to Color.Red + is ConnStatus.Failed -> generalGetString(MR.strings.relay_conn_status_failed) to Color.Red + else -> generalGetString(MR.strings.relay_conn_status_connecting) to WarningYellow + } +} + +fun hostFromRelayLink(link: String): String { + val ft = parseToMarkdown(link) + if (ft != null) { + for (f in ft) { + val format = f.format + if (format is Format.SimplexLink) { + val host = format.smpHosts.firstOrNull() + if (host != null) return host + } + } + } + return link +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 1ea3daeab1..0f64479359 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -5,6 +5,7 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewLongClickable +import SectionItemViewSpaceBetween import SectionSpacer import SectionTextFooter import SectionView @@ -41,6 +42,7 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.TtlOptions +import chat.simplex.common.views.newchat.SimpleXLinkQRCode import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -114,7 +116,7 @@ fun ModalData.GroupChatInfoView( } } }, - showMemberInfo = { member -> + showMemberInfo = { member, groupRelay -> withBGApi { val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second @@ -126,7 +128,7 @@ fun ModalData.GroupChatInfoView( } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { closeCurrent() close() } @@ -165,7 +167,7 @@ fun ModalData.GroupChatInfoView( clearChat = { clearChatDialog(chat, close) }, leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } }, onSearchClicked = onSearchClicked, deletingItems = deletingItems @@ -175,9 +177,14 @@ fun ModalData.GroupChatInfoView( fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { val chatInfo = chat.chatInfo - val titleId = if (groupInfo.businessChat == null) MR.strings.delete_group_question else MR.strings.delete_chat_question + val titleId = if (groupInfo.useRelays) MR.strings.delete_channel_question + else if (groupInfo.businessChat == null) MR.strings.delete_group_question + else MR.strings.delete_chat_question val messageId = - if (groupInfo.businessChat == null) { + if (groupInfo.useRelays) { + if (groupInfo.membership.memberCurrent) MR.strings.delete_channel_for_all_subscribers_cannot_undo_warning + else MR.strings.delete_channel_for_self_cannot_undo_warning + } else if (groupInfo.businessChat == null) { if (groupInfo.membership.memberCurrent) MR.strings.delete_group_for_all_members_cannot_undo_warning else MR.strings.delete_group_for_self_cannot_undo_warning } else { @@ -209,8 +216,12 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl } fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { - val titleId = if (groupInfo.businessChat == null) MR.strings.leave_group_question else MR.strings.leave_chat_question - val messageId = if (groupInfo.businessChat == null) + val titleId = if (groupInfo.useRelays) MR.strings.leave_channel_question + else if (groupInfo.businessChat == null) MR.strings.leave_group_question + else MR.strings.leave_chat_question + val messageId = if (groupInfo.useRelays) + MR.strings.you_will_stop_receiving_messages_from_this_channel_chat_history_will_be_preserved + else if (groupInfo.businessChat == null) MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved else MR.strings.you_will_stop_receiving_messages_from_this_chat_chat_history_will_be_preserved @@ -228,20 +239,88 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl ) } -private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { - val messageId = if (groupInfo.businessChat == null) - MR.strings.member_will_be_removed_from_group_cannot_be_undone - else - MR.strings.member_will_be_removed_from_chat_cannot_be_undone - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.button_remove_member_question), - text = generalGetString(messageId), - confirmText = generalGetString(MR.strings.remove_member_confirmation), - onConfirm = { - removeMembers(rhId, groupInfo, listOf(mem.groupMemberId)) - }, - destructive = true, - ) +fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { + if (mem.memberRole == GroupMemberRole.Relay) { + val isLastActive = groupInfo.useRelays && mem.memberCurrent && run { + val activeRelays = ChatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent } + activeRelays.size <= 1 + } + val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning) + else generalGetString(MR.strings.relay_will_be_removed_from_channel) + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_relay_question), + message, + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) + } else if (groupInfo.useRelays) { + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_subscriber_question), + generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) + } else { + val titleId = MR.strings.button_remove_member_question + val messageId = if (groupInfo.businessChat == null) + MR.strings.member_will_be_removed_from_group_cannot_be_undone + else + MR.strings.member_will_be_removed_from_chat_cannot_be_undone + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(titleId), + generalGetString(messageId), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) + } } private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { @@ -249,15 +328,30 @@ private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: Lis MR.strings.members_will_be_removed_from_group_cannot_be_undone else MR.strings.members_will_be_removed_from_chat_cannot_be_undone - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.button_remove_members_question), - text = generalGetString(messageId), - confirmText = generalGetString(MR.strings.remove_member_confirmation), - onConfirm = { - removeMembers(rhId, groupInfo, memberIds, onSuccess) - }, - destructive = true, - ) + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_members_question), + generalGetString(messageId), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, memberIds, withMessages = false, onSuccess = onSuccess) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, memberIds, withMessages = true, onSuccess = onSuccess) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) } @Composable @@ -328,6 +422,22 @@ fun AddGroupMembersButton( ) } +@Composable +fun ChannelLinkActionButton( + modifier: Modifier, + groupInfo: GroupInfo, + manageGroupLink: () -> Unit +) { + InfoViewActionButton( + modifier = modifier, + icon = painterResource(MR.images.ic_link), + title = stringResource(MR.strings.action_button_channel_link), + disabled = !groupInfo.ready, + disabledLook = !groupInfo.ready, + onClick = manageGroupLink + ) +} + @Composable fun UserSupportChatButton( chat: Chat, @@ -379,7 +489,7 @@ fun ModalData.GroupChatInfoLayout( appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, addMembers: () -> Unit, - showMemberInfo: (GroupMember) -> Unit, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit, editGroupProfile: () -> Unit, addOrEditWelcomeMessage: () -> Unit, openMemberSupport: () -> Unit, @@ -448,14 +558,19 @@ fun ModalData.GroupChatInfoLayout( Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { + val showThreeButtons = if (groupInfo.useRelays) groupInfo.isOwner else groupInfo.canAddMembers Row( Modifier - .widthIn(max = if (groupInfo.canAddMembers) 320.dp else 230.dp) + .widthIn(max = if (showThreeButtons) 320.dp else 230.dp) .padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - if (groupInfo.canAddMembers) { + if (groupInfo.useRelays && groupInfo.isOwner) { + SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) + ChannelLinkActionButton(modifier = Modifier.fillMaxWidth(0.5f), groupInfo, manageGroupLink) + MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) + } else if (!groupInfo.useRelays && groupInfo.canAddMembers) { SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) AddGroupMembersButton(modifier = Modifier.fillMaxWidth(0.5f), chat, groupInfo) MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) @@ -468,59 +583,109 @@ fun ModalData.GroupChatInfoLayout( SectionSpacer() - var anyTopSectionRowShow = false - SectionView { - if (groupInfo.canAddMembers && groupInfo.businessChat == null) { - anyTopSectionRowShow = true - if (groupLink == null) { - CreateGroupLinkButton(manageGroupLink) - } else { - GroupLinkButton(manageGroupLink) + if (groupInfo.useRelays && groupInfo.membership.memberIncognito) { + SectionView(generalGetString(MR.strings.incognito).uppercase()) { + SectionItemViewSpaceBetween { + Text(generalGetString(MR.strings.incognito_random_profile)) + Text(groupInfo.membership.chatViewName, color = Indigo) } } - if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - anyTopSectionRowShow = true - MemberSupportButton(chat, openMemberSupport) - } - if (groupInfo.canModerate) { - anyTopSectionRowShow = true - GroupReportsButton(chat) { - scope.launch { - showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + SectionDividerSpaced() + } + + var anyTopSectionRowShow = false + val channelLink = groupInfo.groupProfile.publicGroup?.groupLink + val showUserSupportChat = groupInfo.membership.memberActive && + ((groupInfo.fullGroupPreferences.support.on && groupInfo.membership.memberRole < GroupMemberRole.Moderator) + || groupInfo.membership.supportChat != null) + + if (groupInfo.useRelays) { + SectionView { + if (groupInfo.isOwner && groupLink != null) { + anyTopSectionRowShow = true + ChannelLinkButton(manageGroupLink) + } else if (channelLink != null) { + anyTopSectionRowShow = true + ChannelLinkQRCodeSection(channelLink) + ShareViaChatButton { + chatModel.sharedContent.value = SharedContent.ChatLink(groupInfo) + chatModel.chatId.value = null + ModalManager.closeAllModalsEverywhere() } } + if (groupInfo.isOwner || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { + anyTopSectionRowShow = true + ChannelMembersButton(chat.remoteHostId, groupInfo, showMemberInfo) + } + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(chat, openMemberSupport) + } + if (showUserSupportChat) { + anyTopSectionRowShow = true + UserSupportChatButton(chat, groupInfo, scrollToItemId) + } } - if ( - groupInfo.membership.memberActive && - (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) - ) { - anyTopSectionRowShow = true - UserSupportChatButton(chat, groupInfo, scrollToItemId) + if (!groupInfo.isOwner && channelLink != null) { + SectionTextFooter(stringResource(MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect)) + } + } else { + SectionView { + if (groupInfo.canAddMembers && groupInfo.businessChat == null) { + anyTopSectionRowShow = true + if (groupLink == null) { + CreateGroupLinkButton(manageGroupLink) + } else { + GroupLinkButton(manageGroupLink) + } + } + if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(chat, openMemberSupport) + } + if (groupInfo.canModerate) { + anyTopSectionRowShow = true + GroupReportsButton(chat) { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } + if (showUserSupportChat) { + anyTopSectionRowShow = true + UserSupportChatButton(chat, groupInfo, scrollToItemId) + } } } if (anyTopSectionRowShow) { SectionDividerSpaced(maxBottomPadding = false) } - SectionView { if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { - EditGroupProfileButton(editGroupProfile) + val editProfileTitleId = if (groupInfo.useRelays) MR.strings.button_edit_channel_profile else MR.strings.button_edit_group_profile + EditGroupProfileButton(editProfileTitleId, editGroupProfile) } if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) } - val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences + val prefsTitleId = if (groupInfo.useRelays) MR.strings.channel_preferences + else if (groupInfo.businessChat == null) MR.strings.group_preferences + else MR.strings.chat_preferences GroupPreferencesButton(prefsTitleId, openPreferences) } - val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs + val footerId = if (groupInfo.useRelays) MR.strings.only_channel_owners_can_change_prefs + else if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs + else MR.strings.only_chat_owners_can_change_prefs SectionTextFooter(stringResource(footerId)) SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) SectionView { - if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { - SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) - } else { - SendReceiptsOptionDisabled() + if (!groupInfo.useRelays) { + if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { + SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) + } else { + SendReceiptsOptionDisabled() + } } WallpaperButton { ModalManager.end.showModal { @@ -536,7 +701,7 @@ fun ModalData.GroupChatInfoLayout( } SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { if (groupInfo.canAddMembers) { val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers @@ -559,7 +724,7 @@ fun ModalData.GroupChatInfoLayout( } } } - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { items(filteredMembers.value, key = { it.groupMemberId }) { member -> Divider() val showMenu = remember { mutableStateOf(false) } @@ -571,7 +736,7 @@ fun ModalData.GroupChatInfoLayout( toggleItemSelection(member.groupMemberId, selectedItems) } } else { - showMemberInfo(member) + showMemberInfo(member, null) } }, longClick = { showMenu.value = true }, @@ -592,18 +757,30 @@ fun ModalData.GroupChatInfoLayout( } } item { - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) } SectionView { + if (groupInfo.useRelays && (groupInfo.isOwner || activeSortedMembers.any { it.memberRole == GroupMemberRole.Relay })) { + ChannelRelaysButton(chat.remoteHostId, groupInfo, showMemberInfo) + } ClearChatButton(clearChat) if (groupInfo.canDelete) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat + val titleId = if (groupInfo.useRelays) MR.strings.button_delete_channel + else if (groupInfo.businessChat == null) MR.strings.button_delete_group + else MR.strings.button_delete_chat DeleteGroupButton(titleId, deleteGroup) } if (groupInfo.membership.memberCurrentOrPending) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat - LeaveGroupButton(titleId, leaveGroup) + val hasOtherOwner = activeSortedMembers.any { + it.memberRole == GroupMemberRole.Owner && it.groupMemberId != groupInfo.membership.groupMemberId + } + if (!groupInfo.useRelays || !groupInfo.isOwner || hasOtherOwner) { + val titleId = if (groupInfo.useRelays) MR.strings.button_leave_channel + else if (groupInfo.businessChat == null) MR.strings.button_leave_group + else MR.strings.button_leave_chat + LeaveGroupButton(titleId, leaveGroup) + } } } @@ -745,6 +922,17 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName) ) ChatInfoDescription(cInfo, displayName, copyNameToClipboard) + if (groupInfo.useRelays) { + val count = groupInfo.groupSummary.publicMemberCount + if (count != null && count > 0) { + Text( + subscriberCountStr(count), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(bottom = 2.dp) + ) + } + } } } @@ -848,9 +1036,11 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr } fun memberConnStatus(): String { - return if (member.activeConn?.connDisabled == true) { - generalGetString(MR.strings.member_info_member_disabled) + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.member_info_member_failed) } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { generalGetString(MR.strings.member_info_member_inactive) } else { member.memberStatus.shortText @@ -867,7 +1057,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member, async = true) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Column { Row(verticalAlignment = Alignment.CenterVertically) { @@ -984,10 +1174,81 @@ private fun CreateGroupLinkButton(onClick: () -> Unit) { } @Composable -fun EditGroupProfileButton(onClick: () -> Unit) { +private fun ChannelLinkButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_link), + stringResource(MR.strings.channel_link), + onClick, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +private fun ChannelLinkQRCodeSection(groupLink: String) { + val clipboard = LocalClipboardManager.current + SimpleXLinkQRCode(connReq = groupLink) + SectionItemView({ + clipboard.shareText(simplexChatLink(groupLink)) + }) { + Icon(painterResource(MR.images.ic_share), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.share_link), color = MaterialTheme.colors.primary) + } +} + +@Composable +private fun ShareViaChatButton(onClick: () -> Unit) { + SectionItemView(onClick) { + Icon(painterResource(MR.images.ic_forward), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.share_via_chat), color = MaterialTheme.colors.primary) + } +} + +@Composable +private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { + val title = if (groupInfo.isOwner) { + stringResource(MR.strings.channel_members_title_subscribers) + } else { + stringResource(MR.strings.channel_members_section_owners) + } + SettingsActionItem( + painterResource(MR.images.ic_group), + title, + click = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + ModalManager.end.showModalCloseable(true) { close -> + ChannelMembersView(rhId, groupInfo, chatModel, close) { member -> showMemberInfo(member, null) } + } + } + }, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +private fun ChannelRelaysButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_wifi_tethering), + stringResource(MR.strings.button_channel_relays), + click = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + ModalManager.end.showModalCloseable(true) { close -> + ChannelRelaysView(rhId, groupInfo, chatModel, close, showMemberInfo) + } + } + }, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +fun EditGroupProfileButton(titleId: StringResource = MR.strings.button_edit_group_profile, onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_edit), - stringResource(MR.strings.button_edit_group_profile), + stringResource(titleId), onClick, iconColor = MaterialTheme.colors.secondary ) @@ -1052,20 +1313,26 @@ private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) } } -fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { +fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, withMessages: Boolean, onSuccess: () -> Unit = {}) { withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds) + val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds, withMessages = withMessages) if (r != null) { val (updatedGroupInfo, updatedMembers) = r withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) updatedMembers.forEach { updatedMember -> chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) + if (withMessages) { + chatModel.chatsContext.removeMemberItems(rhId, updatedMember, byMember = groupInfo.membership, groupInfo) + } } } withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) + if (withMessages) { + chatModel.chatsContext.removeMemberItems(rhId, updatedMember, byMember = groupInfo.membership, groupInfo) + } } } onSuccess() @@ -1109,7 +1376,7 @@ fun PreviewGroupChatInfoLayout() { appBar = remember { mutableStateOf(null) }, scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, - showMemberInfo = {}, + showMemberInfo = { _, _ -> }, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openMemberSupport = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 5a94e7d505..673f72bb4e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -32,6 +32,8 @@ fun GroupLinkView( groupLink: GroupLink?, onGroupLinkUpdated: ((GroupLink?) -> Unit)?, creatingGroup: Boolean = false, + isChannel: Boolean = false, + shareGroupInfo: GroupInfo? = null, close: (() -> Unit)? = null ) { var groupLinkVar by rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(groupLink) } @@ -122,6 +124,8 @@ fun GroupLinkView( groupInfo, groupLinkMemberRole, creatingLink, + isChannel = isChannel, + shareGroupInfo = shareGroupInfo, createLink = ::createLink, showAddShortLinkAlert = ::showAddShortLinkAlert, updateLink = { @@ -168,6 +172,8 @@ fun GroupLinkLayout( groupInfo: GroupInfo, groupLinkMemberRole: MutableState, creatingLink: Boolean, + isChannel: Boolean = false, + shareGroupInfo: GroupInfo? = null, createLink: () -> Unit, showAddShortLinkAlert: ((() -> Unit)?) -> Unit, updateLink: () -> Unit, @@ -185,9 +191,9 @@ fun GroupLinkLayout( } ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.group_link)) + AppBarTitle(stringResource(if (isChannel) MR.strings.channel_link else MR.strings.group_link)) Text( - stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect), + stringResource(if (isChannel) MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect else MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp), lineHeight = 22.sp ) @@ -208,7 +214,9 @@ fun GroupLinkLayout( } } } else { - RoleSelectionRow(groupInfo, groupLinkMemberRole) + if (!isChannel) { + RoleSelectionRow(groupInfo, groupLinkMemberRole) + } var initialLaunch by remember { mutableStateOf(true) } LaunchedEffect(groupLinkMemberRole.value) { if (!initialLaunch) { @@ -218,47 +226,68 @@ fun GroupLinkLayout( } val showShortLink = remember { mutableStateOf(true) } Spacer(Modifier.height(DEFAULT_PADDING_HALF)) - if (groupLink.connLinkContact.connShortLink == null) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = false) - } else { - SectionViewWithButton(titleButton = { ToggleShortLinkButton(showShortLink) }) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) - } + SectionViewWithButton( + titleButton = + if (!isChannel && groupLink.connLinkContact.connShortLink != null) { + { ToggleShortLinkButton(showShortLink) } + } else null) { + SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) } - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) - ) { - val clipboard = LocalClipboardManager.current - SimpleButton( - stringResource(MR.strings.share_link), - icon = painterResource(MR.images.ic_share), - click = { - if (groupLink.shouldBeUpgraded) { - showAddShortLinkAlert { - clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) - } - } else { + if (!isChannel && groupLink.shouldBeUpgraded) { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.upgrade_group_link), + click = { showAddShortLinkAlert(null) }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + } + val clipboard = LocalClipboardManager.current + SettingsActionItem( + painterResource(MR.images.ic_share), + stringResource(MR.strings.share_link), + click = { + if (!isChannel && groupLink.shouldBeUpgraded) { + showAddShortLinkAlert { clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } + } else { + clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } + }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + if (shareGroupInfo != null && isChannel) { + SettingsActionItem( + painterResource(MR.images.ic_forward), + stringResource(MR.strings.share_via_chat), + click = { + chatModel.sharedContent.value = SharedContent.ChatLink(shareGroupInfo) + chatModel.chatId.value = null + ModalManager.closeAllModalsEverywhere() + }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, ) - if (creatingGroup && close != null) { - ContinueButton(close) - } else { - SimpleButton( - stringResource(MR.strings.delete_link), - icon = painterResource(MR.images.ic_delete), - color = Color.Red, - click = deleteLink - ) - } } - if (groupLink.shouldBeUpgraded) { - AddShortLinkButton(text = stringResource(MR.strings.upgrade_group_link)) { - showAddShortLinkAlert(null) - } + if (!creatingGroup && !isChannel) { + SettingsActionItem( + painterResource(MR.images.ic_delete), + stringResource(MR.strings.delete_link), + click = deleteLink, + iconColor = Color.Red, + textColor = Color.Red, + ) + } + if (creatingGroup && close != null) { + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.continue_to_next_step), + click = close, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) } } } @@ -266,17 +295,6 @@ fun GroupLinkLayout( } } -@Composable -private fun AddShortLinkButton(text: String, onClick: () -> Unit) { - SettingsActionItem( - painterResource(MR.images.ic_add), - text, - onClick, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) -} - @Composable private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState, enabled: Boolean = true) { Row( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index b29374390e..8677609863 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -50,6 +50,7 @@ fun GroupMemberInfoView( connectionCode: String?, chatModel: ChatModel, openedFromSupportChat: Boolean, + groupRelay: GroupRelay? = null, close: () -> Unit, closeAll: () -> Unit, // Close all open windows up to ChatView ) { @@ -90,6 +91,7 @@ fun GroupMemberInfoView( newRole, developerTools, connectionCode, + groupRelay = groupRelay, getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { contactId -> scope.launch { @@ -109,7 +111,6 @@ fun GroupMemberInfoView( } openDirectChat(rhId, memberContact.contactId) closeAll() - chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } progressIndicator = false } @@ -137,6 +138,7 @@ fun GroupMemberInfoView( blockForAll = { blockForAllAlert(rhId, groupInfo, member) }, unblockForAll = { unblockForAllAlert(rhId, groupInfo, member) }, removeMember = { removeMemberDialog(rhId, groupInfo, member, chatModel, close) }, + deleteMemberMessages = { deleteMemberMessagesDialog(rhId, groupInfo, member, chatModel, close) }, onRoleSelected = { if (it == newRole.value) return@GroupMemberInfoLayout val prevValue = newRole.value @@ -240,30 +242,112 @@ fun GroupMemberInfoView( } fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { - val messageId = if (groupInfo.businessChat == null) - MR.strings.member_will_be_removed_from_group_cannot_be_undone - else - MR.strings.member_will_be_removed_from_chat_cannot_be_undone + if (member.memberRole == GroupMemberRole.Relay) { + val isLastActive = groupInfo.useRelays && run { + val activeRelays = chatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent } + activeRelays.size <= 1 + } + val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning) + else generalGetString(MR.strings.relay_will_be_removed_from_channel) + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_relay_question), + message, + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) + } else if (groupInfo.useRelays) { + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_subscriber_question), + generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) + } else { + val messageId = if (groupInfo.businessChat == null) + MR.strings.member_will_be_removed_from_group_cannot_be_undone + else + MR.strings.member_will_be_removed_from_chat_cannot_be_undone + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_member_question), + generalGetString(messageId), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) + } +} + +fun deleteMemberMessagesDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.button_remove_member), - text = generalGetString(messageId), - confirmText = generalGetString(MR.strings.remove_member_confirmation), + title = generalGetString(MR.strings.button_delete_member_messages_question), + text = generalGetString(MR.strings.member_messages_will_be_deleted_cannot_be_undone), + confirmText = generalGetString(MR.strings.delete_member_messages_confirmation), onConfirm = { - removeMember(rhId, member, chatModel, close) + removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close) }, destructive = true, ) } -fun removeMember(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun removeMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, withMessages: Boolean, chatModel: ChatModel, close: (() -> Unit)? = null) { withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) + val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId), withMessages = withMessages) if (r != null) { val (updatedGroupInfo, removedMembers) = r withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) removedMembers.forEach { removedMember -> chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember) + if (withMessages) { + chat.simplex.common.platform.chatModel.chatsContext.removeMemberItems(rhId, removedMember, byMember = groupInfo.membership, groupInfo) + } } } } @@ -281,6 +365,7 @@ fun GroupMemberInfoLayout( newRole: MutableState, developerTools: Boolean, connectionCode: String?, + groupRelay: GroupRelay? = null, getContactChat: (Long) -> Chat?, openDirectChat: (Long) -> Unit, createMemberContact: () -> Unit, @@ -290,6 +375,7 @@ fun GroupMemberInfoLayout( blockForAll: () -> Unit, unblockForAll: () -> Unit, removeMember: () -> Unit, + deleteMemberMessages: () -> Unit, onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, abortSwitchMemberAddress: () -> Unit, @@ -334,7 +420,8 @@ fun GroupMemberInfoLayout( @Composable fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) - val canRemove = member.canBeRemoved(groupInfo) + // TODO [relays] re-enable when relay management ships + val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay if (canBlockForAll || canRemove) { SectionDividerSpaced(maxBottomPadding = false) SectionView { @@ -346,7 +433,11 @@ fun GroupMemberInfoLayout( } } if (canRemove) { - RemoveMemberButton(removeMember) + if (member.memberStatus != GroupMemberStatus.MemRemoved && (member.memberStatus != GroupMemberStatus.MemLeft || member.memberRole == GroupMemberRole.Relay)) { + RemoveMemberButton(groupInfo.useRelays, member.memberRole == GroupMemberRole.Relay, removeMember) + } else if (member.memberRole != GroupMemberRole.Relay) { + DeleteMemberMessagesButton(deleteMemberMessages) + } } } } @@ -382,77 +473,81 @@ fun GroupMemberInfoLayout( val contactId = member.memberContactId - Box( - Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Row( - Modifier - .widthIn(max = 320.dp) - .padding(horizontal = DEFAULT_PADDING), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + if (!groupInfo.useRelays) { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { - val knownChat = if (contactId != null) knownDirectChat(contactId) else null - if (knownChat != null) { - val (chat, contact) = knownChat - val knownContactConnectionStats: MutableState = remember { mutableStateOf(null) } + Row( + Modifier + .widthIn(max = 320.dp) + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val knownChat = if (contactId != null) knownDirectChat(contactId) else null + if (knownChat != null) { + val (chat, contact) = knownChat + val knownContactConnectionStats: MutableState = remember { mutableStateOf(null) } - LaunchedEffect(contact.contactId) { - withBGApi { - val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId) - if (contactInfo != null) { - knownContactConnectionStats.value = contactInfo.first + LaunchedEffect(contact.contactId) { + withBGApi { + val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId) + if (contactInfo != null) { + knownContactConnectionStats.value = contactInfo.first + } } } - } - OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) - AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats) - VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats) - } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { - if (contactId != null) { - OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group - } else { - OpenChatButton( - modifier = Modifier.fillMaxWidth(0.33f), - disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)), - onClick = { createMemberContact() } - ) + OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) + AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats) + VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats) + } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { + if (contactId != null) { + OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group + } else { + OpenChatButton( + modifier = Modifier.fillMaxWidth(0.33f), + disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)), + onClick = { createMemberContact() } + ) + } + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + } else { // no known contact chat && directMessages are off + val messageId = if (groupInfo.businessChat == null) MR.strings.direct_messages_are_prohibited_in_group else MR.strings.direct_messages_are_prohibited_in_chat + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.33f), painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title), messageId) + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) + }) } - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { - showSendMessageToEnableCallsAlert() - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { - showSendMessageToEnableCallsAlert() - }) - } else { // no known contact chat && directMessages are off - val messageId = if (groupInfo.businessChat == null) MR.strings.direct_messages_are_prohibited_in_group else MR.strings.direct_messages_are_prohibited_in_chat - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.33f), painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title), messageId) - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) - }) } } + + SectionSpacer() } - SectionSpacer() + val showMemberSupportChat = !openedFromSupportChat && + groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + member.memberRole != GroupMemberRole.Relay && + ((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator) + || member.supportChat != null) if (member.memberActive) { SectionView { - if ( - !openedFromSupportChat && - groupInfo.membership.memberRole >= GroupMemberRole.Moderator && - (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) - ) { + if (showMemberSupportChat) { SupportChatButton() } - if (connectionCode != null) { + if (connectionCode != null && !(groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay)) { VerifyCodeButton(member.verified, verifyClicked) } if (cStats != null && cStats.ratchetSyncAllowed) { @@ -463,6 +558,11 @@ fun GroupMemberInfoLayout( // } } SectionDividerSpaced() + } else if (groupInfo.useRelays && member.memberCurrent && showMemberSupportChat) { + SectionView { + SupportChatButton() + } + SectionDividerSpaced() } if (member.contactLink != null) { @@ -482,19 +582,64 @@ fun GroupMemberInfoLayout( SectionDividerSpaced() } - SectionView(title = stringResource(MR.strings.member_info_section_title_member)) { - val titleId = if (groupInfo.businessChat == null) MR.strings.info_row_group else MR.strings.info_row_chat + val memberSectionTitle = if (groupInfo.useRelays) { + when (member.memberRole) { + GroupMemberRole.Relay -> stringResource(MR.strings.member_info_section_title_relay) + GroupMemberRole.Owner -> stringResource(MR.strings.member_info_section_title_owner) + else -> stringResource(MR.strings.member_info_section_title_subscriber) + } + } else { + stringResource(MR.strings.member_info_section_title_member) + } + SectionView(title = memberSectionTitle) { + val titleId = if (groupInfo.useRelays) MR.strings.info_row_channel + else if (groupInfo.businessChat == null) MR.strings.info_row_group + else MR.strings.info_row_chat InfoRow(stringResource(titleId), groupInfo.displayName) - val roles = remember { member.canChangeRoleTo(groupInfo) } - if (roles != null) { - RoleSelectionRow(roles, newRole, onRoleSelected) + if (!groupInfo.useRelays) { + val roles = remember { member.canChangeRoleTo(groupInfo) } + if (roles != null) { + RoleSelectionRow(roles, newRole, onRoleSelected) + } else { + InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text) + } } else { InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text) } + val relayLink = member.relayLink + if (relayLink != null) { + InfoRow(stringResource(MR.strings.info_row_relay_link), String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relayLink))) + } + val relayAddress = groupRelay?.userChatRelay?.address + if (relayAddress != null) { + InfoRow(stringResource(MR.strings.info_row_relay_address), String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relayAddress))) + val clipboard = LocalClipboardManager.current + ShareRelayAddressButton { clipboard.shareText(simplexChatLink(relayAddress)) } + } + if (groupRelay?.relayStatus == RelayStatus.Rejected) { + InfoRow(stringResource(MR.strings.member_info_status), stringResource(MR.strings.member_info_relay_status_rejected_by_operator)) + } + } + if (groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay) { + SectionTextFooter( + if (groupInfo.isOwner) stringResource(MR.strings.relay_section_footer_owner) + else stringResource(MR.strings.relay_section_footer_subscriber) + ) } if (cStats != null) { SectionDividerSpaced() SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { + val subStatus = cStats.subStatus + if (subStatus != null) { + SectionItemView({ + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.network_status), + subStatus.statusExplanation + ) + }) { + SubStatusRow(subStatus) + } + } SwitchAddressButton( disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || !member.sendMsgEnabled, switchAddress = switchMemberAddress @@ -516,9 +661,22 @@ fun GroupMemberInfoLayout( } } + val connFailedErr = member.activeConn?.connFailedErr + if (connFailedErr != null) { + SectionDividerSpaced() + SectionView(title = stringResource(MR.strings.info_row_connection_failed), icon = painterResource(MR.images.ic_warning), iconTint = Color.Red, leadingIcon = true) { + SectionItemView { + Text( + connFailedErr, + color = MaterialTheme.colors.secondary + ) + } + } + } + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ModeratorDestructiveSection() - } else { + } else if (!groupInfo.useRelays) { NonAdminBlockSection() } @@ -534,18 +692,20 @@ fun GroupMemberInfoLayout( else String.format(generalGetString(MR.strings.conn_level_desc_indirect), conn.connLevel) InfoRow(stringResource(MR.strings.info_row_connection), connLevelDesc) } - SectionItemView({ - withBGApi { - val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId) - if (info != null) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.message_queue_info), - text = queueInfoText(info) - ) + if (!groupInfo.useRelays || member.memberRole == GroupMemberRole.Relay) { + SectionItemView({ + withBGApi { + val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId) + if (info != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.message_queue_info), + text = queueInfoText(info) + ) + } } + }) { + Text(stringResource(MR.strings.info_row_debug_delivery)) } - }) { - Text(stringResource(MR.strings.info_row_debug_delivery)) } } } @@ -649,16 +809,41 @@ fun UnblockForAllButton(onClick: () -> Unit) { } @Composable -fun RemoveMemberButton(onClick: () -> Unit) { +fun RemoveMemberButton(useRelays: Boolean = false, isRelay: Boolean = false, onClick: () -> Unit) { + val label = if (isRelay) MR.strings.button_remove_relay + else if (useRelays) MR.strings.button_remove_subscriber + else MR.strings.button_remove_member SettingsActionItem( painterResource(MR.images.ic_delete), - stringResource(MR.strings.button_remove_member), + stringResource(label), click = onClick, textColor = Color.Red, iconColor = Color.Red, ) } +@Composable +fun DeleteMemberMessagesButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_delete), + stringResource(MR.strings.button_delete_member_messages), + click = onClick, + textColor = Color.Red, + iconColor = Color.Red, + ) +} + +@Composable +fun ShareRelayAddressButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_share_filled), + stringResource(MR.strings.share_relay_address), + onClick, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + @Composable fun OpenChatButton( modifier: Modifier, @@ -714,14 +899,16 @@ fun MemberProfileImage( size: Dp, mem: GroupMember, color: Color = MaterialTheme.colors.secondaryVariant, - backgroundColor: Color? = null + backgroundColor: Color? = null, + async: Boolean = false ) { ProfileImage( size = size, image = mem.image, color = color, backgroundColor = backgroundColor, - blurred = mem.blocked + blurred = mem.blocked, + async = async ) } @@ -841,8 +1028,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem } fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { + val titleId = if (gInfo.useRelays) MR.strings.block_subscriber_for_all_question else MR.strings.block_for_all_question AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.block_for_all_question), + title = generalGetString(titleId), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { @@ -865,8 +1053,9 @@ fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onSuc } fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { + val titleId = if (gInfo.useRelays) MR.strings.unblock_subscriber_for_all_question else MR.strings.unblock_for_all_question AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.unblock_for_all_question), + title = generalGetString(titleId), text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { @@ -925,6 +1114,7 @@ fun PreviewGroupMemberInfoLayout() { blockForAll = {}, unblockForAll = {}, removeMember = {}, + deleteMemberMessages = {}, onRoleSelected = {}, switchMemberAddress = {}, abortSwitchMemberAddress = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index b8db5969a1..740349eaea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -10,6 +10,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -43,7 +44,7 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> fun savePrefs(afterSave: () -> Unit = {}) { withBGApi { val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) - val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp, gInfo.useRelays) if (g != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, g) @@ -56,10 +57,12 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> afterSave() } } + val saveTextId = if (gInfo.useRelays) MR.strings.save_and_notify_channel_subscribers + else MR.strings.save_and_notify_group_members ModalView( close = { if (preferences == currentPreferences) close() - else showUnsavedChangesAlert({ savePrefs(close) }, close) + else showUnsavedChangesAlert({ savePrefs(close) }, close, saveTextId) }, ) { GroupPreferencesLayout( @@ -97,17 +100,11 @@ private fun GroupPreferencesLayout( savePrefs: () -> Unit, openMemberAdmission: () -> Unit, ) { - ColumnWithScrollBar { - val titleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences - AppBarTitle(stringResource(titleId)) - if (groupInfo.businessChat == null) { - MemberAdmissionButton(openMemberAdmission) - SectionDividerSpaced(maxBottomPadding = false) - } + val onTTLUpdated = { ttl: Int? -> + applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl))) + } + @Composable fun TimedMessagesPreference() { val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) } - val onTTLUpdated = { ttl: Int? -> - applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl))) - } FeatureSection(GroupFeature.TimedMessages, timedMessages, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> if (enable == GroupFeatureEnabled.ON) { applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400))) @@ -115,58 +112,127 @@ private fun GroupPreferencesLayout( applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl))) } } - SectionDividerSpaced(true, maxBottomPadding = false) + } + @Composable fun DirectMessagesPreference() { val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) } val directMessagesRole = remember(preferences) { mutableStateOf(preferences.directMessages.role) } FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, directMessagesRole, groupInfo, preferences, onTTLUpdated) { enable, role -> applyPrefs(preferences.copy(directMessages = RoleGroupPreference(enable = enable, role))) } - SectionDividerSpaced(true, maxBottomPadding = false) + } + @Composable fun FullDeletePreference() { val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) } FeatureSection(GroupFeature.FullDelete, allowFullDeletion, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = enable))) } - SectionDividerSpaced(true, maxBottomPadding = false) + } + @Composable fun ReactionsPreference() { val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.enable) } FeatureSection(GroupFeature.Reactions, allowReactions, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> applyPrefs(preferences.copy(reactions = GroupPreference(enable = enable))) } - SectionDividerSpaced(true, maxBottomPadding = false) + } + @Composable fun VoicePreference() { val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) } val voiceRole = remember(preferences) { mutableStateOf(preferences.voice.role) } FeatureSection(GroupFeature.Voice, allowVoice, voiceRole, groupInfo, preferences, onTTLUpdated) { enable, role -> applyPrefs(preferences.copy(voice = RoleGroupPreference(enable = enable, role))) } - SectionDividerSpaced(true, maxBottomPadding = false) + } + @Composable fun FilesPreference() { val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } val filesRole = remember(preferences) { mutableStateOf(preferences.files.role) } FeatureSection(GroupFeature.Files, allowFiles, filesRole, groupInfo, preferences, onTTLUpdated) { enable, role -> applyPrefs(preferences.copy(files = RoleGroupPreference(enable = enable, role))) } - - SectionDividerSpaced(true, maxBottomPadding = false) + } + @Composable fun SimplexLinksPreference() { val allowSimplexLinks = remember(preferences) { mutableStateOf(preferences.simplexLinks.enable) } val simplexLinksRole = remember(preferences) { mutableStateOf(preferences.simplexLinks.role) } FeatureSection(GroupFeature.SimplexLinks, allowSimplexLinks, simplexLinksRole, groupInfo, preferences, onTTLUpdated) { enable, role -> applyPrefs(preferences.copy(simplexLinks = RoleGroupPreference(enable = enable, role))) } - - SectionDividerSpaced(true, maxBottomPadding = false) + } + @Composable fun ReportsPreference() { val enableReports = remember(preferences) { mutableStateOf(preferences.reports.enable) } - FeatureSection(GroupFeature.Reports, enableReports, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + FeatureSection(GroupFeature.Reports, enableReports, null, groupInfo, preferences, onTTLUpdated, disabled = true) { enable, _ -> // enable reports in 7.0 once directory support added applyPrefs(preferences.copy(reports = GroupPreference(enable = enable))) } - SectionDividerSpaced(true, maxBottomPadding = false) + } + @Composable fun HistoryPreference() { val enableHistory = remember(preferences) { mutableStateOf(preferences.history.enable) } FeatureSection(GroupFeature.History, enableHistory, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> applyPrefs(preferences.copy(history = GroupPreference(enable = enable))) } + } + @Composable fun SupportPreference(disabled: Boolean = false, notice: String? = null, onEnable: ((() -> Unit) -> Unit)? = null) { + val enableSupport = remember(preferences) { mutableStateOf(preferences.support.enable) } + FeatureSection(GroupFeature.Support, enableSupport, null, groupInfo, preferences, onTTLUpdated, disabled = disabled, notice = notice) { enable, _ -> + applyPrefs(preferences.copy(support = GroupPreference(enable = enable))) + if (enable == GroupFeatureEnabled.ON) onEnable?.invoke { + enableSupport.value = GroupFeatureEnabled.OFF + applyPrefs(preferences.copy(support = GroupPreference(enable = GroupFeatureEnabled.OFF))) + } + } + } + ColumnWithScrollBar { + val titleId = if (groupInfo.useRelays) MR.strings.channel_preferences + else if (groupInfo.businessChat == null) MR.strings.group_preferences + else MR.strings.chat_preferences + AppBarTitle(stringResource(titleId)) + if (!groupInfo.useRelays) { + if (groupInfo.businessChat == null) { + MemberAdmissionButton(openMemberAdmission) + SectionDividerSpaced(maxBottomPadding = false) + } + TimedMessagesPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + DirectMessagesPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + FullDeletePreference() + SectionDividerSpaced(true, maxBottomPadding = false) + ReactionsPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + VoicePreference() + SectionDividerSpaced(true, maxBottomPadding = false) + FilesPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + SimplexLinksPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + ReportsPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + HistoryPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + SupportPreference(disabled = true) + } else { + TimedMessagesPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + FullDeletePreference() + SectionDividerSpaced(true, maxBottomPadding = false) + ReactionsPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + HistoryPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + SupportPreference(notice = generalGetString(MR.strings.chat_with_admins_relay_note), onEnable = { revert -> + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.enable_chats_with_admins_question), + text = generalGetString(MR.strings.chat_with_admins_relay_note), + confirmText = generalGetString(MR.strings.enable_chats_with_admins), + destructive = true, + onDismiss = revert, + onDismissRequest = revert, + ) + }) + } if (groupInfo.isOwner) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + val saveTextId = if (groupInfo.useRelays) MR.strings.save_and_notify_channel_subscribers + else MR.strings.save_and_notify_group_members ResetSaveButtons( reset = reset, save = savePrefs, - disabled = preferences == currentPreferences + disabled = preferences == currentPreferences, + saveTextId = saveTextId ) } SectionBottomSpacer() @@ -190,6 +256,8 @@ private fun FeatureSection( groupInfo: GroupInfo, preferences: FullGroupPreferences, onTTLUpdated: (Int?) -> Unit, + disabled: Boolean = false, + notice: String? = null, onSelected: (GroupFeatureEnabled, GroupMemberRole?) -> Unit ) { SectionView { @@ -199,10 +267,10 @@ private fun FeatureSection( val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON if (groupInfo.isOwner) { PreferenceToggleWithIcon( - feature.text, + feature.text(groupInfo.isChannel), icon, iconTint, - disabled = feature == GroupFeature.Reports, // remove in 6.4 + disabled = disabled, checked = enableFeature.value == GroupFeatureEnabled.ON, ) { checked -> onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF, enableForRole?.value) @@ -231,7 +299,7 @@ private fun FeatureSection( } } else { InfoRow( - feature.text, + feature.text(groupInfo.isChannel), enableFeature.value.text, icon = icon, iconTint = iconTint, @@ -249,25 +317,28 @@ private fun FeatureSection( onSelected(enableFeature.value, null) } } - SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.isOwner)) + SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.isOwner, groupInfo.isChannel)) + if (notice != null) { + SectionTextFooter(notice) + } } @Composable -private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { +private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean, saveTextId: StringResource) { SectionView { SectionItemView(reset, disabled = disabled) { Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } SectionItemView(save, disabled = disabled) { - Text(stringResource(MR.strings.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + Text(stringResource(saveTextId), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } } -private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { +private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit, confirmTextId: StringResource) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.save_preferences_question), - confirmText = generalGetString(MR.strings.save_and_notify_group_members), + confirmText = generalGetString(confirmTextId), dismissText = generalGetString(MR.strings.exit_without_saving), onConfirm = save, onDismiss = revert, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index f15f70673a..6e91ad92d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -32,10 +32,11 @@ import java.net.URI fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { GroupProfileLayout( close = close, + groupInfo = groupInfo, groupProfile = groupInfo.groupProfile, saveProfile = { p -> withBGApi { - val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p) + val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p, groupInfo.useRelays) if (gInfo != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, gInfo) @@ -50,9 +51,11 @@ fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl @Composable fun GroupProfileLayout( close: () -> Unit, + groupInfo: GroupInfo, groupProfile: GroupProfile, saveProfile: (GroupProfile) -> Unit, ) { + val isChannel = groupInfo.useRelays val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) } val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) } @@ -71,7 +74,7 @@ fun GroupProfileLayout( if (dataUnchanged || !canUpdateProfile(displayName.value, shortDescr.value, groupProfile)) { close() } else { - showUnsavedChangesAlert({ + showUnsavedChangesAlert(isChannel, { saveProfile( groupProfile.copy( displayName = displayName.value.trim(), @@ -103,7 +106,11 @@ fun GroupProfileLayout( Modifier.fillMaxWidth() .padding(horizontal = DEFAULT_PADDING) ) { - ReadableText(MR.strings.group_profile_is_stored_on_members_devices, TextAlign.Center) + ReadableText( + if (isChannel) MR.strings.channel_profile_is_stored_on_subscribers_devices + else MR.strings.group_profile_is_stored_on_members_devices, + TextAlign.Center + ) Box( Modifier .fillMaxWidth() @@ -112,7 +119,7 @@ fun GroupProfileLayout( ) { Box(contentAlignment = Alignment.TopEnd) { Box(contentAlignment = Alignment.Center) { - ProfileImage(108.dp, profileImage.value, color = MaterialTheme.colors.secondary.copy(alpha = 0.1f)) + ProfileImage(108.dp, profileImage.value, icon = groupInfo.chatIconName, color = MaterialTheme.colors.secondary.copy(alpha = 0.1f)) EditImageButton { scope.launch { bottomSheetModalState.show() } } } if (profileImage.value != null) { @@ -122,7 +129,7 @@ fun GroupProfileLayout( } Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( - stringResource(MR.strings.group_display_name_field), + stringResource(if (isChannel) MR.strings.channel_display_name_field else MR.strings.group_display_name_field), fontSize = 16.sp ) if (!isValidNewProfileName(displayName.value, groupProfile)) { @@ -136,7 +143,7 @@ fun GroupProfileLayout( if (groupProfile.fullName.trim().isNotEmpty() && groupProfile.fullName.trim() != groupProfile.displayName.trim()) { Spacer(Modifier.height(DEFAULT_PADDING)) Text( - stringResource(MR.strings.group_full_name_field), + stringResource(if (isChannel) MR.strings.channel_full_name_field else MR.strings.group_full_name_field), fontSize = 16.sp, modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) ) @@ -164,9 +171,10 @@ fun GroupProfileLayout( Spacer(Modifier.height(DEFAULT_PADDING)) val enabled = !dataUnchanged && canUpdateProfile(displayName.value, shortDescr.value, groupProfile) + val saveProfileLabel = if (isChannel) MR.strings.save_channel_profile else MR.strings.save_group_profile if (enabled) { Text( - stringResource(MR.strings.save_group_profile), + stringResource(saveProfileLabel), modifier = Modifier.clickable { saveProfile( groupProfile.copy( @@ -181,7 +189,7 @@ fun GroupProfileLayout( ) } else { Text( - stringResource(MR.strings.save_group_profile), + stringResource(saveProfileLabel), color = MaterialTheme.colors.secondary ) } @@ -204,10 +212,10 @@ private fun canUpdateProfile(displayName: String, shortDescr: String, groupProfi private fun isValidNewProfileName(displayName: String, groupProfile: GroupProfile): Boolean = displayName == groupProfile.displayName || isValidDisplayName(displayName.trim()) -private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { +private fun showUnsavedChangesAlert(isChannel: Boolean, save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.save_preferences_question), - confirmText = generalGetString(MR.strings.save_and_notify_group_members), + confirmText = generalGetString(if (isChannel) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members), dismissText = generalGetString(MR.strings.exit_without_saving), onConfirm = save, onDismiss = revert, @@ -224,6 +232,7 @@ fun PreviewGroupProfileLayout() { SimpleXTheme { GroupProfileLayout( close = {}, + groupInfo = GroupInfo.sampleData, groupProfile = GroupProfile.sampleData, saveProfile = { _ -> } ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt index 48171bfeb7..7c9db58316 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -34,7 +34,7 @@ fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () -> fun saveAdmission(afterSave: () -> Unit = {}) { withBGApi { val gp = gInfo.groupProfile.copy(memberAdmission = admission) - val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp, gInfo.useRelays) if (g != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, g) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index e696128288..3d76c845ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -116,7 +116,10 @@ private fun ModalData.MemberSupportViewLayout( if (membersWithChats.isEmpty()) { item { Box(Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) { - Text(generalGetString(MR.strings.no_support_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Text( + generalGetString(if (groupInfo.fullGroupPreferences.support.on) MR.strings.no_support_chats else MR.strings.support_chats_disabled), + color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center + ) } } } else { @@ -162,7 +165,9 @@ private fun ModalData.MemberSupportViewLayout( @Composable fun SupportChatRow(member: GroupMember) { fun memberStatus(): String { - return if (member.activeConn?.connDisabled == true) { + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.member_info_member_failed) + } else if (member.activeConn?.connDisabled == true) { generalGetString(MR.strings.member_info_member_disabled) } else if (member.activeConn?.connInactive == true) { generalGetString(MR.strings.member_info_member_inactive) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 1e99c7f527..927e9940b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -45,7 +45,7 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () welcome = null } val groupProfileUpdated = gInfo.groupProfile.copy(description = welcome) - val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated) + val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated, gInfo.useRelays) if (res != null) { gInfo = res withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt new file mode 100644 index 0000000000..3c3e4baf49 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt @@ -0,0 +1,79 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun CIChatLinkHeader( + chatLink: MsgChatLink, + ownerSig: LinkOwnerSig?, + hasText: Boolean, +) { + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 4.dp) + ) { + Row( + Modifier.defaultMinSize(minWidth = 220.dp) + ) { + ProfileImage( + size = 54.dp, + image = chatLink.image, + icon = chatLink.iconRes, + color = if (isInDarkTheme()) FileDark else FileLight + ) + Spacer(Modifier.width(8.dp)) + Column( + Modifier.defaultMinSize(minHeight = 54.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + chatLink.displayName, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + val fn = chatLink.fullName + if (fn.isNotEmpty() && fn != chatLink.displayName) { + Text(fn, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) + Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + chatLink.shortDescription?.let { descr -> + Text( + descr, + color = MaterialTheme.colors.secondary, + fontSize = 13.sp, + lineHeight = 18.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + chatLink.infoLine(signed = ownerSig != null), + color = MaterialTheme.colors.secondary, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + Text( + stringResource(MR.strings.tap_to_open), + color = MaterialTheme.colors.primary, + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 542623028a..afd55ed928 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -127,7 +127,8 @@ fun CIFileView( fun fileIndicator() { Box( Modifier - .size(42.sp.toDp() * sizeMultiplier) + .padding(top = 2.sp.toDp()) + .size(40.sp.toDp() * sizeMultiplier) .clip(RoundedCornerShape(4.sp.toDp() * sizeMultiplier)), contentAlignment = Alignment.Center ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 39bb9545e1..9b8393f66a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* @@ -52,15 +53,12 @@ fun CIGroupInvitationView( else if (isInDarkTheme()) FileDark else FileLight Row( - Modifier - .defaultMinSize(minWidth = 220.dp) - .padding(vertical = 4.dp) - .padding(end = 2.dp) + Modifier.defaultMinSize(minWidth = 220.dp) ) { - ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled, color = iconColor) - Spacer(Modifier.padding(horizontal = 3.dp)) + ProfileImage(size = 54.dp, image = groupInvitation.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled, color = iconColor) + Spacer(Modifier.width(8.dp)) Column( - Modifier.defaultMinSize(minHeight = 60.dp), + Modifier.defaultMinSize(minHeight = 54.dp), verticalArrangement = Arrangement.Center ) { Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis) @@ -98,8 +96,7 @@ fun CIGroupInvitationView( Box( Modifier .width(IntrinsicSize.Min) - .padding(vertical = 3.dp) - .padding(start = 8.dp, end = 12.dp), + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), contentAlignment = Alignment.BottomEnd ) { Box( @@ -112,10 +109,10 @@ fun CIGroupInvitationView( ) { groupInfoView() val secondaryColor = MaterialTheme.colors.secondary - Column(Modifier.padding(top = 2.dp, start = 5.dp)) { - Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) + Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) + Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { if (action) { - Text(groupInvitationStr()) + Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) Text( buildAnnotatedString { append(generalGetString(if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join)) @@ -131,7 +128,9 @@ fun CIGroupInvitationView( buildAnnotatedString { append(groupInvitationStr()) withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } - } + }, + fontSize = 13.sp, + lineHeight = 18.sp, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 1be2110b1f..8bfbea9fa6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* @Composable fun CIImageView( @@ -38,6 +38,7 @@ fun CIImageView( receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } + val previewBitmap = remember(image) { base64ToBitmap(image) } @Composable fun progressIndicator() { CircularProgressIndicator( @@ -144,7 +145,7 @@ fun CIImageView( .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentAlignment = Alignment.Center ) { - imageView(base64ToBitmap(image), onClick = { + imageView(previewBitmap, onClick = { if (fileSource != null) { openFile(fileSource) } @@ -178,14 +179,16 @@ fun CIImageView( Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) + .then( + if (!smallView) { + val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH + Modifier.width(w).aspectRatio((previewBitmap.width.toFloat() / previewBitmap.height.toFloat()).coerceAtLeast(1f / 2.33f)) + } else Modifier + ) .desktopModifyBlurredState(!smallView, blurred, showMenu), contentAlignment = Alignment.TopEnd ) { - val res: MutableState?> = remember { - mutableStateOf( - if (chatModel.connectedToRemote()) null else runBlocking { imageAndFilePath(file) } - ) - } + val res: MutableState?> = remember { mutableStateOf(null) } if (chatModel.connectedToRemote()) { LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { withBGApi { @@ -195,9 +198,9 @@ fun CIImageView( } } } else { - KeyChangeEffect(file) { + LaunchedEffect(file) { if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { - res.value = imageAndFilePath(file) + res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) } } } } @@ -206,7 +209,7 @@ fun CIImageView( val (imageBitmap, data, _) = loaded SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) } else { - imageView(base64ToBitmap(image), onClick = { + imageView(previewBitmap, onClick = { if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 6f873035f1..64288d9055 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -10,6 +10,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* @@ -47,8 +48,8 @@ private val msgTailMaxHeightDp = msgTailWidthDp * 1.732f // 60deg val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary) -fun chatEventText(ci: ChatItem): AnnotatedString = - chatEventText(ci.content.text, ci.timestampText) +fun chatEventText(ci: ChatItem, isChannel: Boolean = false): AnnotatedString = + chatEventText(ci.content.text(isChannel), ci.timestampText) fun chatEventText(eventText: String, ts: String): AnnotatedString = buildAnnotatedString { @@ -61,6 +62,7 @@ data class ChatItemReactionMenuItem ( val onClick: (() -> Unit)? ) +// Spec: spec/client/chat-view.md#ChatItemView @Composable fun ChatItemView( chatsCtx: ChatModel.ChatsContext, @@ -108,6 +110,7 @@ fun ChatItemView( showTimestamp: Boolean, itemSeparation: ItemSeparation, preview: Boolean = false, + swipeOffset: Float = 0f, ) { val cInfo = chat.chatInfo val uriHandler = LocalUriHandler.current @@ -297,8 +300,11 @@ fun ChatItemView( } Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { - Row(verticalAlignment = Alignment.CenterVertically) { - val bubbleInteractionSource = remember { MutableInteractionSource() } + val canReply = (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && + cInfo !is ChatInfo.Local && !cItem.isReport && !cItem.meta.isLive && cItem.meta.itemDeleted == null + Box { + Row(verticalAlignment = Alignment.CenterVertically) { + val bubbleInteractionSource = remember { MutableInteractionSource() } val bubbleHovered = bubbleInteractionSource.collectIsHoveredAsState() if (cItem.chatDir.sent) { GoToItemButton(true, bubbleHovered) @@ -368,7 +374,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -386,7 +392,7 @@ fun ChatItemView( if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) } - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) Divider() SelectItemAction(showMenu, selectChatItem) } @@ -396,7 +402,7 @@ fun ChatItemView( if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { MsgReactionsMenu() } - if (cItem.meta.itemDeleted == null && !live && !cItem.localNote) { + if (cItem.meta.itemDeleted == null && !live && !cItem.localNote && cInfo.sendMsgEnabled) { ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) @@ -476,7 +482,7 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } if (cItem.chatDir !is CIDirection.GroupSnd) { val groupInfo = cItem.memberToModerate(cInfo)?.first @@ -502,7 +508,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -512,7 +518,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -526,7 +532,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -535,7 +541,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -552,7 +558,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -563,30 +569,25 @@ fun ChatItemView( @Composable fun ContentItem() { val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - MarkedDeletedItemDropdownMenu() - } else { - if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { - if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) - } else { - framedItemView() - } + if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) } else { framedItemView() } - MsgContentItemDropdownMenu() + } else { + framedItemView() } + MsgContentItemDropdownMenu() } @Composable fun LegacyDeletedItem() { DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -611,7 +612,7 @@ fun ChatItemView( return if (count <= 1) { null } else if (ns.isEmpty()) { - generalGetString(MR.strings.rcv_group_events_count).format(count) + generalGetString(if (cInfo.isChannel) MR.strings.rcv_channel_events_count else MR.strings.rcv_group_events_count).format(count) } else if (count > ns.size) { members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) } else { @@ -628,9 +629,9 @@ fun ChatItemView( buildAnnotatedString { withStyle(chatEventStyle) { append(memberDisplayName) } append(" ") - }.plus(chatEventText(cItem)) + }.plus(chatEventText(cItem, cInfo.isChannel)) } else { - chatEventText(cItem) + chatEventText(cItem, cInfo.isChannel) } } @@ -642,7 +643,7 @@ fun ChatItemView( @Composable fun PendingReviewEventItemView() { Text( buildAnnotatedString { - withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) } + withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text(cInfo.isChannel)) } }, Modifier.padding(horizontal = 6.dp, vertical = 6.dp) ) @@ -660,7 +661,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -679,119 +680,123 @@ fun ChatItemView( } @Composable - fun E2EEInfoNoPQText() { - e2eeInfoText(MR.strings.e2ee_info_no_pq) + fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { + e2eeInfoText(when (e2EEInfo.pqEnabled) { + true -> MR.strings.e2ee_info_pq + false -> MR.strings.e2ee_info_no_pq + null -> MR.strings.e2ee_info_e2ee + }) } @Composable - fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { - if (e2EEInfo.pqEnabled != null) { - if (e2EEInfo.pqEnabled) { - e2eeInfoText(MR.strings.e2ee_info_pq) - } else { - E2EEInfoNoPQText() - } - } else { - e2eeInfoText(MR.strings.e2ee_info_e2ee) - } + fun GroupE2EEInfoText(e2EEInfo: E2EEInfo) { + e2eeInfoText(if (e2EEInfo.public == true) MR.strings.e2ee_info_no_e2ee else MR.strings.e2ee_info_no_pq) } - when (val c = cItem.content) { - is CIContent.SndMsgContent -> ContentItem() - is CIContent.RcvMsgContent -> ContentItem() - is CIContent.SndDeleted -> LegacyDeletedItem() - is CIContent.RcvDeleted -> LegacyDeletedItem() - is CIContent.SndCall -> CallItem(c.status, c.duration) - is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> if (developerTools) { - IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) - DeleteItemMenu() - } else { - Box(Modifier.size(0.dp)) {} - } - is CIContent.RcvDecryptionError -> { - CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) - DeleteItemMenu() - } - is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.RcvDirectEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupEventContent -> { - when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView() - else -> EventItemView() + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemDropdownMenu() + } else { + when (val c = cItem.content) { + is CIContent.SndMsgContent -> ContentItem() + is CIContent.RcvMsgContent -> ContentItem() + is CIContent.SndDeleted -> LegacyDeletedItem() + is CIContent.RcvDeleted -> LegacyDeletedItem() + is CIContent.SndCall -> CallItem(c.status, c.duration) + is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> if (developerTools) { + IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) + DeleteItemMenu() + } else { + Box(Modifier.size(0.dp)) {} } - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupEventContent -> { - when (c.sndGroupEvent) { - is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView() - else -> EventItemView() + is CIContent.RcvMsgErrorContent -> { + RcvMsgErrorItemView(c.rcvMsgError, cItem, showTimestamp, cInfo.timedMessagesTTL) + } + is CIContent.RcvDecryptionError -> { + CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) + DeleteItemMenu() + } + is CIContent.RcvGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.SndGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView() + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + when (c.sndGroupEvent) { + is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView() + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatPreference -> { + val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null + CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) + DeleteItemMenu() + } + is CIContent.SndChatPreference -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeatureRejected -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndModerated -> DeletedItem() + is CIContent.RcvModerated -> DeletedItem() + is CIContent.RcvBlocked -> DeletedItem() + is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.SndGroupE2EEInfo -> GroupE2EEInfoText(c.e2eeInfo) + is CIContent.RcvGroupE2EEInfo -> GroupE2EEInfoText(c.e2eeInfo) + is CIContent.ChatBanner -> Spacer(modifier = Modifier.size(0.dp)) + is CIContent.InvalidJSON -> { + CIInvalidJSONView(c.json) + DeleteItemMenu() } - MsgContentItemDropdownMenu() - } - is CIContent.RcvConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.SndConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndChatFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatPreference -> { - val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null - CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) - DeleteItemMenu() - } - is CIContent.SndChatPreference -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndModerated -> DeletedItem() - is CIContent.RcvModerated -> DeletedItem() - is CIContent.RcvBlocked -> DeletedItem() - is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.ChatBanner -> Spacer(modifier = Modifier.size(0.dp)) - is CIContent.InvalidJSON -> { - CIInvalidJSONView(c.json) - DeleteItemMenu() } } } @@ -799,6 +804,15 @@ fun ChatItemView( if (!cItem.chatDir.sent) { GoToItemButton(false, bubbleHovered) } + } + if (canReply && swipeOffset < 0) { + Icon( + painterResource(MR.images.ic_reply), + contentDescription = null, + modifier = Modifier.align(Alignment.CenterEnd).offset(x = 26.dp).size(18.dp).alpha(minOf(1f, -swipeOffset / 30f)), + tint = MaterialTheme.colors.secondary + ) + } } if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { ChatItemReactions() @@ -852,6 +866,7 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( chatsCtx: ChatModel.ChatsContext, + cInfo: ChatInfo, cItem: ChatItem, revealed: State, showMenu: MutableState, @@ -884,13 +899,13 @@ fun DeleteItemAction( deleteMessages = { ids, _ -> deleteMessages(ids) } ) } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } }, color = Color.Red @@ -1295,6 +1310,7 @@ fun shapeStyleWithTail(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVis is CIContent.SndMsgContent, is CIContent.RcvMsgContent, is CIContent.RcvDecryptionError, + is CIContent.RcvMsgErrorContent, is CIContent.SndDeleted, is CIContent.RcvDeleted, is CIContent.RcvIntegrityError, @@ -1356,7 +1372,9 @@ fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction ) } -fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { +fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, chatInfo: ChatInfo, deleteMessage: (Long, CIDeleteMode) -> Unit) { + val canDeleteForEveryone = chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport + val editorial = publicGroupEditor(chatInfo) AlertManager.shared.showAlertDialogButtons( title = generalGetString(MR.strings.delete_message__question), text = questionText, @@ -1367,11 +1385,18 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes .padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = Arrangement.Center, ) { - TextButton(onClick = { - deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) - AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } - if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) { + if (editorial) { + TextButton(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmHistory) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.from_history), color = MaterialTheme.colors.error) } + } else { + TextButton(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + } + if (canDeleteForEveryone) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) @@ -1383,7 +1408,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes ) } -fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: Boolean, deleteMessages: (List, Boolean) -> Unit) { +fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: Boolean, editorial: Boolean = false, deleteMessages: (List, Boolean) -> Unit) { AlertManager.shared.showAlertDialogButtons( title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size), text = questionText, @@ -1397,7 +1422,7 @@ fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: TextButton(onClick = { deleteMessages(itemIds, false) AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + }) { Text(stringResource(if (editorial) MR.strings.from_history else MR.strings.for_me_only), color = MaterialTheme.colors.error) } if (forAll) { TextButton(onClick = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 7aca0466f9..3bcd02411f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -1,9 +1,11 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle @@ -12,6 +14,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MREmojiChar import chat.simplex.common.ui.theme.EmojiFont +import chat.simplex.common.views.chat.* import java.sql.Timestamp val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont) @@ -19,11 +22,20 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF @Composable fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) { + val emojiText = chatItem.content.text.trim() + val isSelected = setupEmojiSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, emojiText.length) + Column( Modifier.padding(vertical = 8.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - EmojiText(chatItem.content.text) + if (isSelected) { + Box(Modifier.background(SelectionHighlightColor)) { + EmojiText(chatItem.content.text) + } + } else { + EmojiText(chatItem.content.text) + } CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 9a9626b1b8..f55c49fdd1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -21,8 +21,9 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.ComposeState +import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -55,7 +56,7 @@ fun FramedItemView( } @Composable - fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean) { + fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean, stripLink: String? = null, prefix: AnnotatedString? = null) { MarkdownText( qi.text, qi.formattedText, @@ -66,11 +67,13 @@ fun FramedItemView( linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, showTimestamp = showTimestamp, + prefix = prefix, + stripLink = stripLink, ) } @Composable - fun ciQuotedMsgView(qi: CIQuote) { + fun ciQuotedMsgView(qi: CIQuote, stripLink: String? = null, prefix: AnnotatedString? = null) { Box( Modifier // this width limitation prevents crash on calculating constraints that may happen if you post veeeery long message and then quote it. @@ -89,10 +92,10 @@ fun FramedItemView( style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary), maxLines = 1 ) - ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp) + ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp, stripLink = stripLink, prefix = prefix) } } else { - ciQuotedMsgTextView(qi, lines = 3, showTimestamp = showTimestamp) + ciQuotedMsgTextView(qi, lines = 3, showTimestamp = showTimestamp, stripLink = stripLink, prefix = prefix) } } } @@ -144,7 +147,7 @@ fun FramedItemView( Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } - val imageBitmap = base64ToBitmap(qi.content.image) + val imageBitmap = remember(qi.content.image) { base64ToBitmap(qi.content.image) } Image( imageBitmap, contentDescription = stringResource(MR.strings.image_descr), @@ -156,7 +159,7 @@ fun FramedItemView( Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } - val imageBitmap = base64ToBitmap(qi.content.image) + val imageBitmap = remember(qi.content.image) { base64ToBitmap(qi.content.image) } Image( imageBitmap, contentDescription = stringResource(MR.strings.video_descr), @@ -177,6 +180,20 @@ fun FramedItemView( tint = if (isInDarkTheme()) FileDark else FileLight ) } + is MsgContent.MCChat -> { + val prefix = buildAnnotatedString { + append(qi.content.chatLink.displayName + if (qi.content.text != qi.content.chatLink.connLinkStr) " - " else "") + } + Box(Modifier.fillMaxWidth().weight(1f)) { + ciQuotedMsgView(qi, stripLink = qi.content.chatLink.connLinkStr, prefix = prefix) + } + Icon( + painterResource(qi.content.chatLink.smallIconRes), + null, + Modifier.padding(top = 6.dp, end = 4.dp).size(22.dp), + tint = if (isInDarkTheme()) FileDark else FileLight + ) + } else -> ciQuotedMsgView(qi) } } @@ -329,10 +346,26 @@ fun FramedItemView( CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } + is MsgContent.MCChat -> { + val hasText = mc.text != mc.chatLink.connLinkStr + Box( + Modifier.combinedClickable( + onClick = { + withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } + }, + onLongClick = { showMenu.value = true } + ) + ) { + CIChatLinkHeader(chatLink = mc.chatLink, ownerSig = mc.ownerSig, hasText = hasText) + } + if (hasText) { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, stripLink = mc.chatLink.connLinkStr) + } + } is MsgContent.MCReport -> { val prefix = buildAnnotatedString { withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { - append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + append(itemPrefixText(ci)) } } CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) @@ -366,11 +399,14 @@ fun CIMarkdownText( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean, showTimestamp: Boolean, - prefix: AnnotatedString? = null + prefix: AnnotatedString? = null, + stripLink: String? = null ) { - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { - val chatInfo = chat.chatInfo - val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + val chatInfo = chat.chatInfo + val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, @@ -379,7 +415,10 @@ fun CIMarkdownText( chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null }, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, + stripLink = stripLink, + selectionRange = selection.highlightRange, + onTextLayoutResult = selection.onTextLayoutResult ) } } @@ -437,7 +476,7 @@ fun PriorityLayout( ) { measureable, constraints -> // Find important element which should tell what max width other elements can use // Expecting only one such element. Can be less than one but not more - // Constrain max image height to prevent crashes and scroll issues from images with extreme aspect ratios + // Max image height for chat item display, taller images are cropped val maxImageHeight = (constraints.maxWidth * 2.33f).toInt().coerceAtMost(constraints.maxHeight) val imageConstraints = constraints.copy(maxHeight = maxImageHeight) val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(imageConstraints) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index d528396193..08b6520dfa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MsgErrorType +import chat.simplex.common.model.RcvMsgError import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString @@ -73,6 +74,19 @@ fun CIMsgError(ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?, onC } } +@Composable +fun RcvMsgErrorItemView(rcvMsgError: RcvMsgError, ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?) { + CIMsgError(ci, showTimestamp, timedMessagesTTL) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.alert_title_msg_error), + text = when (rcvMsgError) { + is RcvMsgError.Dropped -> String.format(generalGetString(MR.strings.alert_text_msg_reception_error), rcvMsgError.attempts) + is RcvMsgError.ParseError -> rcvMsgError.parseError + } + ) + } +} + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 60595fc255..3358a23e1e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -10,6 +10,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.* @@ -22,6 +23,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* import kotlinx.coroutines.* @@ -55,6 +57,42 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) +// Display text for a single formatted segment — must be coordinated with MarkdownText. +fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = + when (ft.format) { + is Format.Mention -> { + val mention = ci.mentions?.get(ft.format.memberName) + if (mention?.memberRef != null) { + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName + else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" + mentionText(name) + } else if (mention != null) mentionText(ft.format.memberName) + else ft.text + } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { + val t = ft.format.showText + ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null + if (t != null) "$t ${ft.format.viaHosts}" else ft.text + } + is Format.Command -> ft.text + else -> ft.text + } + +// Full display text for a chat item — joins segment display texts. +fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text + return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +} + +// Display-only prefix rendered before ci.text (e.g. "Spam: " for reports). +// Renderers and selection code MUST share this string — otherwise selection offsets drift from screen. +fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) { + is MsgContent.MCReport -> if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: " + else -> "" +} + +// Text transformations in MarkdownText must match itemSegmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, @@ -77,8 +115,13 @@ fun MarkdownText ( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, showTimestamp: Boolean = true, - prefix: AnnotatedString? = null + prefix: AnnotatedString? = null, + stripLink: String? = null, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null ) { + val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text + val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } @@ -126,19 +169,27 @@ fun MarkdownText ( ) } if (formattedText == null) { + var selectableEnd = 0 val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) + selectableEnd = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } + if (onTextLayoutResult != null) { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + } else { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } } else { + var selectableEnd = 0 var hasLinks = false var hasSecrets = false var hasCommands = false @@ -153,6 +204,7 @@ fun MarkdownText ( is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } + is Format.Small -> withStyle(ft.format.style) { append(ft.text) } is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } is Format.Secret -> { val ftStyle = ft.format.style @@ -246,6 +298,7 @@ fun MarkdownText ( is Format.Unknown -> append(ft.text) } } + selectableEnd = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } @@ -254,9 +307,10 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } + val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { - val icon = remember { mutableStateOf(PointerIcon.Default) } - ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + val icon = remember { mutableStateOf(PointerIcon.Text) } + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -292,17 +346,22 @@ fun MarkdownText ( if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { PointerIcon.Hand } else { - PointerIcon.Default + PointerIcon.Text } }, shouldConsumeEvent = { offset -> annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) - } + }, + onTextLayout = { onTextLayoutResult?.invoke(it) } ) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + if (onTextLayoutResult != null) { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + } else { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } } } } @@ -313,6 +372,7 @@ fun ClickableText( text: AnnotatedString, modifier: Modifier = Modifier, style: TextStyle = TextStyle.Default, + selectionRange: IntRange? = null, softWrap: Boolean = true, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, @@ -355,7 +415,7 @@ fun ClickableText( BasicText( text = text, - modifier = modifier.then(pressIndicator), + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator), style = style, softWrap = softWrap, overflow = overflow, @@ -367,6 +427,42 @@ fun ClickableText( ) } +@Composable +private fun SelectableText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null +) { + val layoutResult = remember { mutableStateOf(null) } + + BasicText( + text = text, + modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)), + style = style, + maxLines = maxLines, + overflow = overflow, + onTextLayout = { + layoutResult.value = it + onTextLayoutResult?.invoke(it) + } + ) +} + +private fun selectionHighlight(selectionRange: IntRange?, textLength: Int, layoutResult: State): Modifier = + if (selectionRange != null) { + Modifier.drawBehind { + layoutResult.value?.let { result -> + if (selectionRange.first <= selectionRange.last && selectionRange.last + 1 <= textLength) { + drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor) + } + } + } + } else Modifier + fun openBrowserAlert(uri: String, uriHandler: UriHandler) { val (res, err) = sanitizeUri(uri) if (res == null) { @@ -445,4 +541,21 @@ private fun isRtl(s: CharSequence): Boolean { return false } -private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" +fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" + +fun stripTextLink(text: String, link: String): String = + if (text == link) "" + else if (text.endsWith("\n$link")) text.dropLast(link.length + 1) + else text + +fun stripFormattedTextLink(ft: List?, link: String): List? { + if (ft == null || ft.isEmpty() || ft.last().text != link) return ft + val result = ft.toMutableList() + result.removeAt(result.lastIndex) + val i = result.lastIndex + if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + result[i] = FormattedText(result[i].text.dropLast(1), null) + if (result[i].text.isEmpty()) result.removeLast() + } + return result.ifEmpty { null } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 4a8e9e5193..0cec9ab773 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -32,6 +32,7 @@ import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.datetime.Clock +// Spec: spec/client/chat-list.md#ChatListNavLinkView @Composable fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { val showMenu = remember { mutableStateOf(false) } @@ -62,12 +63,11 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { when (chat.chatInfo) { is ChatInfo.Direct -> { - val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, click = defaultClickAction, @@ -87,7 +87,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction) } }, click = defaultClickAction, @@ -107,7 +107,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, click = defaultClickAction, @@ -229,6 +229,7 @@ suspend fun openChat( } else { ChatPagination.Initial(ChatPagination.INITIAL_COUNT) }, + contentTag = null, "", openAroundItemId ) @@ -242,11 +243,12 @@ suspend fun openLoadedChat(chat: Chat) { } } -suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, search: String) { +suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, contentTag: MsgContentTag?, search: String) { withContext(Dispatchers.Main) { chatsCtx.chatItems.clearAndNotify() } - apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search) + val pagination = if (search.isNotEmpty() || contentTag != null) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination, contentTag, search) } suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { @@ -314,7 +316,7 @@ fun GroupMenuItems( } } GroupMemberStatus.MemAccepted -> { - if (groupInfo.membership.memberCurrentOrPending) { + if (groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { @@ -336,7 +338,7 @@ fun GroupMenuItems( } } ClearChatAction(chat, showMenu) - if (groupInfo.membership.memberCurrentOrPending) { + if (groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { @@ -744,7 +746,6 @@ fun acceptContactRequest( } inProgress?.value = false } - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) close?.invoke(chat) } else { inProgress?.value = false @@ -1057,7 +1058,6 @@ fun PreviewChatListNavLinkDirect() { null, null, null, - null, disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, @@ -1103,7 +1103,6 @@ fun PreviewChatListNavLinkGroup() { null, null, null, - null, disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 2109e21bfe..01dcd021f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -24,7 +24,13 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize import chat.simplex.common.AppLock +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts @@ -46,7 +52,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds -enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } +enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, CHANNELS, BUSINESS, NOTES } sealed class ActiveFilter { data class PresetTag(val tag: PresetTagKind) : ActiveFilter() @@ -84,44 +90,89 @@ private fun showNewChatSheet(oneHandUI: State) { @Composable fun ToggleChatListCard() { - ChatListCard( - close = { - appPrefs.oneHandUICardShown.set(true) - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.one_hand_ui), - text = generalGetString(MR.strings.one_hand_ui_change_instruction), - ) - } + val oneHandUI = remember { appPrefs.oneHandUI.state } + val onClose = { + appPrefs.oneHandUICardShown.set(true) + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.one_hand_ui), + text = generalGetString(MR.strings.one_hand_ui_change_instruction), + ) + } + val activeBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) + val selectedBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) + Row( + Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(percent = 50)), + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - Column( - modifier = Modifier - .padding(horizontal = DEFAULT_PADDING) - .padding(top = DEFAULT_PADDING) - ) { - Row( - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(MR.strings.one_hand_ui_card_title), style = MaterialTheme.typography.h3) - } - Row( - Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(MR.strings.one_hand_ui), Modifier.weight(10f), style = MaterialTheme.typography.body1) - - Spacer(Modifier.fillMaxWidth().weight(1f)) - - SharedPreferenceToggle( - appPrefs.oneHandUI, - enabled = true - ) - } + ToolbarSegment( + icon = MR.images.ic_mobile_3, + text = stringResource(MR.strings.one_hand_ui_bottom_bar), + isSelected = oneHandUI.value, + selectedBg = selectedBg, + activeBg = activeBg, + modifier = Modifier.weight(1f) + ) { appPrefs.oneHandUI.set(true) } + Box(Modifier.weight(1f).fillMaxHeight()) { + ToolbarSegment( + icon = MR.images.ic_mobile_4, + text = stringResource(MR.strings.one_hand_ui_top_bar), + isSelected = !oneHandUI.value, + selectedBg = selectedBg, + activeBg = activeBg, + modifier = Modifier.fillMaxSize() + ) { appPrefs.oneHandUI.set(false) } + Icon( + painterResource(MR.images.ic_close), null, + Modifier + .align(Alignment.CenterEnd) + .padding(end = 4.dp) + .clip(CircleShape) + .clickable(onClick = onClose) + .padding(8.dp) + .size(16.dp), + tint = MaterialTheme.colors.secondary + ) } } } +@Composable +private fun ToolbarSegment( + icon: ImageResource, + text: String, + isSelected: Boolean, + selectedBg: Color, + activeBg: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Row( + modifier + .fillMaxHeight() + .background(if (isSelected) selectedBg else activeBg) + .then(if (!isSelected) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(icon), null, Modifier.size(20.dp), + tint = if (isSelected) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + Spacer(Modifier.width(8.dp)) + Text( + text, + color = if (isSelected) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground, + style = MaterialTheme.typography.body1 + ) + } +} + +// Spec: spec/client/chat-list.md#ChatListView @Composable fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -233,53 +284,120 @@ private fun ChatListCard( } } +private const val BANNER_IMAGE_RATIO = 800f / 505f + @Composable -private fun AddressCreationCard() { - ChatListCard( - close = { - appPrefs.addressCreationCardShown.set(true) - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.simplex_address), - text = generalGetString(MR.strings.address_creation_instruction), +private fun BannerGradientBox(isDark: Boolean, content: @Composable () -> Unit) { + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + val gp = gradientPoints(1f / BANNER_IMAGE_RATIO, scale) + var size by remember { mutableStateOf(IntSize.Zero) } + val brush = remember(size, isDark) { + if (size.width > 0 && size.height > 0) { + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * size.width, gp.startY * size.height), + end = Offset(gp.endX * size.width, gp.endY * size.height) ) - }, - onCardClick = { - ModalManager.start.showModal { - UserAddressLearnMore(showCreateAddressButton = true) - } + } else { + Brush.linearGradient(colorStops = stops) } - ) { - Box(modifier = Modifier.matchParentSize().padding(end = (DEFAULT_PADDING_HALF + 2.dp) * fontSizeSqrtMultiplier, bottom = 2.dp), contentAlignment = Alignment.BottomEnd) { - TextButton( - onClick = { - ModalManager.start.showModalCloseable { close -> - UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close) - } - }, - ) { - Text(stringResource(MR.strings.create_address_button), style = MaterialTheme.typography.body1) - } + } + Box( + Modifier.fillMaxWidth().aspectRatio(BANNER_IMAGE_RATIO).background(brush).onSizeChanged { size = it }, + contentAlignment = Alignment.Center + ) { content() } +} + +@Composable +private fun ConnectBannerCard() { + val isDark = isInDarkTheme() + val labelBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) + val buttonSize = 30.dp * fontSizeSqrtMultiplier + val gap = 3.dp * fontSizeSqrtMultiplier + + Column(horizontalAlignment = Alignment.End) { + IconButton( + onClick = { appPrefs.addressCreationCardShown.set(true) }, + modifier = Modifier.size(buttonSize) + ) { + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.icon_descr_close_button), + modifier = Modifier + .size(buttonSize) + .background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f), CircleShape) + .padding(buttonSize * 0.15f), + tint = MaterialTheme.colors.secondary + ) } + Spacer(Modifier.height(gap)) Row( Modifier .fillMaxWidth() - .padding(DEFAULT_PADDING), - verticalAlignment = Alignment.CenterVertically + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(18.dp)) ) { - Box(Modifier.padding(vertical = 4.dp)) { - Box(Modifier.background(MaterialTheme.colors.primary, CircleShape).padding(12.dp)) { - ProfileImage(size = 37.dp, null, icon = MR.images.ic_mail_filled, color = Color.White, backgroundColor = Color.Red) + Column( + Modifier.weight(1f).clickable { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) + } + } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.banner_create_link_light else MR.images.banner_create_link), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().aspectRatio(BANNER_IMAGE_RATIO) + ) + } else { + BannerGradientBox(isDark) { + Icon(painterResource(MR.images.ic_add_link), contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colors.primary) + } + } + Box(Modifier.fillMaxWidth().background(labelBg).padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(painterResource(MR.images.ic_add_link), contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.new_1_time_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } + } else { + Text(stringResource(MR.strings.new_1_time_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } } } - Column(modifier = Modifier.padding(start = DEFAULT_PADDING)) { - Text(stringResource(MR.strings.your_simplex_contact_address), style = MaterialTheme.typography.h3) - Spacer(Modifier.fillMaxWidth().padding(DEFAULT_PADDING_HALF)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(MR.strings.how_to_use_simplex_chat), Modifier.padding(end = DEFAULT_SPACE_AFTER_ICON), style = MaterialTheme.typography.body1) - Icon( - painterResource(MR.images.ic_info), - null, + Spacer(Modifier.width(2.dp).fillMaxHeight().background(MaterialTheme.colors.background)) + Column( + Modifier.weight(1f).clickable { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = close) + } + } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.banner_paste_link_light else MR.images.banner_paste_link), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().aspectRatio(BANNER_IMAGE_RATIO) ) + } else { + BannerGradientBox(isDark) { + Icon(painterResource(MR.images.ic_qr_code_scanner), contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colors.primary) + } + } + Box(Modifier.fillMaxWidth().background(labelBg).padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(painterResource(MR.images.ic_qr_code_scanner), contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + Text(stringResource(if (appPlatform.isAndroid) MR.strings.scan_paste_link else MR.strings.paste_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } + } else { + Text(stringResource(if (appPlatform.isAndroid) MR.strings.scan_paste_link else MR.strings.paste_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } } } } @@ -288,15 +406,31 @@ private fun AddressCreationCard() { @Composable private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState, listState: LazyListState) { - if (!chatModel.desktopNoUserNoRemote) { - ChatList(searchText = searchText, listState) + if (chatModel.chatRunning.value == null) { + Text(stringResource(MR.strings.loading_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } else if (shouldShowOnboarding()) { + if (appPlatform.isAndroid) AndroidOnboardingCards() + } else { + if (!chatModel.desktopNoUserNoRemote) { + ChatList(searchText = searchText, listState) + } + if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + Text(stringResource(MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } } - if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { - Text( - stringResource( - if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats - ), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary - ) +} + +@Composable +private fun AndroidOnboardingCards() { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val topPad = topPaddingToContent(false) + val bottomPad = if (oneHandUI.value) { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier + } else { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + } + Box(Modifier.fillMaxSize().padding(top = topPad, bottom = bottomPad)) { + ConnectOnboardingView() } } @@ -453,31 +587,33 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow } }, title = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)) { - Text( - stringResource(MR.strings.your_chats), - color = MaterialTheme.colors.onBackground, - fontWeight = FontWeight.SemiBold, - ) - SubscriptionStatusIndicator( - click = { - ModalManager.start.closeModals() - val summary = serversSummary.value - ModalManager.start.showModalCloseable( - endButtons = { - if (summary != null) { - ShareButton { - val json = Json { - prettyPrint = true + if (!shouldShowOnboarding()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)) { + Text( + stringResource(MR.strings.your_chats), + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.SemiBold, + ) + SubscriptionStatusIndicator( + click = { + ModalManager.start.closeModals() + val summary = serversSummary.value + ModalManager.start.showModalCloseable( + endButtons = { + if (summary != null) { + ShareButton { + val json = Json { + prettyPrint = true + } + val text = json.encodeToString(PresentedServersSummary.serializer(), summary) + clipboard.shareText(text) } - val text = json.encodeToString(PresentedServersSummary.serializer(), summary) - clipboard.shareText(text) } } - } - ) { ServersSummaryView(chatModel.currentRemoteHost.value, serversSummary) } - } - ) + ) { ServersSummaryView(chatModel.currentRemoteHost.value, serversSummary) } + } + ) + } } }, onTitleClick = if (canScrollToZero.value) { { scrollToBottom(scope, listState) } } else null, @@ -826,13 +962,18 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } } + if (!oneHandUICardShown.value) { + item { + ToggleChatListCard() + } + } itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat -> val nextChatSelected = remember(chat.id, chats) { derivedStateOf { chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value } } ChatListNavLinkView(chat, nextChatSelected) } - if (!oneHandUICardShown.value || !addressCreationCardShown.value) { + if (!addressCreationCardShown.value) { item { ChatListFeatureCards() } @@ -859,14 +1000,6 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } - if (!addressCreationCardShown.value) { - LaunchedEffect(chatModel.userAddress.value) { - if (chatModel.userAddress.value != null) { - appPrefs.addressCreationCardShown.set(true) - } - } - } - LaunchedEffect(activeFilter.value) { searchText.value = TextFieldValue("") } @@ -905,19 +1038,11 @@ private fun NoChatsView(searchText: MutableState) { @Composable private fun ChatListFeatureCards() { - val oneHandUI = remember { appPrefs.oneHandUI.state } - val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state } val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (!oneHandUICardShown.value && !oneHandUI.value) { - ToggleChatListCard() - } - if (!addressCreationCardShown.value) { - AddressCreationCard() - } - if (!oneHandUICardShown.value && oneHandUI.value) { - ToggleChatListCard() + if (!addressCreationCardShown.value && hasConversations(chatModel.chats.value)) { + Column(modifier = Modifier.padding(16.dp)) { + ConnectBannerCard() } } } @@ -1060,7 +1185,7 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { is ActiveFilter.PresetTag -> af.tag == tag else -> false } - val (icon, text) = presetTagLabel(tag, active) + val (icon, menuIcon, text) = presetTagLabel(tag, active) val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Row( @@ -1080,7 +1205,7 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { horizontalArrangement = Arrangement.Center ) { Icon( - painterResource(icon), + painterResource(menuIcon ?: icon), stringResource(text), Modifier.size(18.sp.toDp()), tint = color @@ -1122,9 +1247,9 @@ private fun CollapsedTagsFilterView(searchText: MutableState) { contentAlignment = Alignment.Center ) { if (selectedPresetTag != null) { - val (icon, text) = presetTagLabel(selectedPresetTag, true) + val (icon, menuIcon, text) = presetTagLabel(selectedPresetTag, true) Icon( - painterResource(icon), + painterResource(menuIcon ?: icon), stringResource(text), Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.primary @@ -1170,7 +1295,7 @@ fun ItemPresetFilterAction( showMenu: MutableState, onCloseMenuAction: MutableState<(() -> Unit)> ) { - val (icon, text) = presetTagLabel(presetTag, active) + val (icon, _, text) = presetTagLabel(presetTag, active) ItemAction( stringResource(text), painterResource(icon), @@ -1235,7 +1360,11 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo, chatStats: Chat else -> false } PresetTagKind.GROUPS -> when (chatInfo) { - is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null + is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null && !chatInfo.groupInfo.isChannel + else -> false + } + PresetTagKind.CHANNELS -> when (chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.isChannel else -> false } PresetTagKind.BUSINESS -> when (chatInfo) { @@ -1248,14 +1377,15 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo, chatStats: Chat } } -private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair = +private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Triple = when (tag) { - PresetTagKind.GROUP_REPORTS -> (if (active) MR.images.ic_flag_filled else MR.images.ic_flag) to MR.strings.chat_list_group_reports - PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites - PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts - PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups - PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses - PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes + PresetTagKind.GROUP_REPORTS -> Triple(if (active) MR.images.ic_flag_filled else MR.images.ic_flag, null, MR.strings.chat_list_group_reports) + PresetTagKind.FAVORITES -> Triple(if (active) MR.images.ic_star_filled else MR.images.ic_star, null, MR.strings.chat_list_favorites) + PresetTagKind.CONTACTS -> Triple(if (active) MR.images.ic_person_filled else MR.images.ic_person, null, MR.strings.chat_list_contacts) + PresetTagKind.GROUPS -> Triple(if (active) MR.images.ic_group_filled else MR.images.ic_group, null, MR.strings.chat_list_groups) + PresetTagKind.CHANNELS -> Triple(if (active) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_bigtop_updates, MR.images.ic_bigtop_updates, MR.strings.chat_list_channels) + PresetTagKind.BUSINESS -> Triple(if (active) MR.images.ic_work_filled else MR.images.ic_work, null, MR.strings.chat_list_businesses) + PresetTagKind.NOTES -> Triple(if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed, null, MR.strings.chat_list_notes) } private fun presetCanBeCollapsed(tag: PresetTagKind): Boolean = when (tag) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index d5d6facafe..d749865e10 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -31,10 +31,12 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.common.views.chat.item.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource +// Spec: spec/client/chat-list.md#ChatPreviewView @Composable fun ChatPreviewView( chat: Chat, @@ -42,7 +44,6 @@ fun ChatPreviewView( chatModelDraft: ComposeState?, chatModelDraftChatId: ChatId?, currentUserProfileDisplayName: String?, - contactNetworkStatus: NetworkStatus?, disabled: Boolean, linkMode: SimplexLinkMode, inProgress: Boolean, @@ -241,18 +242,24 @@ fun ChatPreviewView( Text(previewText.first, color = previewText.second) } else if (ci != null && showChatPreviews) { val (text: CharSequence, inlineTextContent) = when { - ci.meta.itemDeleted == null -> ci.text to null - else -> markedDeletedText(ci, chat.chatInfo) to null + ci.meta.itemDeleted != null -> markedDeletedText(ci, chat.chatInfo) to null + ci.content.msgContent is MsgContent.MCChat -> { + val chatLink = (ci.content.msgContent as MsgContent.MCChat).chatLink + val descr = chatLink.shortDescription?.let { "\n$it" } ?: "" + (chatLink.displayName + descr) to null + } + else -> ci.text(chat.chatInfo.isChannel) to null } - val formattedText = when { - ci.meta.itemDeleted == null -> ci.formattedText - else -> null + val formattedText: List? = when { + ci.meta.itemDeleted != null -> null + ci.content.msgContent is MsgContent.MCChat -> null + else -> ci.formattedText } - val prefix = when (val mc = ci.content.msgContent) { + val prefix = when (ci.content.msgContent) { is MsgContent.MCReport -> buildAnnotatedString { withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { - append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + append(itemPrefixText(ci)) } } @@ -332,6 +339,19 @@ fun ChatPreviewView( withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } } + is MsgContent.MCChat -> SmallContentPreview(borderColor = if (mc.chatLink.image != null) MaterialTheme.colors.onSurface.copy(alpha = 0.12f) else Color.Transparent) { + Box( + Modifier.fillMaxSize().clickable { withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } }, + contentAlignment = Alignment.Center + ) { + val image = mc.chatLink.image + if (image != null) { + Image(base64ToBitmap(image), null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } else { + Icon(painterResource(mc.chatLink.iconRes), null, Modifier.size(44.sp.toDp()), tint = if (isInDarkTheme()) FileDark else FileLight) + } + } + } else -> {} } } @@ -349,33 +369,7 @@ fun ChatPreviewView( @Composable fun chatStatusImage() { - if (cInfo is ChatInfo.Direct) { - if ( - cInfo.contact.active && - (cInfo.contact.activeConn?.connStatus == ConnStatus.Ready || cInfo.contact.activeConn?.connStatus == ConnStatus.SndReady) - ) { - val descr = contactNetworkStatus?.statusString - when (contactNetworkStatus) { - is NetworkStatus.Connected -> - IncognitoIcon(chat.chatInfo.incognito) - - is NetworkStatus.Error -> - Icon( - painterResource(MR.images.ic_error), - contentDescription = descr, - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(19.sp.toDp()) - .offset(x = 2.sp.toDp()) - ) - - else -> - progressView() - } - } else { - IncognitoIcon(chat.chatInfo.incognito) - } - } else if (cInfo is ChatInfo.Group) { + if (cInfo is ChatInfo.Group) { if (progressByTimeout) { progressView() } else if (chat.chatStats.reportsCount > 0) { @@ -526,8 +520,8 @@ fun ChatPreviewView( } @Composable -private fun SmallContentPreview(content: @Composable BoxScope.() -> Unit) { - Box(Modifier.padding(top = 2.sp.toDp(), end = 8.sp.toDp()).size(36.sp.toDp()).border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(22)).clip(RoundedCornerShape(22))) { +private fun SmallContentPreview(borderColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), content: @Composable BoxScope.() -> Unit) { + Box(Modifier.padding(top = 2.sp.toDp(), end = 8.sp.toDp()).size(36.sp.toDp()).border(0.5.dp, borderColor, RoundedCornerShape(22)).clip(RoundedCornerShape(22))) { content() } } @@ -636,6 +630,6 @@ private data class ActiveVoicePreview( @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) + ChatPreviewView(Chat.sampleData, true, null, null, "", disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 55ac9c8810..ed1c7116e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -64,7 +64,7 @@ enum class SubscriptionColorType { ACTIVE, ACTIVE_SOCKS_PROXY, DISCONNECTED, ACTIVE_DISCONNECTED } -data class SubscriptionStatus( +data class AppSubscriptionStatus( val color: SubscriptionColorType, val variableValue: Float, val statusPercent: Float @@ -75,7 +75,7 @@ fun subscriptionStatusColorAndPercentage( socksProxy: String?, subs: SMPServerSubs, hasSess: Boolean -): SubscriptionStatus { +): AppSubscriptionStatus { fun roundedToQuarter(n: Float): Float = when { n >= 1 -> 1f n <= 0 -> 0f @@ -83,25 +83,25 @@ fun subscriptionStatusColorAndPercentage( } val activeColor: SubscriptionColorType = if (socksProxy != null) SubscriptionColorType.ACTIVE_SOCKS_PROXY else SubscriptionColorType.ACTIVE - val noConnColorAndPercent = SubscriptionStatus(SubscriptionColorType.DISCONNECTED, 1f, 0f) + val noConnColorAndPercent = AppSubscriptionStatus(SubscriptionColorType.DISCONNECTED, 1f, 0f) val activeSubsRounded = roundedToQuarter(subs.shareOfActive) return if (!online) noConnColorAndPercent else if (subs.total == 0 && !hasSess) // On freshly installed app (without chats) and on app start - SubscriptionStatus(activeColor, 0f, 0f) + AppSubscriptionStatus(activeColor, 0f, 0f) else if (subs.ssActive == 0) { if (hasSess) - SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) else noConnColorAndPercent } else { // ssActive > 0 if (hasSess) - SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) else // This would mean implementation error - SubscriptionStatus(SubscriptionColorType.ACTIVE_DISCONNECTED, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(SubscriptionColorType.ACTIVE_DISCONNECTED, activeSubsRounded, subs.shareOfActive) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index aa9847c98a..96af5337d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -51,6 +51,9 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) { } } } + is SharedContent.ChatLink -> { + hasSimplexLink = true + } null -> {} } if (chatModel.chats.value.isNotEmpty()) { @@ -98,7 +101,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal val navButton: @Composable RowScope.() -> Unit = { when { showSearch -> NavigationButtonBack(hideSearchOnBack) - (users.size > 1 || chatModel.remoteHosts.isNotEmpty()) && remember { chatModel.sharedContent }.value !is SharedContent.Forward -> { + (users.size > 1 || chatModel.remoteHosts.isNotEmpty()) && remember { chatModel.sharedContent }.value !is SharedContent.Forward && remember { chatModel.sharedContent }.value !is SharedContent.ChatLink -> { val allRead = users .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } @@ -129,6 +132,8 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal chatModel.sharedContent.value = null if (sharedContent is SharedContent.Forward) { chatModel.chatId.value = sharedContent.fromChatInfo.id + } else if (sharedContent is SharedContent.ChatLink) { + chatModel.chatId.value = sharedContent.groupInfo.id } }) } @@ -144,6 +149,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal is SharedContent.Media -> stringResource(MR.strings.share_image) is SharedContent.File -> stringResource(MR.strings.share_file) is SharedContent.Forward -> if (v.chatItems.size > 1) stringResource(MR.strings.forward_multiple) else stringResource(MR.strings.forward_message) + is SharedContent.ChatLink -> stringResource(MR.strings.share_channel) null -> stringResource(MR.strings.share_message) }, color = MaterialTheme.colors.onBackground, @@ -190,7 +196,7 @@ private fun ShareList( val oneHandUI = remember { appPrefs.oneHandUI.state } val chats by remember(search) { derivedStateOf { - val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready }.sortedByDescending { it.chatInfo is ChatInfo.Local } + val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local } filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 8dfe138da1..c6cc887655 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -43,6 +43,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* +// Spec: spec/client/chat-list.md#TagListView @Composable fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) { val userTags = remember { chatModel.userTags } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index ed74e083e7..a02e0dc768 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.* private val USER_PICKER_SECTION_SPACING = 32.dp +// Spec: spec/client/chat-list.md#UserPicker @Composable fun UserPicker( chatModel: ChatModel, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 9264ca69af..4aeb929624 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -117,12 +118,25 @@ fun DatabaseErrorView( OpenDatabaseDirectoryButton() } is MigrationError.Downgrade -> { + val warnings = downMigrationWarnings(err.downMigrations).reversed() DatabaseErrorDetails(MR.strings.database_downgrade) { TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) { Text(generalGetString(MR.strings.downgrade_and_open_chat)) } Spacer(Modifier.height(20.dp)) + Icon( + painterResource(MR.images.ic_warning_filled), + contentDescription = null, + Modifier.size(40.dp).align(Alignment.CenterHorizontally), + tint = Color.Red + ) + Spacer(Modifier.height(12.dp)) Text(generalGetString(MR.strings.database_downgrade_warning), fontWeight = FontWeight.Bold) + if (warnings.isNotEmpty()) { + warnings.forEach { warning -> + Text(warning, fontWeight = FontWeight.Bold) + } + } FileNameText(status.dbFile) MigrationsText(err.downMigrations) AppVersionText() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index dc0a86b8fb..3d670d1c43 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -272,10 +272,12 @@ class AlertManager { profileName: String, profileFullName: String, profileImage: @Composable () -> Unit, - confirmText: String = generalGetString(MR.strings.connect_plan_open_chat), - onConfirm: () -> Unit, + subtitle: String? = null, + information: String? = null, + confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat), + onConfirm: (() -> Unit)? = null, dismissText: String = generalGetString(MR.strings.cancel_verb), - onDismiss: (() -> Unit)?, + onDismiss: (() -> Unit)? = null, ) { showAlert { AlertDialog( @@ -317,6 +319,27 @@ class AlertManager { modifier = Modifier.fillMaxWidth() ) } + if (subtitle != null) { + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text( + subtitle, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + } + if (information != null) { + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text( + information, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + } } Column( @@ -329,16 +352,18 @@ class AlertManager { delay(200) focusRequester.requestFocus() } - TextButton(onClick = { - onConfirm.invoke() - hideAlert() - }, Modifier.focusRequester(focusRequester)) { - Text(confirmText) + if (confirmText != null && onConfirm != null) { + TextButton(onClick = { + onConfirm.invoke() + hideAlert() + }, Modifier.focusRequester(focusRequester)) { + Text(confirmText) + } } TextButton(onClick = { onDismiss?.invoke() hideAlert() - }) { + }, if (confirmText == null) Modifier.focusRequester(focusRequester) else Modifier) { Text(dismissText) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index afb557cc78..ee63846657 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -22,7 +22,10 @@ fun AppBarTitle( hostDevice: Pair? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp, - enableAlphaChanges: Boolean = true + enableAlphaChanges: Boolean = true, + overrideTitleColor: Color? = null, + textAlign: TextAlign = TextAlign.Start, + lineHeight: TextUnit = TextUnit.Unspecified ) { val handler = LocalAppBarHandler.current val connection = if (enableAlphaChanges) handler?.connection else null @@ -34,10 +37,12 @@ fun AppBarTitle( } } val theme = CurrentColors.collectAsState() - val titleColor = MaterialTheme.appColors.title - val brush = if (theme.value.base == DefaultTheme.SIMPLEX) + val titleColor = overrideTitleColor ?: MaterialTheme.appColors.title + val brush = if (overrideTitleColor != null) + Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f)) + else if (theme.value.base == DefaultTheme.SIMPLEX) Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f)) - else // color is not updated when changing themes if I pass null here + else Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f)) Column { Text( @@ -48,9 +53,9 @@ fun AppBarTitle( alpha = bottomTitleAlpha(connection) }, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.h1.copy(brush = brush), + style = MaterialTheme.typography.h1.copy(brush = brush, lineHeight = lineHeight), color = MaterialTheme.colors.primaryVariant, - textAlign = TextAlign.Start + textAlign = textAlign ) if (hostDevice != null) { Box(Modifier.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp).graphicsLayer { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt index 096b6c55ac..c7553b6ed0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt @@ -64,7 +64,7 @@ private fun Modifier.androidBlurredModifier( } } .drawBehind { - drawRect(Color.Black) + drawRect(CurrentColors.value.colors.background) if (onTop) { clipRect { if (backgroundGraphicsLayer.size != IntSize.Zero) { @@ -110,7 +110,7 @@ private fun Modifier.desktopBlurredModifier( clip = blurRadius.value > 0 } .drawBehind { - drawRect(Color.Black) + drawRect(CurrentColors.value.colors.background) if (onTop) { clipRect { if (backgroundGraphicsLayer.size != IntSize.Zero) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 72ef8c623d..5f3a73e7ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -12,12 +12,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.InspectableValue import androidx.compose.ui.unit.* -import chat.simplex.common.model.BusinessChatType import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.ChatInfo @@ -57,7 +54,8 @@ fun ProfileImage( icon: ImageResource = MR.images.ic_account_circle_filled, color: Color = MaterialTheme.colors.secondaryVariant, backgroundColor: Color? = null, - blurred: Boolean = false + blurred: Boolean = false, + async: Boolean = false ) { Box(Modifier.size(size)) { if (image == null) { @@ -85,13 +83,22 @@ fun ProfileImage( ) } } else { - val imageBitmap = base64ToBitmap(image) - Image( - imageBitmap, - stringResource(MR.strings.image_descr_profile_image), - contentScale = ContentScale.Crop, - modifier = ProfileIconModifier(size, blurred = blurred) - ) + if (async) { + Base64AsyncImage( + base64ImageString = image, + contentDescription = stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = ProfileIconModifier(size, blurred = blurred) + ) + } else { + val imageBitmap = base64ToBitmap(image) + Image( + bitmap = imageBitmap, + contentDescription = stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = ProfileIconModifier(size, blurred = blurred) + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 4827e6ae61..e584fcc11c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.helpers import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.res.MR import kotlinx.serialization.* import java.io.File import java.security.SecureRandom @@ -74,6 +75,7 @@ object DatabaseUtils { } } +// Spec: spec/database.md#DBMigrationResult @Serializable sealed class DBMigrationResult { @Serializable @SerialName("ok") object OK: DBMigrationResult() @@ -107,6 +109,15 @@ data class UpMigration( // val withDown: Boolean ) +fun downMigrationWarnings(downMigrations: List): List { + val warnings = listOf( + "20260222_chat_relays" to MR.strings.down_migration_warning_chat_relays + ) + return warnings.mapNotNull { (key, res) -> + if (downMigrations.contains(key)) generalGetString(res) else null + } +} + @Serializable sealed class MTRError { @Serializable @SerialName("noDown") class NoDown(val dbMigrations: List): MTRError() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 1c5f86c8b5..81fac40a40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -29,7 +29,9 @@ fun DefaultAppBar( onTop: Boolean, showSearch: Boolean = false, searchAlwaysVisible: Boolean = false, + searchPlaceholder: String? = null, onSearchValueChanged: (String) -> Unit = {}, + searchTrailingContent: @Composable (() -> Unit)? = null, buttons: @Composable RowScope.() -> Unit = {}, ) { // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier @@ -78,7 +80,8 @@ fun DefaultAppBar( AppBar( title = { if (showSearch) { - SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged) + val placeholder = searchPlaceholder ?: stringResource(MR.strings.search_verb) + SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, placeholder = placeholder, trailingContent = searchTrailingContent, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged) } else if (title != null) { title() } else if (titleText.value.isNotEmpty() && connection != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt index 30811d5c94..cf3281f776 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt @@ -15,6 +15,7 @@ sealed class SharedContent { data class Media(val text: String, val uris: List): SharedContent() data class File(val text: String, val uri: URI): SharedContent() data class Forward(val chatItems: List, val fromChatInfo: ChatInfo): SharedContent() + data class ChatLink(val groupInfo: GroupInfo): SharedContent() } enum class AnimatedViewState { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index 9c529e547a..d2a98ae101 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.LinkPreview +import chat.simplex.common.model.NetworkProxyAuth import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.chatViewScrollState @@ -24,67 +25,124 @@ import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID import chat.simplex.common.views.chat.item.imageViewFullWidth import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.jsoup.Jsoup +import java.net.Authenticator +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy import java.net.URL +import java.util.UUID private const val OG_SELECT_QUERY = "meta[property^=og:]" private const val ICON_SELECT_QUERY = "link[rel^=icon],link[rel^=apple-touch-icon],link[rel^=shortcut icon]" private val IMAGE_SUFFIXES = listOf(".jpg", ".png", ".ico", ".webp", ".gif") +// Authenticator.setDefault is process-global. The mutex serializes preview fetches +// so concurrent calls cannot clobber each other's authenticator, and so the +// snapshot/restore in getLinkPreview is race-free. +private val previewMutex = Mutex() + suspend fun getLinkPreview(url: String): LinkPreview? { return withContext(Dispatchers.IO) { - try { - val title: String? - val u = kotlin.runCatching { URL(url) }.getOrNull() ?: return@withContext null - var imageUri = when { - IMAGE_SUFFIXES.any { u.path.lowercase().endsWith(it) } -> { - title = u.path.substringAfterLast("/") - url - } - else -> { - val connection = Jsoup.connect(url) - .ignoreContentType(true) - .timeout(10000) - .followRedirects(true) - - val response = if (url.lowercase().startsWith("https://x.com/")) { - // Apple sends request with special user-agent which handled differently by X.com. - // Different response that includes video poster from post - connection - .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0") - .execute() - } else { - connection - .execute() - } - val doc = response.parse() - val ogTags = doc.select(OG_SELECT_QUERY) - title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title() - ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content") - ?: doc.select(ICON_SELECT_QUERY).firstOrNull { it.attr("rel").contains("icon") }?.attr("href") - } - } - if (imageUri != null) { - imageUri = normalizeImageUri(u, imageUri) + previewMutex.withLock { + try { try { - val stream = URL(imageUri).openStream() - val image = resizeImageToStrSize(stream.use(::loadImageBitmap), maxDataSize = 14000) - // TODO add once supported in iOS - // val description = ogTags.firstOrNull { - // it.attr("property") == "og:description" - // }?.attr("content") ?: "" - if (title != null) { - return@withContext LinkPreview(url, title, description = "", image) + val title: String? + val u = kotlin.runCatching { URL(url) }.getOrNull() ?: return@withLock null + val useSocksProxy = appPrefs.networkUseSocksProxy.get() + val proxy: Proxy? + if (useSocksProxy) { + val networkProxy = appPrefs.networkProxy.get() + proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(networkProxy.host, networkProxy.port)) + val (authUser, authPass) = when (networkProxy.auth) { + NetworkProxyAuth.USERNAME -> + if (networkProxy.username.isNotEmpty() && networkProxy.password.isNotEmpty()) + networkProxy.username to networkProxy.password + else + null to null + // Per-call random credentials drive Tor-style stream isolation: each + // preview gets its own circuit, and previews don't share a circuit + // with other unauthenticated traffic on the proxy. + NetworkProxyAuth.ISOLATE -> + UUID.randomUUID().toString() to UUID.randomUUID().toString() + } + if (authUser != null && authPass != null) { + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? = + // Only respond when the SOCKS proxy itself challenges. A destination + // server returning 401 also triggers RequestorType.SERVER; without + // this gate, the JDK's auto-retry would post our SOCKS credentials + // in an Authorization header to the destination. + if (requestingHost == networkProxy.host && requestingPort == networkProxy.port) + PasswordAuthentication(authUser, authPass.toCharArray()) + else null + }) + } else { + Authenticator.setDefault(null) + } + } else { + proxy = null + Authenticator.setDefault(null) + } + var imageUri = when { + IMAGE_SUFFIXES.any { u.path.lowercase().endsWith(it) } -> { + title = u.path.substringAfterLast("/") + url + } + else -> { + val connection = Jsoup.connect(url) + .ignoreContentType(true) + .timeout(10000) + .followRedirects(true) + .proxy(proxy) + + val response = if (url.lowercase().startsWith("https://x.com/")) { + // Apple sends request with special user-agent which handled differently by X.com. + // Different response that includes video poster from post + connection + .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0") + .execute() + } else { + connection + .execute() + } + val doc = response.parse() + val ogTags = doc.select(OG_SELECT_QUERY) + title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title() + ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content") + ?: doc.select(ICON_SELECT_QUERY).firstOrNull { it.attr("rel").contains("icon") }?.attr("href") + } + } + if (imageUri != null) { + imageUri = normalizeImageUri(u, imageUri) + try { + val conn = URL(imageUri).openConnection(proxy ?: Proxy.NO_PROXY) + conn.connectTimeout = 20_000 + conn.readTimeout = 20_000 + val stream = conn.getInputStream() + val image = resizeImageToStrSize(stream.use(::loadImageBitmap), maxDataSize = 14000) + // TODO add once supported in iOS + // val description = ogTags.firstOrNull { + // it.attr("property") == "og:description" + // }?.attr("content") ?: "" + if (title != null) { + return@withLock LinkPreview(url, title, description = "", image) + } + } catch (e: Exception) { + e.printStackTrace() + } } } catch (e: Exception) { e.printStackTrace() } + return@withLock null + } finally { + Authenticator.setDefault(null) } - } catch (e: Exception) { - e.printStackTrace() } - return@withContext null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 21520f5424..28c81fbf56 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -111,8 +111,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id - fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { - showCustomModal(id = id) { close -> + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { + showCustomModal(id = id, forceAnimated = forceAnimated) { close -> ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() }) } } @@ -123,7 +123,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { } } - fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, id: ModalViewId? = null, modal: @Composable ModalData.(close: () -> Unit) -> Unit) { + fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, modal: @Composable ModalData.(close: () -> Unit) -> Unit) { Log.d(TAG, "ModalManager.showCustomModal") val data = ModalData(keyboardCoversBar = keyboardCoversBar) // Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen. @@ -133,7 +133,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { } // Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0) // to prevent unneeded animation on different situations - val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START) + val anim = if (appPlatform.isAndroid) animated else (animated && (modalCount.value > 0 || placement == ModalPlacement.START)) || forceAnimated modalViews.add(ModalViewHolder(id, anim, data, modal)) _modalCount.value = modalViews.size - toRemove.size diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index 7124f34ac0..a122ddd885 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -10,6 +10,7 @@ import androidx.compose.material.TextFieldDefaults.indicatorLine import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -112,18 +113,26 @@ fun SearchTextField( placeholder = { Text(placeholder, style = textStyle.copy(color = MaterialTheme.colors.secondary), maxLines = 1, overflow = TextOverflow.Ellipsis) }, - trailingIcon = if (searchText.value.text.isNotEmpty()) {{ - IconButton({ - if (alwaysVisible) { - keyboard?.hide() - focusManager.clearFocus() + trailingIcon = if (searchText.value.text.isNotEmpty() || trailingContent != null) {{ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.offset(x = 8.dp) + ) { + if (searchText.value.text.isNotEmpty()) { + IconButton({ + if (alwaysVisible) { + keyboard?.hide() + focusManager.clearFocus() + } + searchText.value = TextFieldValue("") + onValueChange("") + }) { + Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary) + } } - searchText.value = TextFieldValue(""); - onValueChange("") - }, Modifier.offset(x = reducedCloseButtonPadding)) { - Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary,) + trailingContent?.invoke() } - }} else trailingContent, + }} else null, singleLine = true, enabled = enabled, interactionSource = interactionSource, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index da16e2b7e7..e8070b5c76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -32,7 +32,8 @@ fun TextEditor( placeholder: String? = null, contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING), isValid: (String) -> Boolean = { true }, - focusRequester: FocusRequester? = null + focusRequester: FocusRequester? = null, + enabled: Boolean = true ) { var valid by rememberSaveable { mutableStateOf(true) } var focused by rememberSaveable { mutableStateOf(false) } @@ -64,6 +65,7 @@ fun TextEditor( value = value.value, onValueChange = { value.value = it }, modifier = if (focusRequester == null) textFieldModifier else textFieldModifier.focusRequester(focusRequester), + enabled = enabled, textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -83,7 +85,7 @@ fun TextEditor( leadingIcon = null, trailingIcon = null, singleLine = false, - enabled = true, + enabled = enabled, isError = false, interactionSource = remember { MutableInteractionSource() }, colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index db1a0be9da..424d500085 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -114,6 +114,7 @@ fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedStr expect fun SetupClipboardListener() // maximum image file size to be auto-accepted +// Spec: spec/services/files.md#MAX_IMAGE_SIZE const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 @@ -129,6 +130,8 @@ const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI +expect fun clearImageCaches() + // https://developer.android.com/training/data-storage/shared/documents-files#bitmap expect suspend fun getLoadedImage(file: CIFile?): Pair? @@ -422,6 +425,7 @@ fun deleteAppFiles() { } catch (e: java.lang.Exception) { Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}") } + clearImageCaches() } fun directoryFileCountAndSize(dir: String): Pair { // count, size in bytes @@ -533,6 +537,20 @@ fun UriHandler.openUriCatching(uri: String) { } } +fun UriHandler.openExternalLink(uri: String) { + val uriHandler = this + if (uri.startsWith("https://simplex.chat/contact#") || (uri.startsWith("https://smp") && ".simplex.im/a#" in uri)) { + uriHandler.openVerifiedSimplexUri(uri) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.open_external_link_title), + text = uri, + confirmText = generalGetString(MR.strings.open_verb), + onConfirm = { uriHandler.openUriCatching(uri) } + ) + } +} + fun IntSize.Companion.Saver(): Saver = Saver( save = { it.width to it.height }, restore = { IntSize(it.first, it.second) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 6199621c39..cabfbf031e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -474,7 +474,9 @@ private fun MutableState.MigrationConfirmationView(status: DB Tuple4( generalGetString(MR.strings.database_downgrade), generalGetString(MR.strings.downgrade_and_open_chat), - generalGetString(MR.strings.database_downgrade_warning), + (listOf(generalGetString(MR.strings.database_downgrade_warning)) + + downMigrationWarnings(err.downMigrations).reversed()) + .joinToString("\n"), MigrationConfirmation.YesUpDown ) is MigrationError.Error -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt new file mode 100644 index 0000000000..93bb4f49db --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -0,0 +1,665 @@ +package chat.simplex.common.views.newchat + +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.getUserServers +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import androidx.compose.ui.layout.ContentScale +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.views.* +import chat.simplex.common.views.chat.group.GroupLinkView +import chat.simplex.common.views.chatlist.openGroupChat +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView +import chat.simplex.common.views.chat.group.hostFromRelayLink +import chat.simplex.res.MR +import java.net.URI +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* + +@Composable +fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit) { + val view = LocalMultiplatformView() + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val displayName = rememberSaveable { mutableStateOf("") } + val chosenImage = rememberSaveable { mutableStateOf(null) } + val profileImage = rememberSaveable { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + val hasRelays = rememberSaveable { mutableStateOf(true) } + val groupInfo = remember { mutableStateOf(null) } + val groupLink = rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(null) } + val groupRelays = remember { mutableStateOf>(emptyList()) } + val creationInProgress = rememberSaveable { mutableStateOf(false) } + val showLinkStep = rememberSaveable { mutableStateOf(false) } + val relayListExpanded = rememberSaveable { mutableStateOf(false) } + + val gInfo = groupInfo.value + if (showLinkStep.value && gInfo != null) { + LinkStepView(chatModel, gInfo, groupLink, closeAll) + } else if (gInfo != null) { + ProgressStepView( + chatModel, gInfo, groupRelays, relayListExpanded, + onLinkReady = if (appPlatform.isDesktop) { + { + chatModel.creatingChannelId.value = null + closeAll() + withBGApi { + openGroupChat(null, gInfo.groupId) + ModalManager.end.showModalCloseable(true) { close -> + GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, shareGroupInfo = gInfo, close = close) + } + } + } + } else { + { showLinkStep.value = true } + }, + cancelChannelCreation = { + chatModel.creatingChannelId.value = null + ChannelRelaysModel.reset() + closeAll() + withBGApi { + try { + chatModel.controller.apiDeleteChat(rh = null, type = ChatType.Group, id = gInfo.apiId) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(null, gInfo.id) + } + } catch (e: Exception) { + Log.e(TAG, "cancelChannelCreation error: ${e.message}") + } + } + } + ) + } else { + ProfileStepView( + chatModel = chatModel, + displayName = displayName, + profileImage = profileImage, + chosenImage = chosenImage, + focusRequester = focusRequester, + hasRelays = hasRelays, + creationInProgress = creationInProgress, + bottomSheetModalState = bottomSheetModalState, + scope = scope, + view = view, + close = close, + createChannel = { + hideKeyboard(view) + val trimmedName = displayName.value.trim() + displayName.value = trimmedName + val profile = GroupProfile( + displayName = trimmedName, + fullName = "", + shortDescr = null, + image = profileImage.value, + groupPreferences = GroupPreferences( + history = GroupPreference(GroupFeatureEnabled.ON), + support = GroupPreference(GroupFeatureEnabled.OFF) + ) + ) + creationInProgress.value = true + withBGApi { + try { + val enabledRelays = chooseRandomRelays() + val relayIds = enabledRelays.mapNotNull { it.chatRelayId } + if (relayIds.isEmpty()) { + withContext(Dispatchers.Main) { + creationInProgress.value = false + hasRelays.value = false + } + return@withBGApi + } + val result = chatModel.controller.apiNewPublicGroup( + rh = null, + incognito = false, + relayIds = relayIds, + groupProfile = profile + ) + when (result) { + is ChatController.PublicGroupCreationResult.Created -> { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId = null, result.groupInfo) + chatModel.creatingChannelId.value = result.groupInfo.id + groupInfo.value = result.groupInfo + groupLink.value = result.groupLink + groupRelays.value = result.groupRelays.sortedBy { relayDisplayName(it) } + ChannelRelaysModel.set(result.groupInfo.groupId, result.groupRelays) + creationInProgress.value = false + } + } + is ChatController.PublicGroupCreationResult.CreationFailed -> { + withContext(Dispatchers.Main) { + creationInProgress.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_creating_channel), + text = generalGetString(MR.strings.relay_results) + "\n" + + result.addRelayResults.joinToString("\n") { "${chatRelayDisplayName(it.relay)}: ${it.relayError?.let { e -> ChatController.connErrorText(e) } ?: "ok"}" } + ) + } + } + null -> { + withContext(Dispatchers.Main) { creationInProgress.value = false } + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + creationInProgress.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_creating_channel), + text = e.message + ) + } + } + } + } + ) + } +} + +private const val maxRelays = 3 + +private suspend fun chooseRandomRelays(): List { + val servers = getUserServers(rh = null) ?: return emptyList() + // Operator relays are grouped per operator; custom relays (null operator) + // are treated independently to maximize trust distribution. + val operatorGroups = mutableListOf>() + var customRelays = mutableListOf() + for (op in servers) { + if (op.operator?.enabled == false) continue + val relays = op.chatRelays.filter { it.enabled && !it.deleted && it.chatRelayId != null } + if (relays.isEmpty()) continue + if (op.operator != null) { + operatorGroups.add(relays.shuffled()) + } else { + customRelays = relays.shuffled().toMutableList() + } + } + val selected = mutableListOf() + // Prefer at least one custom relay when available - + // user's own infrastructure for trust distribution. + if (customRelays.isNotEmpty()) { + selected.add(customRelays.removeAt(0)) + if (selected.size >= maxRelays) return selected + } + // Round-robin across shuffled groups to distribute relays across operators. + val groups = (operatorGroups + customRelays.map { listOf(it) }).shuffled() + val maxDepth = groups.maxOfOrNull { it.size } ?: 0 + for (depth in 0 until maxDepth) { + for (group in groups) { + if (depth < group.size) { + selected.add(group[depth]) + if (selected.size >= maxRelays) return selected + } + } + } + return selected +} + +private suspend fun checkHasRelays(): Boolean { + val servers = try { getUserServers(rh = null) } catch (_: Exception) { null } ?: return false + return servers.any { op -> + (op.operator?.enabled ?: true) && + op.chatRelays.any { it.enabled && !it.deleted && it.chatRelayId != null } + } +} + +@Composable +private fun ProfileStepView( + chatModel: ChatModel, + displayName: MutableState, + profileImage: MutableState, + chosenImage: MutableState, + focusRequester: FocusRequester, + hasRelays: MutableState, + creationInProgress: MutableState, + bottomSheetModalState: ModalBottomSheetState, + scope: CoroutineScope, + view: Any?, + close: () -> Unit, + createChannel: () -> Unit +) { + LaunchedEffect(Unit) { + hasRelays.value = checkHasRelays() + } + + ModalBottomSheetLayout( + scrimColor = Color.Black.copy(alpha = 0.12F), + modifier = Modifier.imePadding(), + sheetContent = { + GetImageBottomSheet( + chosenImage, + onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) }, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + } + ) + }, + sheetState = bottomSheetModalState, + sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) { + ModalView(close = close) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.create_channel_title), bottomPadding = DEFAULT_PADDING_HALF) + Row( + Modifier + .fillMaxWidth() + .padding(vertical = DEFAULT_PADDING_HALF), + horizontalArrangement = if (BuildConfigCommon.SIMPLEX_ASSETS) Arrangement.SpaceEvenly else Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // Padding offsets transparent space built into 3D asset + Box( + modifier = if (BuildConfigCommon.SIMPLEX_ASSETS) Modifier.padding(horizontal = 3.dp) else Modifier, + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(128.dp, image = profileImage.value, icon = MR.images.ic_bigtop_updates_circle_filled) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } + } + } + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) MR.images.create_channel_light else MR.images.create_channel), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.height(140.dp) + ) + } + } + Row( + Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + generalGetString(MR.strings.channel_display_name_field), + fontSize = 16.sp + ) + if (!isValidDisplayName(displayName.value.trim())) { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value.trim()), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { + ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) + } + Spacer(Modifier.height(8.dp)) + + SettingsActionItem( + painterResource(MR.images.ic_wifi_tethering), + generalGetString(MR.strings.configure_relays), + click = { + ModalManager.start.showCustomModal { close -> + NetworkAndServersView(close) + } + }, + textColor = if (hasRelays.value) MaterialTheme.colors.primary else WarningOrange, + iconColor = if (hasRelays.value) MaterialTheme.colors.primary else WarningOrange + ) + + val canCreate = canCreateProfile(displayName.value) && hasRelays.value && !creationInProgress.value + SettingsActionItem( + painterResource(MR.images.ic_check), + generalGetString(MR.strings.create_channel_button), + click = createChannel, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = !canCreate + ) + + SectionTextFooter( + if (!hasRelays.value) { + generalGetString(MR.strings.enable_at_least_one_chat_relay) + } else { + val name = chatModel.currentUser.value?.displayName ?: "" + String.format(generalGetString(MR.strings.your_profile_shared_with_channel_relays), name) + } + ) + + LaunchedEffect(Unit) { + delay(1000) + focusRequester.requestFocus() + } + } + } + } +} + +@Composable +private fun ProgressStepView( + chatModel: ChatModel, + gInfo: GroupInfo, + groupRelays: MutableState>, + relayListExpanded: MutableState, + onLinkReady: () -> Unit, + cancelChannelCreation: () -> Unit +) { + val failedCount = groupRelays.value.count { relayMemberConnFailed(chatModel, it) != null } + val activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null } + val total = groupRelays.value.size + + fun showCancelAlert() { + val active = groupRelays.value.count { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null } + val tot = groupRelays.value.size + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.cancel_creating_channel_question), + text = String.format(generalGetString(MR.strings.cancel_channel_alert_msg), gInfo.groupProfile.displayName, active, tot), + confirmText = generalGetString(MR.strings.cancel_verb), + onConfirm = cancelChannelCreation, + dismissText = generalGetString(MR.strings.wait_verb), + destructive = true, + ) + } + + if (appPlatform.isDesktop) { + DisposableEffect(Unit) { + chatModel.centerPanelBackgroundClickHandler = { + showCancelAlert() + true + } + onDispose { + chatModel.centerPanelBackgroundClickHandler = null + } + } + } + + LaunchedEffect(gInfo.groupId) { + snapshotFlow { ChannelRelaysModel.groupRelays.toList() } + .collect { relays -> + if (ChannelRelaysModel.groupId.value != gInfo.groupId) return@collect + groupRelays.value = relays.sortedBy { relayDisplayName(it) } + if (relays.all { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null }) { + onLinkReady() + ChannelRelaysModel.reset() + } + } + } + + ModalView( + close = { showCancelAlert() }, + showClose = false, + ) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.creating_channel)) + + Box( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + ProfileImage(108.dp, image = gInfo.groupProfile.image, icon = MR.images.ic_bigtop_updates_circle_filled) + } + Text( + gInfo.groupProfile.displayName, + style = MaterialTheme.typography.h6, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + textAlign = TextAlign.Center + ) + + SectionView { + SectionItemView(click = { relayListExpanded.value = !relayListExpanded.value }) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (activeCount + failedCount < total) { + RelayProgressIndicator(active = activeCount, total = total) + } + val statusText = if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_failures), activeCount, total, failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_active), activeCount, total) + } + Text(statusText, modifier = Modifier.weight(1f)) + Icon( + painterResource(if (relayListExpanded.value) MR.images.ic_chevron_up else MR.images.ic_chevron_down), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } + } + if (relayListExpanded.value) { + groupRelays.value.forEach { relay -> + val failedErr = relayMemberConnFailed(chatModel, relay) + if (failedErr != null) { + SectionItemView( + click = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + }, + minHeight = 30.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + RelayRow(relay, connFailed = true) + } + } else { + SectionItemView( + minHeight = 30.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + RelayRow(relay, connFailed = false) + } + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + SectionView { + SettingsActionItem( + painterResource(MR.images.ic_delete), + generalGetString(MR.strings.button_cancel_and_delete_channel), + click = { showCancelAlert() }, + textColor = Color.Red, + iconColor = Color.Red, + ) + val enabled = activeCount > 0 + SettingsActionItem( + painterResource(MR.images.ic_check), + generalGetString(MR.strings.continue_to_next_step), + click = { + if (activeCount >= total) { + onLinkReady() + } else if (activeCount > 0) { + val alertText = String.format( + generalGetString(MR.strings.channel_will_start_with_relays), + activeCount, total + ) + if (activeCount + failedCount < total) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.not_all_relays_connected), + text = alertText, + buttons = { + Row(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton(onClick = { AlertManager.shared.hideAlert() }) { + Text(generalGetString(MR.strings.wait_verb)) + } + TextButton(onClick = { + AlertManager.shared.hideAlert() + onLinkReady() + }) { + Text(generalGetString(MR.strings.continue_to_next_step)) + } + } + } + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.not_all_relays_connected), + text = alertText, + confirmText = generalGetString(MR.strings.continue_to_next_step), + onConfirm = { onLinkReady() } + ) + } + } + }, + textColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + disabled = !enabled + ) + } + } + } +} + +private fun relayMemberConnFailed(chatModel: ChatModel, relay: GroupRelay): String? { + return chatModel.groupMembers.value + .firstOrNull { it.groupMemberId == relay.groupMemberId } + ?.activeConn?.connFailedErr +} + +@Composable +private fun RelayRow(relay: GroupRelay, connFailed: Boolean) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(relayDisplayName(relay)) + RelayStatusIndicator(relay.relayStatus, connFailed = connFailed) + } +} + +@Composable +private fun LinkStepView( + chatModel: ChatModel, + gInfo: GroupInfo, + groupLink: MutableState, + closeAll: () -> Unit +) { + val close: () -> Unit = { + chatModel.creatingChannelId.value = null + withBGApi { + delay(500) + withContext(Dispatchers.Main) { + ModalManager.start.closeModals() + openGroupChat(null, gInfo.groupId) + } + } + } + ModalView(close = close, showClose = false) { + GroupLinkView( + chatModel = chatModel, + rhId = null, + groupInfo = gInfo, + groupLink = groupLink.value, + onGroupLinkUpdated = { groupLink.value = it }, + creatingGroup = true, + isChannel = true, + close = close + ) + } +} + +fun relayDisplayName(relay: GroupRelay): String { + if (relay.userChatRelay.displayName.isNotEmpty()) return relay.userChatRelay.displayName + relay.userChatRelay.domains.firstOrNull()?.let { return it } + relay.relayLink?.let { return hostFromRelayLink(it) } + return "relay ${relay.groupRelayId}" +} + +fun chatRelayDisplayName(relay: UserChatRelay): String { + if (relay.displayName.isNotEmpty()) return relay.displayName + return relay.address +} + +@Composable +fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) { + val removed = memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) + val isRejected = status == RelayStatus.Rejected + val color = if (connFailed || removed || isRejected) Color.Red else if (status == RelayStatus.Active) Color.Green else WarningYellow + val text = + if (connFailed) generalGetString(MR.strings.relay_status_failed) + else if (isRejected) generalGetString(MR.strings.relay_status_rejected) + else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) + else if (removed) generalGetString(MR.strings.relay_conn_status_removed) + else status.text + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Canvas(Modifier.size(8.dp)) { + drawCircle(color = color) + } + Text( + text, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + if (connFailed) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Composable +fun RelayProgressIndicator(active: Int, total: Int) { + if (active == 0) { + CircularProgressIndicator( + Modifier.size(20.dp), + strokeWidth = 2.5.dp + ) + } else { + val progress = active.toFloat() / total.coerceAtLeast(1).toFloat() + Box(Modifier.size(20.dp)) { + Canvas(Modifier.fillMaxSize()) { + // Background circle + drawCircle( + color = Color.Gray.copy(alpha = 0.3f), + style = Stroke(width = 2.5.dp.toPx()) + ) + // Progress arc + drawArc( + color = Color(0xFF2196F3), // accent blue + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round) + ) + } + } + } +} + +@Preview +@Composable +fun PreviewAddChannelView() { + SimpleXTheme { + AddChannelView(chatModel = ChatModel, close = {}, closeAll = {}) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index e8084e055a..a54d2e42e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -27,6 +27,8 @@ import chat.simplex.common.views.* import chat.simplex.common.views.chat.group.GroupLinkView import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.usersettings.* +import androidx.compose.ui.layout.ContentScale +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -56,7 +58,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close = close) } } } @@ -99,22 +101,37 @@ fun AddGroupLayout( ) { ModalView(close = close) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId)) - Box( + AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId), bottomPadding = DEFAULT_PADDING_HALF) + Row( Modifier .fillMaxWidth() - .padding(bottom = 24.dp), - contentAlignment = Alignment.Center + .padding(vertical = DEFAULT_PADDING_HALF), + horizontalArrangement = if (BuildConfigCommon.SIMPLEX_ASSETS) Arrangement.SpaceEvenly else Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Box(contentAlignment = Alignment.TopEnd) { - Box(contentAlignment = Alignment.Center) { - ProfileImage(108.dp, image = profileImage.value) - EditImageButton { scope.launch { bottomSheetModalState.show() } } - } - if (profileImage.value != null) { - DeleteImageButton { profileImage.value = null } + // Padding offsets transparent space built into 3D asset + Box( + modifier = if (BuildConfigCommon.SIMPLEX_ASSETS) Modifier.padding(horizontal = 3.dp) else Modifier, + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(128.dp, image = profileImage.value, icon = MR.images.ic_supervised_user_circle_filled) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } } } + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) MR.images.create_group_light else MR.images.create_group), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.height(140.dp) + ) + } } Row(Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 434cb6ce27..cafad97574 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -23,23 +24,34 @@ enum class ConnectionLinkType { suspend fun planAndConnect( rhId: Long?, shortOrFullLink: String, + linkOwnerSig: LinkOwnerSig? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? = null, filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { + val link = strHasSingleSimplexLink(shortOrFullLink.trim()) + if (link?.format is Format.SimplexLink && (link.format as Format.SimplexLink).linkType == SimplexLinkType.relay) { + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.relay_address_alert_title), + generalGetString(MR.strings.relay_address_alert_message), + ) + cleanup?.invoke() + return CompletableDeferred(false) + } connectProgressManager.cancelConnectProgress() val inProgress = mutableStateOf(true) connectProgressManager.startConnectProgress(generalGetString(MR.strings.loading_profile)) { inProgress.value = false cleanup?.invoke() } - return planAndConnectTask(rhId, shortOrFullLink, close, cleanup, filterKnownContact, filterKnownGroup, inProgress) + return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, inProgress) } private suspend fun planAndConnectTask( rhId: Long?, shortOrFullLink: String, + linkOwnerSig: LinkOwnerSig? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? = null, filterKnownContact: ((Contact) -> Unit)? = null, @@ -56,7 +68,7 @@ private suspend fun planAndConnectTask( cleanup?.invoke() completable.complete(!completable.isActive) } - val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, inProgress = inProgress) + val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, linkOwnerSig, inProgress = inProgress) connectProgressManager.stopConnectProgress() if (!inProgress.value) { return completable } if (result != null) { @@ -75,6 +87,7 @@ private suspend fun planAndConnectTask( rhId, connectionLink, connectionPlan.invitationLinkPlan.contactSLinkData_, + ownerVerification = connectionPlan.invitationLinkPlan.ownerVerification, close, cleanup ) @@ -86,6 +99,7 @@ private suspend fun planAndConnectTask( text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, connectDestructive = false, cleanup = cleanup, + ownerVerification = connectionPlan.invitationLinkPlan.ownerVerification, ) } InvitationLinkPlan.OwnLink -> { @@ -136,6 +150,7 @@ private suspend fun planAndConnectTask( rhId, connectionLink, connectionPlan.contactAddressPlan.contactSLinkData_, + ownerVerification = connectionPlan.contactAddressPlan.ownerVerification, close, cleanup ) @@ -147,6 +162,7 @@ private suspend fun planAndConnectTask( text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, connectDestructive = false, cleanup, + ownerVerification = connectionPlan.contactAddressPlan.ownerVerification, ) } ContactAddressPlan.OwnLink -> { @@ -203,7 +219,9 @@ private suspend fun planAndConnectTask( showPrepareGroupAlert( rhId, connectionLink, + connectionPlan.groupLinkPlan.groupSLinkInfo_, connectionPlan.groupLinkPlan.groupSLinkData_, + ownerVerification = connectionPlan.groupLinkPlan.ownerVerification, close, cleanup ) @@ -215,6 +233,7 @@ private suspend fun planAndConnectTask( text = generalGetString(MR.strings.you_will_join_group) + linkText, connectDestructive = false, cleanup = cleanup, + ownerVerification = connectionPlan.groupLinkPlan.ownerVerification, ) } is GroupLinkPlan.OwnLink -> { @@ -270,6 +289,33 @@ private suspend fun planAndConnectTask( cleanup() } } + is GroupLinkPlan.NoRelays -> { + Log.d(TAG, "planAndConnect, .GroupLink, .NoRelays") + val groupSLinkData = connectionPlan.groupLinkPlan.groupSLinkData_ + if (groupSLinkData != null) { + AlertManager.privacySensitive.showOpenChatAlert( + profileName = groupSLinkData.groupProfile.displayName, + profileFullName = groupSLinkData.groupProfile.fullName, + profileImage = { + ProfileImage( + size = alertProfileImageSize, + image = groupSLinkData.groupProfile.image, + icon = MR.images.ic_bigtop_updates_circle_filled + ) + }, + subtitle = generalGetString(MR.strings.channel_no_active_relays_try_later), + confirmText = null, + dismissText = generalGetString(MR.strings.ok), + onDismiss = { cleanup() } + ) + } else { + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.channel_temporarily_unavailable), + generalGetString(MR.strings.channel_no_active_relays_try_later) + ) + cleanup() + } + } } is ConnectionPlan.Error -> { Log.d(TAG, "planAndConnect, error ${connectionPlan.chatError}") @@ -337,10 +383,12 @@ fun askCurrentOrIncognitoProfileAlert( text: String? = null, connectDestructive: Boolean, cleanup: (() -> Unit)?, + ownerVerification: OwnerVerification? = null, ) { + val fullText = listOfNotNull(text, ownerVerificationMessage(ownerVerification)).joinToString("\n\n").ifEmpty { null } AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = title, - text = text, + text = fullText, buttons = { Column { val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary @@ -421,52 +469,79 @@ fun ownGroupLinkConfirmConnect( close: (() -> Unit)?, cleanup: (() -> Unit)?, ) { - AlertManager.privacySensitive.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.connect_plan_join_your_group), - text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, - buttons = { - Column { - // Open group - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - openKnownGroup(chatModel, rhId, close, groupInfo) - cleanup?.invoke() - }) { - Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - // Use current profile - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) + if (groupInfo.useRelays) { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), groupInfo.displayName), + buttons = { + Column { + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() + }) { + Text(generalGetString(MR.strings.connect_plan_open_channel), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - // Use new incognito profile - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } - // Cancel - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - cleanup?.invoke() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } else { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_join_your_group), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, + buttons = { + Column { + // Open group + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() + }) { + Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Use current profile + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Use new incognito profile + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Cancel + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } - } - }, - onDismissRequest = cleanup, - hostDevice = hostDevice(rhId), - ) + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } } private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) { + val subscriberCount = if (groupInfo.useRelays) groupInfo.groupSummary.publicMemberCount?.let { subscriberCountStr(it) } else null AlertManager.privacySensitive.showOpenChatAlert( profileName = groupInfo.groupProfile.displayName, profileFullName = groupInfo.groupProfile.fullName, @@ -477,8 +552,11 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (( icon = groupInfo.chatIconName ) }, + subtitle = subscriberCount, confirmText = generalGetString( - if (groupInfo.businessChat == null) { + if (groupInfo.useRelays) { + if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_channel + } else if (groupInfo.businessChat == null) { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_group else MR.strings.connect_plan_open_group } else { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat @@ -505,6 +583,7 @@ fun showPrepareContactAlert( rhId: Long?, connectionLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, + ownerVerification: OwnerVerification? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { @@ -521,9 +600,11 @@ fun showPrepareContactAlert( else MR.images.ic_account_circle_filled ) }, + information = ownerVerificationMessage(ownerVerification), confirmText = generalGetString(MR.strings.connect_plan_open_new_chat), onConfirm = { AlertManager.privacySensitive.hideAlert() + ModalManager.closeAllModalsEverywhere() withBGApi { val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData) if (chat != null) { @@ -544,21 +625,41 @@ fun showPrepareContactAlert( fun showPrepareGroupAlert( rhId: Long?, connectionLink: CreatedConnLink, + groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, + ownerVerification: OwnerVerification? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { + val isChannel = !(groupShortLinkInfo?.direct ?: true) + val subscriberCount = if (isChannel) groupShortLinkData.publicGroupData?.publicMemberCount?.let { subscriberCountStr(it) } else null AlertManager.privacySensitive.showOpenChatAlert( profileName = groupShortLinkData.groupProfile.displayName, profileFullName = groupShortLinkData.groupProfile.fullName, - profileImage = { ProfileImage(size = alertProfileImageSize, image = groupShortLinkData.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled) }, - confirmText = generalGetString(MR.strings.connect_plan_open_new_group), + profileImage = { + ProfileImage( + size = alertProfileImageSize, + image = groupShortLinkData.groupProfile.image, + icon = if (isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled + ) + }, + subtitle = subscriberCount, + information = ownerVerificationMessage(ownerVerification), + confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group), onConfirm = { AlertManager.privacySensitive.hideAlert() withBGApi { - val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, groupShortLinkData) + val directLink = groupShortLinkInfo?.direct ?: true + val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData) if (chat != null) { withContext(Dispatchers.Main) { + val relays = groupShortLinkInfo?.groupRelays + if (!relays.isNullOrEmpty()) { + val chatInfo = chat.chatInfo + if (chatInfo is ChatInfo.Group) { + chatModel.channelRelayHostnames[chatInfo.groupInfo.groupId] = relays + } + } ChatController.chatModel.chatsContext.addChat(chat) openChat_(chatModel, rhId, close, chat) } @@ -571,3 +672,9 @@ fun showPrepareGroupAlert( } ) } + +fun ownerVerificationMessage(ov: OwnerVerification?): String? = when (ov) { + is OwnerVerification.Verified -> generalGetString(MR.strings.owner_verification_passed) + is OwnerVerification.Failed -> String.format(generalGetString(MR.strings.owner_verification_failed), ov.reason) + null -> null +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index ef6e426141..1eceaf4158 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -63,6 +63,9 @@ fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { createGroup = { ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) } }, + createChannel = { + ModalManager.start.showCustomModal { close -> AddChannelView(chatModel, close, closeAll) } + }, rh = rh, close = close ) @@ -71,7 +74,7 @@ fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { Column(Modifier.align(Alignment.BottomCenter)) { DefaultAppBar( navigationButton = { NavigationButtonBack(onButtonClicked = close) }, - fixedTitleText = generalGetString(MR.strings.new_message), + fixedTitleText = generalGetString(MR.strings.new_chat), onTop = false, ) } @@ -110,6 +113,7 @@ private fun ModalData.NewChatSheetLayout( addContact: () -> Unit, scanPaste: () -> Unit, createGroup: () -> Unit, + createChannel: () -> Unit, close: () -> Unit, ) { val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -193,6 +197,11 @@ private fun ModalData.NewChatSheetLayout( painterResource(MR.images.ic_group), stringResource(MR.strings.create_group_button), createGroup, + ), + Triple( + painterResource(MR.images.ic_bigtop_updates), + stringResource(MR.strings.create_channel_beta_button), + createChannel, ) ) @@ -350,7 +359,7 @@ private fun ModalData.NewChatSheetLayout( item { Box(Modifier.padding(top = blankSpaceSize)) { AppBarTitle( - stringResource(MR.strings.new_message), + stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index f520a86999..72311cd7fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -21,9 +21,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @@ -39,6 +41,7 @@ import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR import kotlinx.coroutines.* @@ -47,7 +50,7 @@ enum class NewChatOption { } @Composable -fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) { +fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, onboarding: Boolean = false, close: () -> Unit) { val selection = remember { stateGetOrPut("selection") { selection } } val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) } @@ -104,60 +107,71 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC } } - BoxWithConstraints { + if (onboarding) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING) - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState( - initialPage = selection.value.ordinal, - initialPageOffsetFraction = 0f - ) { NewChatOption.values().size } - KeyChangeEffect(pagerState.currentPage) { - selection.value = NewChatOption.values()[pagerState.currentPage] + Spacer(Modifier.height(DEFAULT_PADDING)) + when (selection.value) { + NewChatOption.INVITE -> PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq, onboarding = true) + NewChatOption.CONNECT -> ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close, onboarding = true) } - TabRow( - selectedTabIndex = pagerState.currentPage, - backgroundColor = Color.Transparent, - contentColor = MaterialTheme.colors.primary, - ) { - tabTitles.forEachIndexed { index, it -> - LeadingIconTab( - selected = pagerState.currentPage == index, - onClick = { - scope.launch { - pagerState.animateScrollToPage(index) - } - }, - text = { Text(it, fontSize = 13.sp) }, - icon = { - Icon( - if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code), - it - ) - }, - selectedContentColor = MaterialTheme.colors.primary, - unselectedContentColor = MaterialTheme.colors.secondary, - ) + SectionBottomSpacer() + } + } else { + BoxWithConstraints { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING) + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = selection.value.ordinal, + initialPageOffsetFraction = 0f + ) { NewChatOption.values().size } + KeyChangeEffect(pagerState.currentPage) { + selection.value = NewChatOption.values()[pagerState.currentPage] } - } - - HorizontalPager(state = pagerState, Modifier, pageNestedScrollConnection = LocalAppBarHandler.current!!.connection, verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index -> - Column( - Modifier - .fillMaxWidth() - .heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp), - verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connLinkInvitation.connFullLink.isEmpty()) Arrangement.Center else Arrangement.Top + TabRow( + selectedTabIndex = pagerState.currentPage, + backgroundColor = Color.Transparent, + contentColor = MaterialTheme.colors.primary, ) { - Spacer(Modifier.height(DEFAULT_PADDING)) - when (index) { - NewChatOption.INVITE.ordinal -> { - PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq) - } - NewChatOption.CONNECT.ordinal -> { - ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) - } + tabTitles.forEachIndexed { index, it -> + LeadingIconTab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(it, fontSize = 13.sp) }, + icon = { + Icon( + if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code), + it + ) + }, + selectedContentColor = MaterialTheme.colors.primary, + unselectedContentColor = MaterialTheme.colors.secondary, + ) + } + } + + HorizontalPager(state = pagerState, Modifier, pageNestedScrollConnection = LocalAppBarHandler.current!!.connection, verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index -> + Column( + Modifier + .fillMaxWidth() + .heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp), + verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connLinkInvitation.connFullLink.isEmpty()) Arrangement.Center else Arrangement.Top + ) { + Spacer(Modifier.height(DEFAULT_PADDING)) + when (index) { + NewChatOption.INVITE.ordinal -> { + PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq) + } + NewChatOption.CONNECT.ordinal -> { + ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) + } + } + SectionBottomSpacer() } - SectionBottomSpacer() } } } @@ -165,12 +179,13 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC } @Composable -private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState) { +private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState, onboarding: Boolean = false) { if (connLinkInvitation.connFullLink.isNotEmpty()) { InviteView( rhId, connLinkInvitation = connLinkInvitation, contactConnection = contactConnection, + onboarding = onboarding, ) } else if (creatingConnReq.value) { CreatingLinkProgressView() @@ -448,23 +463,53 @@ fun ActiveProfilePicker( } @Composable -private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState) { +private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState, onboarding: Boolean = false) { val showShortLink = remember { mutableStateOf(true) } - Spacer(Modifier.height(10.dp)) - SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.one_time_link_light else MR.images.one_time_link_small_light + } else { + if (onboarding) MR.images.one_time_link else MR.images.one_time_link_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } else { + Spacer(Modifier.height(10.dp)) + } + + if (onboarding) { + Text( + stringResource(MR.strings.onboarding_send_1_time_link), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) - } - - Spacer(Modifier.height(DEFAULT_PADDING)) - - SectionViewWithButton( - stringResource(MR.strings.or_show_this_qr_code).uppercase(), - titleButton = if (connLinkInvitation.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null - ) { + Text( + stringResource(MR.strings.onboarding_or_show_qr_code), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) + } else { + SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) + } + + Spacer(Modifier.height(DEFAULT_PADDING)) + + SectionViewWithButton( + stringResource(MR.strings.or_show_this_qr_code).uppercase(), + titleButton = if (connLinkInvitation.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) + } } + if (!onboarding) { Spacer(Modifier.height(DEFAULT_PADDING)) val incognito by remember(chatModel.showingInvitation.value?.conn?.incognito, controller.appPrefs.incognito.get()) { derivedStateOf { @@ -531,6 +576,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact SectionTextFooter(generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared)) } } + } } @Composable @@ -577,13 +623,26 @@ fun AddContactLearnMoreButton() { } @Composable -private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, pastedLink: MutableState, close: () -> Unit) { +private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, pastedLink: MutableState, close: () -> Unit, onboarding: Boolean = false) { DisposableEffect(Unit) { onDispose { connectProgressManager.cancelConnectProgress() } } + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.connect_via_link_light else MR.images.connect_via_link_small_light + } else { + if (onboarding) MR.images.connect_via_link else MR.images.connect_via_link_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } + SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase(), headerBottomPadding = 5.dp) { PasteLinkView(rhId, pastedLink, showQRCodeScanner, close) } @@ -625,7 +684,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC } }) { Box(Modifier.weight(1f)) { - Text(stringResource(MR.strings.tap_to_paste_link)) + Text(stringResource(MR.strings.tap_to_paste_link), color = MaterialTheme.colors.primary) } if (connectProgressManager.showConnectProgress != null) { CIFileViewScope.progressIndicator(sizeMultiplier = 0.6f) @@ -681,6 +740,13 @@ fun LinkTextView(link: String, share: Boolean) { // So using BasicTextField + manual ... Text("…", fontSize = 16.sp) if (share) { + Spacer(Modifier.width(DEFAULT_PADDING)) + IconButton({ + chatModel.markShowingInvitationUsed() + clipboard.setText(AnnotatedString(link)) + }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary) + } Spacer(Modifier.width(DEFAULT_PADDING)) IconButton({ chatModel.markShowingInvitationUsed() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt new file mode 100644 index 0000000000..26007c74af --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -0,0 +1,425 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.UserAddressView +import chat.simplex.res.MR +import kotlinx.coroutines.launch +import kotlin.math.cos +import kotlin.math.sin + +private const val CARD_HEIGHT_RATIO = 0.75f +private const val GRADIENT_ANGLE_RAD = 80.0 * Math.PI / 180.0 + +@Composable +fun shouldShowOnboarding(): Boolean { + val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val chats = chatModel.chats.value + return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) +} + +fun hasConversations(chats: List): Boolean = + chats.any { chat -> + when (val c = chat.chatInfo) { + is ChatInfo.Local -> false + is ChatInfo.Direct -> !c.contact.chatDeleted && !c.contact.isContactCard + is ChatInfo.Group -> true + is ChatInfo.ContactRequest -> false + is ChatInfo.ContactConnection -> false + is ChatInfo.InvalidJSON -> false + } + } + +internal data class GradientEndpoints(val startX: Float, val startY: Float, val endX: Float, val endY: Float) + +internal fun gradientPoints(aspectRatio: Float, scale: Float): GradientEndpoints { + val r = aspectRatio.toDouble() + val s = scale.toDouble() + val dx = cos(GRADIENT_ANGLE_RAD) + val dy = -sin(GRADIENT_ANGLE_RAD) / r + val dLenSq = dx * dx + dy * dy + val projections = doubleArrayOf( + -0.5 * dx + (-0.5) * dy, + 0.5 * dx + (-0.5) * dy, + -0.5 * dx + 0.5 * dy, + 0.5 * dx + 0.5 * dy + ) + val tMin = projections.min() + val tMax = projections.max() + val startX = 0.5 + tMin * dx / dLenSq + val startY = 0.5 + tMin * dy / dLenSq + val endX = 0.5 + tMax * dx / dLenSq + val endY = 0.5 + tMax * dy / dLenSq + return GradientEndpoints( + startX = (0.5 + (startX - 0.5) * s).toFloat(), + startY = (0.5 + (startY - 0.5) * s).toFloat(), + endX = (0.5 + (endX - 0.5) * s).toFloat(), + endY = (0.5 + (endY - 0.5) * s).toFloat() + ) +} + +internal val lightStops = arrayOf( + 0.0f to oklch(0.9219f, 0.0431f, 249.4f), + 0.5f to oklch(0.9198f, 0.0471f, 240.7f), + 0.9f to oklch(0.9772f, 0.0358f, 196.6f), + 0.95f to oklch(0.9829f, 0.0104f, 70.0f), + 1.0f to oklch(0.9886f, 0.0272f, 99.1f) +) + +internal val darkStops = arrayOf( + 0.4f to oklch(0.1578f, 0.0609f, 267.3f), + 0.72f to oklch(0.4729f, 0.1574f, 267.3f), + 0.9f to oklch(0.9024f, 0.0760f, 202.8f), + 0.95f to oklch(0.9384f, 0.0354f, 65.0f), + 1.0f to oklch(0.9744f, 0.0370f, 88.4f) +) + +private fun Modifier.maxHeightByWidthRatio(ratio: Float) = layout { measurable, constraints -> + val maxH = (constraints.maxWidth * ratio).toInt().coerceAtMost(constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = 0, maxHeight = maxH)) + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } +} + +@Composable +fun OnboardingCardView( + imageName: dev.icerock.moko.resources.ImageResource, + imageNameLight: dev.icerock.moko.resources.ImageResource, + icon: dev.icerock.moko.resources.ImageResource, + title: String, + subtitle: String? = null, + labelHeightRatio: Float, + onClick: () -> Unit +) { + var imageAreaSize by remember { mutableStateOf(IntSize.Zero) } + val isDark = isInDarkTheme() + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + + val brush = remember(imageAreaSize, isDark) { + if (imageAreaSize.width > 0 && imageAreaSize.height > 0) { + val aspect = imageAreaSize.height.toFloat() / imageAreaSize.width.toFloat() + val gp = gradientPoints(aspect, scale) + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * imageAreaSize.width, gp.startY * imageAreaSize.height), + end = Offset(gp.endX * imageAreaSize.width, gp.endY * imageAreaSize.height) + ) + } else { + Brush.linearGradient(colorStops = stops) + } + } + + val labelBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) + + Box( + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Column(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxWidth() + .weight(1f) + .background(brush) + .onSizeChanged { imageAreaSize = it } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) imageNameLight else imageName), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + painterResource(icon), + contentDescription = null, + modifier = Modifier.size(64.dp).align(Alignment.Center), + tint = MaterialTheme.colors.primary + ) + } + } + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f / labelHeightRatio) + .background(labelBg), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Icon( + painterResource(icon), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary + ) + } + Text( + title, + style = (if (appPlatform.isDesktop) MaterialTheme.typography.h3 else MaterialTheme.typography.h4).copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (subtitle != null) { + Text( + subtitle, + style = if (appPlatform.isDesktop) MaterialTheme.typography.body1 else MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground.copy(alpha = 0.7f) + ) + } + } + } + } + } +} + +@Composable +private fun PageHeader(title: String, isLandscape: Boolean, onBack: (() -> Unit)? = null) { + val color = if (onBack != null) MaterialTheme.colors.primary else Color.Transparent + val baseStyle = MaterialTheme.typography.h1 + val titleView = @Composable { + var fontScale by remember(title) { mutableStateOf(1f) } + Text( + title, + style = baseStyle.copy(fontSize = baseStyle.fontSize * fontScale), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + onTextLayout = { result -> + if (result.hasVisualOverflow && fontScale > 0.5f) { + fontScale -= 0.05f + } + } + ) + } + if (isLandscape) { + Box(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING)) { + BackButton(Modifier.align(Alignment.CenterStart), color, onBack) + titleView() + } + } else { + Column(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING)) { + Box(Modifier.align(Alignment.Start)) { + BackButton(color = color, onClick = onBack) + } + titleView() + } + } +} + +@Composable +private fun BackButton(modifier: Modifier = Modifier, color: Color = MaterialTheme.colors.primary, onClick: (() -> Unit)? = null) { + Row( + modifier + .clip(RoundedCornerShape(20.dp)) + .clickable(enabled = onClick != null, onClick = onClick ?: {}) + .padding(end = 12.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painterResource(MR.images.ic_arrow_back_ios_new), + contentDescription = stringResource(MR.strings.back), + tint = color, + modifier = Modifier.height(24.dp) + ) + Text(stringResource(MR.strings.back), color = color) + } +} + +@Composable +private fun CardPair( + isLandscape: Boolean, + heightRatio: Float, + card1: @Composable () -> Unit, + card2: @Composable () -> Unit +) { + if (isLandscape) { + Row( + Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f).maxHeightByWidthRatio(heightRatio)) { card1() } + Box(Modifier.weight(1f).maxHeightByWidthRatio(heightRatio)) { card2() } + } + } else { + Column( + Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING, Alignment.CenterVertically) + ) { + Box(Modifier.fillMaxWidth().weight(1f, fill = false).maxHeightByWidthRatio(heightRatio)) { card1() } + Box(Modifier.fillMaxWidth().weight(1f, fill = false).maxHeightByWidthRatio(heightRatio)) { card2() } + } + } +} + +@Composable +private fun OnboardingPageLayout( + title: String, + onBack: (() -> Unit)? = null, + cards: @Composable (isLandscape: Boolean) -> Unit +) { + val isLandscape = appPlatform.isDesktop || windowOrientation() == WindowOrientation.LANDSCAPE + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + PageHeader(title = title, isLandscape = isLandscape, onBack = onBack) + Box(Modifier.weight(1f).fillMaxWidth().padding(vertical = DEFAULT_PADDING)) { + cards(isLandscape) + } + } +} + +@Composable +fun ConnectOnboardingView() { + val pagerState = rememberPagerState(initialPage = 0) { 2 } + val scope = rememberCoroutineScope() + + val startModalsOpen = appPlatform.isDesktop && ModalManager.start.hasModalsOpen + val cardAlpha by animateFloatAsState(if (startModalsOpen) 0.3f else 1f) + + val cardClickOverride: (() -> Unit)? = if (startModalsOpen) { + { ModalManager.start.closeModals() } + } else null + + fun goToPage(target: Int) { + if (appPlatform.isDesktop) { + scope.launch { pagerState.scrollToPage(target) } + } else { + scope.launch { pagerState.animateScrollToPage(target, animationSpec = tween(350)) } + } + } + + val pager = @Composable { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + userScrollEnabled = !appPlatform.isDesktop + ) { page -> + when (page) { + 0 -> OnboardingPageLayout(title = stringResource(MR.strings.talk_to_someone)) { isLandscape -> + CardPair(isLandscape, CARD_HEIGHT_RATIO, + card1 = { + OnboardingCardView( + imageName = MR.images.card_let_someone_connect_to_you_alpha, + imageNameLight = MR.images.card_let_someone_connect_to_you_alpha_light, + icon = MR.images.ic_add_link, + title = stringResource(MR.strings.let_someone_connect_to_you), + labelHeightRatio = 0.132f, + onClick = cardClickOverride ?: { goToPage(1) } + ) + }, + card2 = { + OnboardingCardView( + imageName = MR.images.card_connect_via_link_alpha, + imageNameLight = MR.images.card_connect_via_link_alpha_light, + icon = MR.images.ic_qr_code_scanner, + title = stringResource(MR.strings.connect_via_link_or_qr_code), + labelHeightRatio = 0.132f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, onboarding = true, close = close) + } + } + ) + } + ) + } + 1 -> OnboardingPageLayout( + title = stringResource(MR.strings.connect_with_someone), + onBack = cardClickOverride ?: { goToPage(0) } + ) { isLandscape -> + CardPair(isLandscape, CARD_HEIGHT_RATIO, + card1 = { + OnboardingCardView( + imageName = MR.images.card_invite_someone_privately_alpha, + imageNameLight = MR.images.card_invite_someone_privately_alpha_light, + icon = MR.images.ic_add_link, + title = stringResource(MR.strings.invite_someone_privately), + subtitle = stringResource(MR.strings.a_link_for_one_person), + labelHeightRatio = 0.195f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, onboarding = true, close = close) + } + } + ) + }, + card2 = { + OnboardingCardView( + imageName = MR.images.card_create_your_public_address_alpha, + imageNameLight = MR.images.card_create_your_public_address_alpha_light, + icon = MR.images.ic_qr_code, + title = stringResource(if (chatModel.userAddress.value != null) MR.strings.your_public_address else MR.strings.create_your_public_address), + subtitle = stringResource(MR.strings.for_anyone_to_reach_you), + labelHeightRatio = 0.195f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, onboarding = true, close = close) + } + } + ) + } + ) + } + } + } + } + + if (appPlatform.isDesktop) { + val maxContentWidth = DEFAULT_WINDOW_WIDTH - DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + Box( + Modifier.fillMaxSize().background(MaterialTheme.colors.background).padding(vertical = DEFAULT_PADDING).graphicsLayer { alpha = cardAlpha }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.widthIn(max = maxContentWidth).fillMaxHeight()) { + pager() + } + } + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + pager() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index 9c6c0fa635..2fd77b46a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -14,80 +14,160 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.TextStyle - import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.usersettings.networkAndServers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -fun ModalData.OnboardingConditionsView() { +fun OnboardingConditionsView(chatModel: ChatModel) { LaunchedEffect(Unit) { prepareChatBeforeFinishingOnboarding() } - CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView({}, showClose = false) { - val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } } - val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } } - ColumnWithScrollBar( - Modifier - .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), - maxIntrinsicSize = true - ) { - Box(Modifier.align(Alignment.CenterHorizontally)) { - AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), bottomPadding = DEFAULT_PADDING) - } + val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } } + val selectedOperatorIds = remember { + mutableStateOf(OnboardingSharedState.selectedOperatorIds.ifEmpty { + serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() + }) + } - Spacer(Modifier.weight(1f)) - Column( - (if (appPlatform.isDesktop) Modifier.width(450.dp).align(Alignment.CenterHorizontally) else Modifier) - .fillMaxWidth() - .padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING), - horizontalAlignment = Alignment.Start - ) { - Text( - stringResource(MR.strings.onboarding_conditions_private_chats_not_accessible), - style = TextStyle(fontSize = 17.sp, lineHeight = 23.sp) - ) - Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.onboarding_conditions_by_using_you_agree), - style = TextStyle(fontSize = 17.sp, lineHeight = 23.sp) - ) - Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.onboarding_conditions_privacy_policy_and_conditions_of_use), - style = TextStyle(fontSize = 17.sp), - color = MaterialTheme.colors.primary, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null + if (appPlatform.isDesktop) { + OnboardingConditionsDesktop(selectedOperatorIds) + } else { + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false, showAppBar = false) { + OnboardingShrinkingLayout( + modifier = Modifier.fillMaxSize().themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer) + .systemBarsPadding() + .padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING), + topPadding = DEFAULT_PADDING, + image = { + Column(Modifier.padding(vertical = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingImage( + MR.images.network_commitments, MR.images.network_commitments_light, MR.images.ic_shield, + modifier = Modifier.fillMaxWidth(), + aspectRatio = 1.5f + ) + } + }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + stringResource(MR.strings.onboarding_network_commitments), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + lineHeight = 42.sp, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) + Column( + Modifier.fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING_HALF) + .padding(top = DEFAULT_PADDING), + horizontalAlignment = Alignment.Start ) { - ModalManager.fullscreen.showModal(endButtons = { ConditionsLinkButton() }) { - SimpleConditionsView(rhId = null) - } + Text( + stringResource(MR.strings.onboarding_conditions_private_chats_not_accessible), + style = MaterialTheme.typography.body2, + lineHeight = 22.sp + ) + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.onboarding_conditions_by_using_you_agree), + style = MaterialTheme.typography.body2, + lineHeight = 22.sp + ) + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.onboarding_conditions_privacy_policy_and_conditions_of_use), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + ModalManager.fullscreen.showModal(endButtons = { ConditionsLinkButton() }) { + SimpleConditionsView(rhId = null) { + ModalManager.fullscreen.closeModal() + acceptConditions(selectedOperatorIds.value) + } + } + } + ) } - ) - } - Spacer(Modifier.weight(1f)) - - Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { - AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperatorIds) - TextButtonBelowOnboardingButton(stringResource(MR.strings.onboarding_conditions_configure_server_operators)) { - ModalManager.fullscreen.showModalCloseable { close -> - ChooseServerOperators(serverOperators, selectedOperatorIds, close) + } + }, + button = { + Column(Modifier.widthIn(max = 450.dp).padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) { + AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperatorIds) } } + ) + } + } + } +} + +@Composable +private fun OnboardingConditionsDesktop(selectedOperatorIds: MutableState>) { + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false) { + ColumnWithScrollBar(horizontalAlignment = Alignment.CenterHorizontally) { + Column(Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + AppBarTitle(stringResource(MR.strings.onboarding_network_commitments), bottomPadding = DEFAULT_PADDING, withPadding = false, overrideTitleColor = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, lineHeight = 42.sp) + } + Column(Modifier.width(450.dp), horizontalAlignment = Alignment.Start) { + ReadableText(MR.strings.onboarding_conditions_private_chats_not_accessible, TextAlign.Start, padding = PaddingValues(), style = MaterialTheme.typography.body1) + Spacer(Modifier.height(DEFAULT_PADDING)) + ReadableText(MR.strings.onboarding_conditions_by_using_you_agree, TextAlign.Start, padding = PaddingValues(), style = MaterialTheme.typography.body1) + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.onboarding_conditions_privacy_policy_and_conditions_of_use), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + ModalManager.fullscreen.showModal(forceAnimated = true, endButtons = { ConditionsLinkButton() }) { + SimpleConditionsView(rhId = null) { + ModalManager.fullscreen.closeModal() + acceptConditions(selectedOperatorIds.value) + } + } + } + ) + } + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + Column(Modifier.widthIn(max = 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperatorIds) + TextButtonBelowOnboardingButton("", null) } } } @@ -104,7 +184,7 @@ fun ModalData.ChooseServerOperators( prepareChatBeforeFinishingOnboarding() } CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView({}, showClose = false) { + ModalView(close, enableClose = selectedOperatorIds.value.isNotEmpty()) { ColumnWithScrollBar( Modifier .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), @@ -141,11 +221,9 @@ fun ModalData.ChooseServerOperators( } Spacer(Modifier.weight(1f)) - Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).padding(bottom = DEFAULT_PADDING * 2).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { val enabled = selectedOperatorIds.value.isNotEmpty() SetOperatorsButton(enabled, close) - // Reserve space - TextButtonBelowOnboardingButton("", null) } } } @@ -212,52 +290,42 @@ private fun SetOperatorsButton(enabled: Boolean, close: () -> Unit) { ) } +private fun acceptConditions(selectedOperatorIds: Set) { + withBGApi { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = selectedOperatorIds.toList()) + if (r != null) { + chatModel.conditions.value = r + val enabledOps = enabledOperators(r.serverOperators, selectedOperatorIds) + if (enabledOps != null) { + val r2 = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOps) + if (r2 != null) { + chatModel.conditions.value = r2 + completeOnboarding() + } + } else { + completeOnboarding() + } + } + } +} + @Composable private fun AcceptConditionsButton( enabled: Boolean, selectedOperatorIds: State> ) { - fun continueOnAccept() { - if (appPlatform.isDesktop) { - continueToNextStep() - } else { - continueToSetNotificationsAfterAccept() - } - } OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), labelId = MR.strings.onboarding_conditions_accept, onboarding = null, enabled = enabled, - onclick = { - withBGApi { - val conditionsId = chatModel.conditions.value.currentConditions.conditionsId - val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = selectedOperatorIds.value.toList()) - if (r != null) { - chatModel.conditions.value = r - val enabledOperators = enabledOperators(r.serverOperators, selectedOperatorIds.value) - if (enabledOperators != null) { - val r2 = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators) - if (r2 != null) { - chatModel.conditions.value = r2 - continueOnAccept() - } - } else { - continueOnAccept() - } - } - } - } + onclick = { acceptConditions(selectedOperatorIds.value) } ) } -private fun continueToNextStep() { - appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete) -} - -private fun continueToSetNotificationsAfterAccept() { - appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) - ModalManager.fullscreen.showModalCloseable(showClose = false) { SetNotificationsMode(chatModel) } +private fun completeOnboarding() { + appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) } private fun enabledOperators(operators: List, selectedOperatorIds: Set): List? { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index aff02e90f5..703d295523 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -1,8 +1,7 @@ package chat.simplex.common.views.onboarding import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -15,7 +14,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* @@ -24,21 +23,26 @@ import dev.icerock.moko.resources.StringResource @Composable fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null) { - ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) { - AppBarTitle(stringResource(MR.strings.how_simplex_works), withPadding = false) - ReadableText(MR.strings.to_protect_privacy_simplex_has_ids_for_queues) - ReadableText(MR.strings.only_client_devices_store_contacts_groups_e2e_encrypted_messages) - ReadableText(MR.strings.all_message_and_files_e2e_encrypted) - if (onboardingStage == null) { - ReadableTextWithLink(MR.strings.read_more_in_github_with_link, "https://github.com/simplex-chat/simplex-chat#readme") + Column(Modifier.fillMaxSize().padding(horizontal = if (appPlatform.isDesktop) DEFAULT_PADDING * 2 else DEFAULT_PADDING)) { + Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier)) + val paraPadding = PaddingValues(bottom = if (appPlatform.isDesktop) 10.dp else 12.dp) + Column(Modifier.weight(1f).padding(bottom = DEFAULT_PADDING).verticalScroll(rememberScrollState())) { + Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) + ReadableText(MR.strings.why_built_p1, padding = paraPadding) + ReadableText(MR.strings.why_built_p2, padding = paraPadding) + ReadableText(MR.strings.why_built_p3, padding = paraPadding) + ReadableText(MR.strings.why_built_p4, padding = paraPadding) + ReadableText(MR.strings.why_built_p5, padding = paraPadding) + ReadableText(MR.strings.why_built_p6, padding = paraPadding) + ReadableText(MR.strings.why_built_p7, padding = paraPadding) + ReadableText(MR.strings.why_built_tagline, padding = paraPadding) } - - Spacer(Modifier.fillMaxHeight().weight(1f)) - if (onboardingStage != null) { - Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Column( + Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), + horizontalAlignment = Alignment.CenterHorizontally + ) { OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.fullscreen.closeModal() }) - // Reserve space TextButtonBelowOnboardingButton("", null) } } @@ -67,7 +71,7 @@ fun ReadableTextWithLink(stringResId: StringResource, link: String, textAlign: T newStyles } val uriHandler = LocalUriHandler.current - Text(AnnotatedString(annotated.text, newStyles), modifier = Modifier.padding(padding).clickable { if (simplexLink) uriHandler.openVerifiedSimplexUri(link) else uriHandler.openUriCatching(link) }, textAlign = textAlign, lineHeight = 22.sp) + Text(AnnotatedString(annotated.text, newStyles), modifier = Modifier.padding(padding).clickable { if (simplexLink) uriHandler.openVerifiedSimplexUri(link) else uriHandler.openExternalLink(link) }, textAlign = textAlign, lineHeight = 22.sp) } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt index 9e48f4b2bd..e902b7947e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.onboarding import SectionTextFooter import SectionView import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -57,7 +58,7 @@ private fun LinkAMobileLayout( ModalView({ appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }) { Column(Modifier.fillMaxSize().padding(top = AppBarHeight * fontSizeSqrtMultiplier)) { Box(Modifier.align(Alignment.CenterHorizontally)) { - AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles)) + AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), overrideTitleColor = MaterialTheme.colors.onBackground) } Row(Modifier.weight(1f).padding(horizontal = DEFAULT_PADDING * 2), verticalAlignment = Alignment.CenterVertically) { Column( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt new file mode 100644 index 0000000000..684bfb0053 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt @@ -0,0 +1,161 @@ +package chat.simplex.common.views.onboarding + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.* +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.isInDarkTheme +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.mixWith +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.compose.painterResource + +/** + * A layout for onboarding screens: image + content + spacer + button. + * The spacer shrinks first (down to [minSpacerHeight]), then the image shrinks. + * Button is always at the bottom. + */ +@Composable +fun OnboardingShrinkingLayout( + modifier: Modifier = Modifier, + topPadding: Dp = 0.dp, + minSpacerHeight: Dp = 20.dp, + image: @Composable () -> Unit, + content: @Composable () -> Unit, + button: @Composable () -> Unit +) { + Layout( + contents = listOf(image, content, button), + modifier = modifier + ) { (imageMeasurables, contentMeasurables, buttonMeasurables), constraints -> + val width = constraints.maxWidth + val height = constraints.maxHeight + val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) + + // 1. Measure fixed content (texts) and button first + val contentPlaceable = contentMeasurables.first().measure(childConstraints) + val buttonPlaceable = buttonMeasurables.first().measure(childConstraints) + val minSpacer = minSpacerHeight.roundToPx() + + // 2. Image gets remaining after top padding + content + button + minimum spacer + val topPad = topPadding.roundToPx() + val reservedHeight = topPad + contentPlaceable.height + buttonPlaceable.height + minSpacer + val imageMaxHeight = (height - reservedHeight).coerceAtLeast(0) + val imagePlaceable = imageMeasurables.first().measure( + childConstraints.copy(maxWidth = width, maxHeight = imageMaxHeight) + ) + + // 3. Spacer fills whatever is left between content and button + val usedHeight = topPad + imagePlaceable.height + contentPlaceable.height + buttonPlaceable.height + val spacerHeight = (height - usedHeight).coerceAtLeast(minSpacer) + + // 4. Place: image centered horizontally, rest below + layout(width, height) { + var y = topPad + imagePlaceable.placeRelative((width - imagePlaceable.width) / 2, y) + y += imagePlaceable.height + contentPlaceable.placeRelative((width - contentPlaceable.width) / 2, y) + y += contentPlaceable.height + y += spacerHeight + buttonPlaceable.placeRelative((width - buttonPlaceable.width) / 2, y) + } + } +} + +@Composable +fun OnboardingImage( + lightImage: ImageResource, + darkImage: ImageResource, + fallbackIcon: ImageResource, + modifier: Modifier = Modifier, + aspectRatio: Float = 1f +) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) darkImage else lightImage), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth().then(modifier) + ) + } else { + val isDark = isInDarkTheme() + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + Box( + modifier + .aspectRatio(aspectRatio) + .clip(RoundedCornerShape(24.dp)) + .drawBehind { + val gp = gradientPoints(size.height / size.width, scale) + drawRect( + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * size.width, gp.startY * size.height), + end = Offset(gp.endX * size.width, gp.endY * size.height) + ) + ) + }, + contentAlignment = Alignment.Center + ) { + Icon( + painterResource(fallbackIcon), + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colors.primary + ) + } + } +} + +@Composable +fun DesktopOnboardingShell(stage: OnboardingStage, content: @Composable () -> Unit) { + Row(Modifier.fillMaxSize()) { + Box( + Modifier.weight(0.382f).fillMaxHeight() + .background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.985f)) + .padding(horizontal = DEFAULT_PADDING), + contentAlignment = Alignment.Center + ) { + when (stage) { + OnboardingStage.Step1_SimpleXInfo -> + OnboardingImage(MR.images.intro, MR.images.intro_light, MR.images.ic_forum, Modifier.fillMaxWidth()) + OnboardingStage.Step2_CreateProfile, + OnboardingStage.Step2_5_SetupDatabasePassphrase, + OnboardingStage.LinkAMobile -> + OnboardingImage(MR.images.your_profile, MR.images.your_profile_light, MR.images.ic_person, Modifier.fillMaxWidth()) + OnboardingStage.Step3_ChooseServerOperators, + OnboardingStage.Step3_CreateSimpleXAddress, + OnboardingStage.Step4_SetNotificationsMode -> + OnboardingImage(MR.images.your_network, MR.images.your_network_light, MR.images.ic_dns, Modifier.fillMaxWidth()) + OnboardingStage.Step4_NetworkCommitments -> + OnboardingImage(MR.images.network_commitments, MR.images.network_commitments_light, MR.images.ic_shield, Modifier.fillMaxWidth(), aspectRatio = 1.5f) + else -> {} + } + } + Divider(Modifier.fillMaxHeight().width(1.dp)) + Box(Modifier.weight(0.618f).fillMaxHeight().clipToBounds()) { + content() + ModalManager.fullscreen.showInView() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index 510df13c3d..7af364b855 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -6,7 +6,8 @@ enum class OnboardingStage { LinkAMobile, Step2_5_SetupDatabasePassphrase, Step3_ChooseServerOperators, - Step3_CreateSimpleXAddress, - Step4_SetNotificationsMode, + Step3_CreateSimpleXAddress, // deprecated + Step4_SetNotificationsMode, // deprecated + Step4_NetworkCommitments, OnboardingComplete } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index 84f473067f..adcfb8b194 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.stringResource @@ -14,27 +14,21 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatModel import chat.simplex.common.model.NotificationsMode import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.changeNotificationsMode import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource @Composable -fun SetNotificationsMode(m: ChatModel) { - LaunchedEffect(Unit) { - prepareChatBeforeFinishingOnboarding() - } - +fun SetNotificationsMode(currentMode: MutableState, onDone: () -> Unit) { CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { ModalView({}, showClose = false) { ColumnWithScrollBar(Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) { Box(Modifier.align(Alignment.CenterHorizontally)) { AppBarTitle(stringResource(MR.strings.onboarding_notifications_mode_title), bottomPadding = DEFAULT_PADDING) } - val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) } Column(Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingInformationButton( stringResource(MR.strings.onboarding_notifications_mode_subtitle), @@ -43,34 +37,28 @@ fun SetNotificationsMode(m: ChatModel) { } Spacer(Modifier.weight(1f)) Column(Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING)) { - SelectableCard(currentMode, NotificationsMode.SERVICE, stringResource(MR.strings.onboarding_notifications_mode_service), annotatedStringResource(MR.strings.onboarding_notifications_mode_service_desc_short)) { + SelectableCard(currentMode, NotificationsMode.SERVICE, stringResource(MR.strings.onboarding_notifications_mode_service), annotatedStringResource(MR.strings.onboarding_notifications_mode_service_desc_short), icon = painterResource(MR.images.ic_bolt)) { currentMode.value = NotificationsMode.SERVICE } - SelectableCard(currentMode, NotificationsMode.PERIODIC, stringResource(MR.strings.onboarding_notifications_mode_periodic), annotatedStringResource(MR.strings.onboarding_notifications_mode_periodic_desc_short)) { + SelectableCard(currentMode, NotificationsMode.PERIODIC, stringResource(MR.strings.onboarding_notifications_mode_periodic), annotatedStringResource(MR.strings.onboarding_notifications_mode_periodic_desc_short), icon = painterResource(MR.images.ic_timer)) { currentMode.value = NotificationsMode.PERIODIC } - SelectableCard(currentMode, NotificationsMode.OFF, stringResource(MR.strings.onboarding_notifications_mode_off), annotatedStringResource(MR.strings.onboarding_notifications_mode_off_desc_short)) { + SelectableCard(currentMode, NotificationsMode.OFF, stringResource(MR.strings.onboarding_notifications_mode_off), annotatedStringResource(MR.strings.onboarding_notifications_mode_off_desc_short), icon = painterResource(MR.images.ic_bolt_off)) { currentMode.value = NotificationsMode.OFF } } Spacer(Modifier.weight(1f)) - Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).padding(bottom = DEFAULT_PADDING * 2).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier, - labelId = MR.strings.use_chat, - onboarding = OnboardingStage.OnboardingComplete, - onclick = { - changeNotificationsMode(currentMode.value, m) - ModalManager.fullscreen.closeModals() - } + labelId = MR.strings.ok, + onboarding = null, + onclick = onDone ) - // Reserve space - TextButtonBelowOnboardingButton("", null) } } } } - SetNotificationsModeAdditions() } @Composable @@ -78,20 +66,31 @@ expect fun SetNotificationsModeAdditions() @Composable fun SelectableCard(currentValue: State, newValue: T, title: String, description: AnnotatedString, onSelected: (T) -> Unit) { + SelectableCard(currentValue, newValue, title, description, icon = null, onSelected) +} + +@Composable +fun SelectableCard(currentValue: State, newValue: T, title: String, description: AnnotatedString, icon: Painter?, onSelected: (T) -> Unit) { + val titleColor = if (currentValue.value == newValue) MaterialTheme.colors.primary else MaterialTheme.colors.secondary TextButton( onClick = { onSelected(newValue) }, border = BorderStroke(1.dp, color = if (currentValue.value == newValue) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)), shape = RoundedCornerShape(35.dp), ) { Column(Modifier.padding(horizontal = 10.dp).padding(top = 4.dp, bottom = 8.dp).fillMaxWidth()) { - Text( - title, - style = MaterialTheme.typography.h3, - fontWeight = FontWeight.Medium, - color = if (currentValue.value == newValue) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier.padding(bottom = 8.dp).align(Alignment.CenterHorizontally), - textAlign = TextAlign.Center - ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 8.dp).align(Alignment.CenterHorizontally)) { + if (icon != null) { + Icon(icon, null, Modifier.size(18.dp), tint = titleColor) + Spacer(Modifier.width(8.dp)) + } + Text( + title, + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Medium, + color = titleColor, + textAlign = TextAlign.Center + ) + } Text(description, Modifier.align(Alignment.CenterHorizontally), fontSize = 15.sp, @@ -105,7 +104,7 @@ fun SelectableCard(currentValue: State, newValue: T, title: String, descr } @Composable -private fun NotificationBatteryUsageInfo() { +fun NotificationBatteryUsageInfo() { ColumnWithScrollBar(Modifier.padding(DEFAULT_PADDING)) { AppBarTitle(stringResource(MR.strings.onboarding_notifications_mode_battery), withPadding = false) Text(stringResource(MR.strings.onboarding_notifications_mode_service), style = MaterialTheme.typography.h3, color = MaterialTheme.colors.secondary) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index c6eceb0ce2..9ef72a7f12 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -107,7 +107,7 @@ private fun SetupDatabasePassphraseLayout( Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer).padding(horizontal = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally, ) { - AppBarTitle(stringResource(MR.strings.setup_database_passphrase)) + AppBarTitle(stringResource(MR.strings.setup_database_passphrase), overrideTitleColor = MaterialTheme.colors.onBackground) val onClickUpdate = { // Don't do things concurrently. Shouldn't be here concurrently, just in case diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index e5d00fddd1..74dadcd671 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -11,6 +11,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale @@ -21,11 +24,15 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.migration.MigrateToDeviceView import chat.simplex.common.views.migration.MigrationToState import chat.simplex.res.MR @@ -36,12 +43,16 @@ import kotlin.math.floor @Composable fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { if (onboarding) { - CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView({}, showClose = false, showAppBar = false) { - SimpleXInfoLayout( - user = chatModel.currentUser.value, - onboardingStage = chatModel.controller.appPrefs.onboardingStage - ) + if (appPlatform.isDesktop) { + SimpleXInfoDesktop(chatModel) + } else { + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false, showAppBar = false) { + SimpleXInfoLayout( + user = chatModel.currentUser.value, + onboardingStage = chatModel.controller.appPrefs.onboardingStage + ) + } } } } else { @@ -52,40 +63,106 @@ fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { } } +@Composable +private fun SimpleXInfoDesktop(chatModel: ChatModel) { + val user = chatModel.currentUser.value + val onboardingStage = chatModel.controller.appPrefs.onboardingStage + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false) { + ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(DEFAULT_PADDING)) + Box(Modifier.widthIn(max = 600.dp).fillMaxWidth(0.45f).align(Alignment.CenterHorizontally)) { + SimpleXLogo() + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + Column(Modifier.widthIn(max = 600.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + AppBarTitle(stringResource(MR.strings.onboarding_be_free), bottomPadding = DEFAULT_PADDING, withPadding = false, overrideTitleColor = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, lineHeight = 42.sp) + } + Text(stringResource(MR.strings.onboarding_private_and_secure), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Medium, color = MaterialTheme.colors.secondary, lineHeight = 25.sp, textAlign = TextAlign.Center) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + ReadableText(MR.strings.onboarding_first_network, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.secondary)) + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + Column(Modifier.widthIn(max = 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton(user, onboardingStage) + TextButtonBelowOnboardingButton(stringResource(MR.strings.why_simplex_is_built), icon = painterResource(MR.images.ic_info), onClick = { + ModalManager.fullscreen.showModal(forceAnimated = true) { HowItWorks(user, onboardingStage) } + }) + } + } + } + } + LaunchedEffect(Unit) { + if (chatModel.migrationState.value != null && !ModalManager.fullscreen.hasModalsOpen()) { + ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateToDeviceView(close) } + } + } +} + @Composable fun SimpleXInfoLayout( user: User?, onboardingStage: SharedPreference? ) { - ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING), horizontalAlignment = Alignment.CenterHorizontally) { - Box(Modifier.widthIn(max = if (appPlatform.isAndroid) 250.dp else 500.dp).padding(top = DEFAULT_PADDING + 8.dp), contentAlignment = Alignment.Center) { + val topBar = onboardingStage == null && !appPrefs.oneHandUI.state.value + val modifier = Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING) + Column(if (topBar) modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier) else modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.padding(top = DEFAULT_PADDING * 2).widthIn(max = if (appPlatform.isAndroid) 185.dp else 160.dp), contentAlignment = Alignment.Center) { SimpleXLogo() } - - OnboardingInformationButton( - stringResource(MR.strings.next_generation_of_private_messaging), - onClick = { ModalManager.fullscreen.showModal { HowItWorks(user, onboardingStage) } }, - ) - - Spacer(Modifier.weight(1f)) - - Column { - InfoRow(painterResource(MR.images.privacy), MR.strings.privacy_redefined, MR.strings.first_platform_without_user_ids, width = 60.dp) - InfoRow(painterResource(MR.images.shield), MR.strings.immune_to_spam_and_abuse, MR.strings.people_can_connect_only_via_links_you_share, width = 46.dp) - InfoRow(painterResource(if (isInDarkTheme()) MR.images.decentralized_light else MR.images.decentralized), MR.strings.decentralized, MR.strings.opensource_protocol_and_code_anybody_can_run_servers) - } - - Column(Modifier.fillMaxHeight().weight(1f)) { } - - if (onboardingStage != null) { - Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally,) { - OnboardingActionButton(user, onboardingStage) - TextButtonBelowOnboardingButton(stringResource(MR.strings.migrate_from_another_device)) { - chatModel.migrationState.value = MigrationToState.PasteOrScanLink - ModalManager.fullscreen.showCustomModal { close -> MigrateToDeviceView(close) } + OnboardingShrinkingLayout( + modifier = Modifier.fillMaxSize(), + image = { + Column(Modifier.padding(vertical = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingImage( + MR.images.intro, MR.images.intro_light, MR.images.ic_forum, + modifier = if (appPlatform.isAndroid) Modifier.fillMaxWidth() else Modifier.heightIn(max = 280.dp) + ) + } + }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + stringResource(MR.strings.onboarding_be_free), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + lineHeight = 42.sp, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) + Text( + stringResource(MR.strings.onboarding_private_and_secure), + style = MaterialTheme.typography.h3, + color = MaterialTheme.colors.secondary, + fontWeight = FontWeight.Medium, + lineHeight = 25.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 14.dp) + ) + Text( + stringResource(MR.strings.onboarding_first_network), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + lineHeight = 20.sp, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) + } + }, + button = { + if (onboardingStage != null) { + Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton(user, onboardingStage) + TextButtonBelowOnboardingButton(stringResource(MR.strings.why_simplex_is_built), icon = painterResource(MR.images.ic_info), onClick = { + ModalManager.fullscreen.showModal { HowItWorks(user, onboardingStage) } + }) } + } else { + Spacer(Modifier) } } + ) } LaunchedEffect(Unit) { if (chatModel.migrationState.value != null && !ModalManager.fullscreen.hasModalsOpen()) { @@ -101,25 +178,11 @@ fun SimpleXLogo() { contentDescription = stringResource(MR.strings.image_descr_simplex_logo), contentScale = ContentScale.FillWidth, modifier = Modifier - .padding(vertical = DEFAULT_PADDING) + .padding(bottom = 10.dp) .fillMaxWidth() ) } -@Composable -private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResource, width: Dp = 58.dp) { - Row(Modifier.padding(bottom = 27.dp), verticalAlignment = Alignment.Top) { - Spacer(Modifier.width((4.dp + 58.dp - width) / 2)) - Image(icon, contentDescription = null, modifier = Modifier - .width(width)) - Spacer(Modifier.width((4.dp + 58.dp - width) / 2 + DEFAULT_PADDING_HALF + 7.dp)) - Column(Modifier.padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF)) { - Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp) - Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary) - } - } -} - @Composable expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)? = null) @@ -155,16 +218,20 @@ fun OnboardingActionButton( } @Composable -fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) { +fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?, icon: Painter? = null) { val state = getKeyboardState() val enabled = onClick != null val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else 7.5.dp) val bottomPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else 7.5.dp) if ((appPlatform.isAndroid && state.value == KeyboardState.Closed) || topPadding > 0.dp) { TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) { + if (icon != null) { + Icon(icon, null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(4.dp)) + } Text( text, - Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF, bottom = 5.dp), + Modifier.padding(vertical = 5.dp), color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center @@ -219,6 +286,7 @@ fun OnboardingInformationButton( textLayoutResult = it }, style = MaterialTheme.typography.button, + fontWeight = FontWeight.Medium, color = MaterialTheme.colors.primary ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index f64f1dcecd..e1415d071d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -59,7 +59,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool Icon( painterResource(MR.images.ic_open_in_new), stringResource(titleId), tint = MaterialTheme.colors.primary, modifier = Modifier - .clickable { if (link.startsWith("simplex:")) uriHandler.openVerifiedSimplexUri(link) else uriHandler.openUriCatching(link) } + .clickable { if (link.startsWith("simplex:")) uriHandler.openVerifiedSimplexUri(link) else uriHandler.openExternalLink(link) } ) } @@ -229,7 +229,7 @@ fun ReadMoreButton(url: String) { interactionSource = remember { MutableInteractionSource() }, indication = null ) { - uriHandler.openUriCatching(url) + uriHandler.openExternalLink(url) } ) Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.whats_new_read_more), tint = MaterialTheme.colors.primary) @@ -880,6 +880,38 @@ private val versionDescriptions: List = listOf( ), ) ), + VersionDescription( + version = "v6.5", + post = "https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html", + features = listOf( + VersionFeature.FeatureDescription( + icon = null, + titleId = MR.strings.v6_5_public_channels, + descrId = null, + subfeatures = listOf( + MR.images.ic_wifi_tethering to MR.strings.v6_5_reliability, + MR.images.ic_dns to MR.strings.v6_5_ownership, + MR.images.ic_vpn_key_filled to MR.strings.v6_5_security, + MR.images.ic_shield to MR.strings.v6_5_privacy, + ) + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_add_link, + titleId = MR.strings.v6_5_invite_friends, + descrId = MR.strings.v6_5_invite_friends_descr + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_security, + titleId = MR.strings.v6_5_safe_web_links, + descrId = MR.strings.v6_5_safe_web_links_descr + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_verified_user, + titleId = MR.strings.v6_5_non_profit_governance, + descrId = MR.strings.v6_5_non_profit_governance_descr + ), + ) + ), ) private val lastVersion = versionDescriptions.last().version diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt new file mode 100644 index 0000000000..b20cfe3096 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt @@ -0,0 +1,226 @@ +package chat.simplex.common.views.onboarding + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.common.views.usersettings.changeNotificationsMode +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +internal object OnboardingSharedState { + var selectedOperatorIds: Set = emptySet() +} + +@Composable +fun YourNetworkView(chatModel: ChatModel) { + LaunchedEffect(Unit) { + prepareChatBeforeFinishingOnboarding() + } + + val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } } + val selectedOperatorIds = remember { + mutableStateOf(serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet()) + } + + LaunchedEffect(selectedOperatorIds.value) { + OnboardingSharedState.selectedOperatorIds = selectedOperatorIds.value + } + + val notificationMode = rememberSaveable { mutableStateOf(NotificationsMode.default) } + + if (appPlatform.isDesktop) { + YourNetworkDesktop(serverOperators, selectedOperatorIds) + } else { + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false, showAppBar = false) { + OnboardingShrinkingLayout( + modifier = Modifier.fillMaxSize().themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer) + .systemBarsPadding() + .padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING), + topPadding = DEFAULT_PADDING, + image = { + Column(Modifier.padding(vertical = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingImage( + MR.images.your_network, MR.images.your_network_light, MR.images.ic_dns, + modifier = Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() + ) + } + }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + stringResource(MR.strings.onboarding_your_network), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + lineHeight = 42.sp, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) + Text( + stringResource(MR.strings.onboarding_network_routers_cannot_know), + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, + lineHeight = 25.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 14.dp) + ) + Column( + Modifier.padding(top = DEFAULT_PADDING_HALF), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + ConfigureRoutersButton(serverOperators, selectedOperatorIds) { + ModalManager.fullscreen.showCustomModal { close -> + ChooseServerOperators(serverOperators, selectedOperatorIds, close) + } + } + ConfigureNotificationsButton(notificationMode) { + ModalManager.fullscreen.showModalCloseable { close -> + SetNotificationsMode(notificationMode, close) + } + } + } + } + }, + button = { + Column( + Modifier.widthIn(max = 450.dp).padding(bottom = DEFAULT_PADDING * 2), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OnboardingActionButton( + modifier = Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth(), + labelId = MR.strings.onboarding_network_operators_continue, + onboarding = null, + onclick = { + changeNotificationsMode(notificationMode.value, chatModel) + appPrefs.onboardingStage.set(OnboardingStage.Step4_NetworkCommitments) + } + ) + } + } + ) + } + } + } +} + +@Composable +private fun YourNetworkDesktop( + serverOperators: State>, + selectedOperatorIds: MutableState> +) { + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false) { + ColumnWithScrollBar(horizontalAlignment = Alignment.CenterHorizontally) { + Column(Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + AppBarTitle(stringResource(MR.strings.onboarding_your_network), bottomPadding = DEFAULT_PADDING, withPadding = false, overrideTitleColor = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, lineHeight = 42.sp) + } + Text(stringResource(MR.strings.onboarding_network_routers_cannot_know), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Medium, color = MaterialTheme.colors.secondary, lineHeight = 25.sp, textAlign = TextAlign.Center) + Spacer(Modifier.height(DEFAULT_PADDING)) + ConfigureRoutersButton(serverOperators, selectedOperatorIds) { + ModalManager.fullscreen.showCustomModal(forceAnimated = true) { close -> + ChooseServerOperators(serverOperators, selectedOperatorIds, close) + } + } + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + Column(Modifier.widthIn(max = 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton( + Modifier.widthIn(min = 300.dp), + labelId = MR.strings.onboarding_network_operators_continue, + onboarding = null, + onclick = { + appPrefs.onboardingStage.set(OnboardingStage.Step4_NetworkCommitments) + } + ) + TextButtonBelowOnboardingButton("", null) + } + } + } + } +} + +@Composable +private fun ConfigureRoutersButton(serverOperators: State>, selectedOperatorIds: State>, onClick: () -> Unit) { + Box( + modifier = Modifier + .clip(CircleShape) + .clickable { onClick() } + ) { + Row(Modifier.padding(8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(MR.strings.onboarding_configure_routers), + style = MaterialTheme.typography.button, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.primary + ) + serverOperators.value.forEach { op -> + Image( + painterResource(op.logo), + contentDescription = null, + modifier = Modifier.size(22.dp), + colorFilter = if (selectedOperatorIds.value.contains(op.operatorId)) null else ColorFilter.colorMatrix(ColorMatrix().apply { + setToSaturation(0f) + }) + ) + } + } + } +} + +@Composable +private fun ConfigureNotificationsButton(notificationMode: State, onClick: () -> Unit) { + val icon = when (notificationMode.value) { + NotificationsMode.SERVICE -> MR.images.ic_bolt + NotificationsMode.PERIODIC -> MR.images.ic_timer + NotificationsMode.OFF -> MR.images.ic_bolt_off + } + Box( + modifier = Modifier + .clip(CircleShape) + .clickable { onClick() } + ) { + Row(Modifier.padding(8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(MR.strings.onboarding_configure_notifications), + style = MaterialTheme.typography.button, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.primary + ) + Icon( + painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt index 761a74d6e4..0c31b062dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt @@ -198,7 +198,7 @@ private fun howToButton() { val uriHandler = LocalUriHandler.current Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { uriHandler.openUriCatching("https://simplex.chat/docs/webrtc.html#configure-mobile-apps") } + modifier = Modifier.clickable { uriHandler.openExternalLink("https://simplex.chat/docs/webrtc.html#configure-mobile-apps") } ) { Text(stringResource(MR.strings.how_to), color = MaterialTheme.colors.primary) Icon( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 7ea656e1e4..a02d67265d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -75,7 +75,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: ( } val simplexTeamUri = - "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" + "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" @Composable fun SettingsLayout( @@ -207,7 +207,7 @@ fun ChatLockItem( } @Composable private fun ContributeItem(uriHandler: UriHandler) { - SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat#contribute") }) { + SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat#contribute") }) { Icon( painterResource(MR.images.ic_keyboard), contentDescription = "GitHub", @@ -235,7 +235,7 @@ fun ChatLockItem( } @Composable private fun StarOnGithubItem(uriHandler: UriHandler) { - SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { + SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat") }) { Icon( painter = painterResource(MR.images.ic_github), contentDescription = "GitHub", @@ -268,7 +268,7 @@ fun ChatLockItem( } @Composable fun InstallTerminalAppItem(uriHandler: UriHandler) { - SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { + SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat") }) { Icon( painter = painterResource(MR.images.ic_github), contentDescription = "GitHub", @@ -295,14 +295,10 @@ fun ChatLockItem( } private fun resetHintPreferences() { - for ((pref, def) in appPreferences.hintPreferences) { - pref.set(def) - } + appPreferences.hintPreferences.forEach { it.reset() } } -fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { (pref, def) -> - pref.state.value == def -} +fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { it.isUnchanged() } @Composable fun AppVersionItem(showVersion: () -> Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 3b6cf34b7c..e5c731f3b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -7,7 +7,9 @@ import SectionTextFooter import SectionView import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* +import androidx.compose.ui.layout.ContentScale import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* @@ -28,6 +30,7 @@ import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.* +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR @Composable @@ -35,6 +38,7 @@ fun UserAddressView( chatModel: ChatModel, shareViaProfile: Boolean = false, autoCreateAddress: Boolean = false, + onboarding: Boolean = false, close: () -> Unit ) { // TODO close when remote host changes @@ -75,17 +79,31 @@ fun UserAddressView( addressSettings = AddressSettings(businessAddress = false, autoAccept = null, autoReply = null) ) - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.share_address_with_contacts_question), - text = generalGetString(MR.strings.add_address_to_your_profile), - confirmText = generalGetString(MR.strings.share_verb), - onConfirm = { - setProfileAddress(true) - shareViaProfile.value = true - } - ) + val hasRelevantContacts = chatModel.chats.value.any { chat -> + val ci = chat.chatInfo + ci is ChatInfo.Direct && + ci.contact.active && + !ci.contact.isContactCard && + !ci.contact.contactConnIncognito + } + if (hasRelevantContacts) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.share_address_with_contacts_question), + text = generalGetString(MR.strings.add_address_to_your_profile), + confirmText = generalGetString(MR.strings.share_verb), + onConfirm = { + setProfileAddress(true) + shareViaProfile.value = true + } + ) + progressIndicator.value = false + } else { + setProfileAddress(true) + shareViaProfile.value = true + } + } else { + progressIndicator.value = false } - progressIndicator.value = false } } @@ -103,6 +121,7 @@ fun UserAddressView( user = user.value, userAddress = userAddress.value, shareViaProfile, + onboarding = onboarding, createAddress = ::createAddress, showAddShortLinkAlert = { shareAddress: (() -> Unit)? -> showAddShortLinkAlert(progressIndicator = progressIndicator, share = ::share, shareAddress = shareAddress) @@ -249,6 +268,7 @@ private fun UserAddressLayout( user: User?, userAddress: UserContactLinkRec?, shareViaProfile: MutableState, + onboarding: Boolean = false, createAddress: () -> Unit, showAddShortLinkAlert: ((() -> Unit)?) -> Unit, learnMore: () -> Unit, @@ -259,68 +279,100 @@ private fun UserAddressLayout( saveAddressSettings: (AddressSettingsState, MutableState) -> Unit, ) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId)) + if (!onboarding) { + AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId)) + } + if (BuildConfigCommon.SIMPLEX_ASSETS && userAddress != null) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.simplex_address_light else MR.images.simplex_address_small_light + } else { + if (onboarding) MR.images.simplex_address else MR.images.simplex_address_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } Column( Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceEvenly ) { if (userAddress == null) { - SectionView(generalGetString(MR.strings.for_social_media).uppercase()) { - CreateAddressButton(createAddress) - } + if (!onboarding) { + SectionView(generalGetString(MR.strings.for_social_media).uppercase()) { + CreateAddressButton(createAddress) + } - SectionDividerSpaced() - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { - CreateOneTimeLinkButton() - } + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + CreateOneTimeLinkButton() + } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - SectionView { - LearnMoreButton(learnMore) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionView { + LearnMoreButton(learnMore) + } } } else { - val addressSettingsState = remember { mutableStateOf(AddressSettingsState(settings = userAddress.addressSettings)) } - val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } val showShortLink = remember { mutableStateOf(true) } - SectionViewWithButton( - stringResource(MR.strings.for_social_media).uppercase(), - titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null - ) { + if (onboarding) { + Text( + stringResource(MR.strings.onboarding_post_address), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) + LinkTextView(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value), true) + Text( + stringResource(MR.strings.onboarding_or_use_qr_code), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) - if (userAddress.shouldBeUpgraded) { - AddShortLinkButton(text = stringResource(MR.strings.add_short_link)) { showAddShortLinkAlert(null) } - } - ShareAddressButton { + } else { + val addressSettingsState = remember { mutableStateOf(AddressSettingsState(settings = userAddress.addressSettings)) } + val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } + + SectionViewWithButton( + stringResource(MR.strings.for_social_media).uppercase(), + titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) if (userAddress.shouldBeUpgraded) { - showAddShortLinkAlert { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } - } else { - share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) + AddShortLinkButton(text = stringResource(MR.strings.add_short_link)) { showAddShortLinkAlert(null) } + } + ShareAddressButton { + if (userAddress.shouldBeUpgraded) { + showAddShortLinkAlert { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } + } else { + share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) + } + } + // ShareViaEmailButton { sendEmail(userAddress) } + BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } + AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) + + if (addressSettingsState.value.businessAddress) { + SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) } } - // ShareViaEmailButton { sendEmail(userAddress) } - BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } - AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) - if (addressSettingsState.value.businessAddress) { - SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) + SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) + SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + CreateOneTimeLinkButton() + } + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + LearnMoreButton(learnMore) } - } - SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { - CreateOneTimeLinkButton() - } - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - LearnMoreButton(learnMore) - } - - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - DeleteAddressButton(deleteAddress) - SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + DeleteAddressButton(deleteAddress) + SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt new file mode 100644 index 0000000000..1c68e780dc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -0,0 +1,416 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewSpaceBetween +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.res.MR +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@Composable +fun ShowRelayTestStatus(relay: UserChatRelay, modifier: Modifier = Modifier) = + when (relay.tested) { + true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen) + false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error) + else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent) + } + +fun validRelayName(name: String): Boolean = + name.isNotEmpty() && isValidDisplayName(name) + +fun showInvalidRelayNameAlert(name: MutableState) { + val validName = mkValidName(name.value) + if (validName.isEmpty()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_name) + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.invalid_name), + text = String.format(generalGetString(MR.strings.correct_name_to), validName), + onConfirm = { + name.value = validName + } + ) + } +} + +fun validRelayAddress(address: String): Boolean { + val parsedMd = parseToMarkdown(address) + return parsedMd != null && + parsedMd.size == 1 && + parsedMd.first().format is Format.SimplexLink && + (parsedMd.first().format as Format.SimplexLink).linkType == SimplexLinkType.relay +} + +fun addChatRelay( + relay: UserChatRelay, + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>?, + rhId: Long?, + close: () -> Unit +) { + val nameEmpty = relay.displayName.trim().isEmpty() + val addressEmpty = relay.address.trim().isEmpty() + if (nameEmpty && addressEmpty) { + close() + } else if (!validRelayName(relay.displayName)) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_name), + text = generalGetString(MR.strings.check_relay_name) + ) + } else if (!validRelayAddress(relay.address)) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_address), + text = generalGetString(MR.strings.check_relay_address) + ) + } else { + val i = userServers.value.indexOfFirst { it.operator == null } + if (i != -1) { + val updatedUserServers = userServers.value.toMutableList() + val operatorServers = updatedUserServers[i] + updatedUserServers[i] = operatorServers.copy( + chatRelays = operatorServers.chatRelays + relay + ) + userServers.value = updatedUserServers + withBGApi { + validateServers_(rhId, userServers.value, serverErrors, serverWarnings) + } + close() + } else { // Shouldn't happen + close() + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_relay)) + } + } +} + +@Composable +fun ChatRelayView( + relay: UserChatRelay, + onDelete: () -> Unit, + onUpdate: (UserChatRelay) -> Unit, + close: () -> Unit +) { + val relayToEdit = remember { mutableStateOf(relay) } + + LaunchedEffect(Unit) { + snapshotFlow { relayToEdit.value.address } + .distinctUntilChanged() + .collect { + if (relayToEdit.value.address == relay.address) { + relayToEdit.value = relayToEdit.value.copy(tested = relay.tested, relayProfile = relay.relayProfile) + } else { + relayToEdit.value = relayToEdit.value.copy(tested = null) + } + } + } + + ModalView( + close = { + val validName = validRelayName(relayToEdit.value.displayName) + val validAddress = validRelayAddress(relayToEdit.value.address) + if (validName && validAddress) { + onUpdate(relayToEdit.value) + close() + } else if (!validName) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_name), + text = generalGetString(MR.strings.check_relay_name) + ) + } else { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_address), + text = generalGetString(MR.strings.check_relay_address) + ) + } + } + ) { + ChatRelayLayout( + relayToEdit, + onDelete = onDelete + ) + } +} + +@Composable +private fun ChatRelayLayout( + relay: MutableState, + onDelete: (() -> Unit)? +) { + val testing = remember { mutableStateOf(false) } + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.chat_relay)) + if (relay.value.preset) { + PresetRelay(relay, testing) + } else { + CustomRelay(relay, onDelete, testing) + } + SectionBottomSpacer() + } + if (testing.value) { + DefaultProgressView(null) + } + } +} + +@Composable +private fun PresetRelay(relay: MutableState, testing: MutableState) { + SectionView(stringResource(MR.strings.preset_relay_address).uppercase()) { + SelectionContainer { + Text( + relay.value.address, + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + color = MaterialTheme.colors.secondary + ) + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) { + SectionItemView { + Text(relay.value.displayName) + } + } + SectionDividerSpaced() + UseRelaySection(relay, testing = testing) +} + +@Composable +private fun CustomRelay( + relay: MutableState, + onDelete: (() -> Unit)?, + testing: MutableState +) { + val relayName = remember { mutableStateOf(relay.value.displayName) } + val relayAddress = remember { mutableStateOf(relay.value.address) } + val validName = remember { derivedStateOf { validRelayName(relayName.value) } } + val validAddress = remember { derivedStateOf { validRelayAddress(relayAddress.value) } } + + LaunchedEffect(Unit) { + snapshotFlow { relayName.value } + .distinctUntilChanged() + .collect { relay.value = relay.value.copyWithName(it) } + } + LaunchedEffect(Unit) { + snapshotFlow { relay.value.displayName } + .distinctUntilChanged() + .collect { relayName.value = it } + } + LaunchedEffect(Unit) { + snapshotFlow { relayAddress.value } + .distinctUntilChanged() + .collect { relay.value = relay.value.copy(address = it) } + } + + SectionView( + stringResource(MR.strings.your_relay_address).uppercase(), + icon = painterResource(MR.images.ic_error), + iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent, + ) { + TextEditor( + relayAddress, + Modifier.height(144.dp) + ) + } + SectionDividerSpaced(maxTopPadding = true) + + Column { + val iconSize = with(LocalDensity.current) { 21.sp.toDp() } + Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(MR.strings.your_relay_name).uppercase(), + color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp + ) + IconButton( + onClick = { if (!validName.value) showInvalidRelayNameAlert(relayName) }, + enabled = !validName.value, + modifier = Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize) + ) { + Icon( + painterResource(MR.images.ic_error), null, + tint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent + ) + } + } + Column(Modifier.fillMaxWidth()) { + TextEditor( + relayName, + Modifier, + placeholder = generalGetString(MR.strings.enter_relay_name), + enabled = relay.value.tested != true + ) + } + } + if (relay.value.tested != true) { + SectionTextFooter(annotatedStringResource(MR.strings.test_relay_to_retrieve_name)) + } + SectionDividerSpaced(maxTopPadding = true) + + UseRelaySection(relay, validAddress.value, testing) + + if (onDelete != null) { + SectionDividerSpaced() + SectionView { + SectionItemView(onDelete) { + Text(stringResource(MR.strings.delete_relay), color = MaterialTheme.colors.error) + } + } + } +} + +@Composable +private fun UseRelaySection( + relay: MutableState, + valid: Boolean = true, + testing: MutableState +) { + val scope = rememberCoroutineScope() + SectionView(stringResource(MR.strings.use_relay).uppercase()) { + SectionItemViewSpaceBetween( + click = { + testing.value = true + relay.value = relay.value.copy(tested = null) + scope.launch { + val f = testRelayConnection(relay) + if (f != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_test_failed_alert), + text = f.localizedDescription + ) + } + testing.value = false + } + }, + disabled = !valid || testing.value + ) { + Text( + stringResource(MR.strings.test_relay), + color = if (valid && !testing.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary + ) + ShowRelayTestStatus(relay.value) + } + + val enabled = rememberUpdatedState(relay.value.enabled) + PreferenceToggle( + stringResource(MR.strings.use_for_new_channels), + checked = enabled.value + ) { + relay.value = relay.value.copy(enabled = it) + } + } +} + +@Composable +fun ChatRelayViewLink( + relay: UserChatRelay, + duplicateRelayAddresses: Set, + onClick: () -> Unit +) { + SectionItemView(onClick) { + Box(Modifier.width(16.dp)) { + when { + relay.address in duplicateRelayAddresses -> InvalidServer() + !relay.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary) + else -> ShowRelayTestStatus(relay) + } + } + Spacer(Modifier.padding(horizontal = 4.dp)) + val displayName = relay.displayName.ifEmpty { relay.domains.firstOrNull() ?: relay.address } + if (relay.enabled) { + Text(displayName, color = MaterialTheme.colors.onBackground, maxLines = 1) + } else { + Text(displayName, maxLines = 1, color = MaterialTheme.colors.secondary) + } + } +} + +@Composable +fun ModalData.NewChatRelayView( + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>, + rhId: Long?, + close: () -> Unit +) { + val relayToEdit = remember { + mutableStateOf( + UserChatRelay( + chatRelayId = null, address = "", relayProfile = RelayProfile(displayName = "", fullName = ""), domains = emptyList(), + preset = false, tested = null, enabled = true, deleted = false + ) + ) + } + + LaunchedEffect(Unit) { + snapshotFlow { relayToEdit.value.address } + .distinctUntilChanged() + .collect { + relayToEdit.value = relayToEdit.value.copy(tested = null) + } + } + + ModalView(close = { + addChatRelay(relayToEdit.value, userServers, serverErrors, serverWarnings, rhId, close) + }) { + NewChatRelayLayout(relayToEdit) + } +} + +@Composable +private fun NewChatRelayLayout(relay: MutableState) { + val testing = remember { mutableStateOf(false) } + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.new_chat_relay)) + CustomRelay(relay, onDelete = null, testing = testing) + SectionBottomSpacer() + } + if (testing.value) { + DefaultProgressView(null) + } + } +} + +suspend fun testRelayConnection(relay: MutableState): RelayTestFailure? = + try { + val (relayProfile, testFailure) = chatModel.controller.testChatRelay(chatModel.remoteHostId(), relay.value.address) + if (testFailure != null) { + relay.value = relay.value.copy(tested = false) + testFailure + } else { + relay.value = relay.value.copy(tested = true).let { + if (relayProfile != null) it.copyWithName(relayProfile.displayName) else it + } + null + } + } catch (e: Exception) { + Log.e(TAG, "testRelayConnection ${e.stackTraceToString()}") + relay.value = relay.value.copy(tested = false) + null + } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 26ecf151ff..a62a58cb10 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -54,6 +54,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList() } } val userServers = remember { stateGetOrPut("userServers") { emptyList() } } val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList() } } + val serverWarnings = remember { stateGetOrPut("serverWarnings") { emptyList() } } val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } } fun onClose(close: () -> Unit): Boolean = if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) { @@ -91,6 +92,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, toggleSocksProxy = { enable -> val def = NetCfg.defaults val proxyDef = NetCfg.proxyDefaults @@ -158,6 +160,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { onionHosts: MutableState, currUserServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, userServers: MutableState>, toggleSocksProxy: (Boolean) -> Unit, ) { @@ -209,7 +212,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { if (!chatModel.desktopNoUserNoRemote) { SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) { userServers.value.forEachIndexed { index, srv -> - srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) } + srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, serverWarnings, currentRemoteHost?.remoteHostId) } } } if (conditionsAction != null && anyOperatorEnabled.value) { @@ -234,6 +237,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { YourServersView( userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = nullOperatorIndex, rhId = currentRemoteHost?.remoteHostId ) @@ -284,6 +288,12 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration)) } } + val serversWarn = globalServersWarning(serverWarnings.value) + if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } + } SectionDividerSpaced() @@ -664,6 +674,7 @@ private fun ServerOperatorRow( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long? ) { SectionItemView( @@ -673,6 +684,7 @@ private fun ServerOperatorRow( currUserServers, userServers, serverErrors, + serverWarnings, index, rhId ) @@ -757,7 +769,7 @@ fun UsageConditionsView( .clip(shape = CircleShape) .clickable { val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit" - uriHandler.openUriCatching(commitUrl) + uriHandler.openExternalLink(commitUrl) } .padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -814,13 +826,22 @@ fun UsageConditionsView( @Composable fun SimpleConditionsView( - rhId: Long? + rhId: Long?, + onAccept: () -> Unit ) { ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { ConditionsTextView(rhId) } + Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).padding(bottom = DEFAULT_PADDING * 2).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.onboarding_conditions_accept, + onboarding = null, + onclick = onAccept + ) + } } } @@ -848,6 +869,30 @@ fun ServersErrorFooter(errStr: String) { } } +@Composable +fun ServersWarningFooter(warnStr: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_warning), + contentDescription = stringResource(MR.strings.server_warning), + tint = WarningOrange, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + warnStr, + color = MaterialTheme.colors.secondary, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } +} + private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.smp_save_servers_question), @@ -887,11 +932,13 @@ fun updateOperatorsConditionsAcceptance(usvs: MutableState, - serverErrors: MutableState> + serverErrors: MutableState>, + serverWarnings: MutableState>? = null ) { try { - val errors = chatController.validateServers(rhId, userServersToValidate) ?: return + val (errors, warnings) = chatController.validateServers(rhId, userServersToValidate) ?: return serverErrors.value = errors + serverWarnings?.value = warnings } catch (ex: Exception) { Log.e(TAG, ex.stackTraceToString()) } @@ -914,6 +961,15 @@ fun globalServersError(serverErrors: List): String? { return null } +fun globalServersWarning(serverWarnings: List): String? { + for (warn in serverWarnings) { + if (warn.globalWarning != null) { + return warn.globalWarning + } + } + return null +} + fun globalSMPServersError(serverErrors: List): String? { for (err in serverErrors) { if (err.globalSMPError != null) { @@ -943,6 +999,9 @@ fun findDuplicateHosts(serverErrors: List): Set { return duplicateHostsList.toSet() } +fun findDuplicateRelayAddresses(serverErrors: List): Set = + serverErrors.mapNotNull { (it as? UserServersError.DuplicateChatRelayAddress)?.duplicateAddress }.toSet() + private suspend fun saveServers( rhId: Long?, currUserServers: MutableState>, @@ -987,7 +1046,8 @@ fun PreviewNetworkAndServersLayout() { toggleSocksProxy = {}, currUserServers = remember { mutableStateOf(emptyList()) }, userServers = remember { mutableStateOf(emptyList()) }, - serverErrors = remember { mutableStateOf(emptyList()) } + serverErrors = remember { mutableStateOf(emptyList()) }, + serverWarnings = remember { mutableStateOf(emptyList()) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index 6a999aa89d..a3a843d034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.* fun ModalData.NewServerView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long?, close: () -> Unit ) { @@ -28,6 +29,7 @@ fun ModalData.NewServerView( newServer.value, userServers, serverErrors, + serverWarnings, rhId, close = close ) @@ -101,6 +103,7 @@ fun addServer( server: UserServer, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>? = null, rhId: Long?, close: () -> Unit ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index cc72387875..9e11b9a932 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -47,6 +47,7 @@ fun OperatorView( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -57,7 +58,7 @@ fun OperatorView( LaunchedEffect(userServers) { snapshotFlow { userServers.value } .collect { updatedServers -> - validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors, serverWarnings = serverWarnings) } } @@ -68,9 +69,10 @@ fun OperatorView( currUserServers, userServers, serverErrors, + serverWarnings, operatorIndex, navigateToProtocolView = { serverIndex, server, protocol -> - navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + navigateToProtocolView(userServers, serverErrors, serverWarnings, operatorIndex, rhId, serverIndex, server, protocol) }, currentUser, rhId, @@ -87,6 +89,7 @@ fun OperatorView( fun navigateToProtocolView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long?, serverIndex: Int, @@ -100,6 +103,7 @@ fun navigateToProtocolView( serverProtocol = protocol, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, onDelete = { if (protocol == ServerProtocol.SMP) { deleteSMPServer(userServers, operatorIndex, serverIndex) @@ -130,11 +134,42 @@ fun navigateToProtocolView( } } +fun navigateToChatRelayView( + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>, + operatorIndex: Int, + relayIndex: Int, + relay: UserChatRelay, + rhId: Long? +) { + ModalManager.start.showCustomModal { close -> + ChatRelayView( + relay = relay, + onDelete = { + deleteChatRelay(userServers, operatorIndex, relayIndex) + close() + }, + onUpdate = { updatedRelay -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + chatRelays = this[operatorIndex].chatRelays.toMutableList().apply { + this[relayIndex] = updatedRelay + } + ) + } + }, + close = close + ) + } +} + @Composable fun OperatorViewLayout( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, currentUser: User?, @@ -170,15 +205,21 @@ fun OperatorViewLayout( currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = operatorIndex, rhId = rhId ) } val serversErr = globalServersError(serverErrors.value) + val serversWarn = globalServersWarning(serverWarnings.value) if (serversErr != null) { SectionCustomFooter { ServersErrorFooter(serversErr) } + } else if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } } else { val footerText = when (val c = operator.conditionsAcceptance) { is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) { @@ -194,6 +235,21 @@ fun OperatorViewLayout( } if (operator.enabled) { + if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { + val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + userServers.value[operatorIndex].chatRelays.forEachIndexed { index, relay -> + if (!relay.deleted) { + ChatRelayViewLink(relay, duplicateRelayAddresses) { + navigateToChatRelayView(userServers, serverErrors, serverWarnings, operatorIndex, index, relay, rhId) + } + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages_in_channels)) + } + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { SectionDividerSpaced() SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) { @@ -387,21 +443,30 @@ fun OperatorViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, - ) { p, l -> - when (p) { - ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - xftpServers = l - ) - } + chatRelays = userServers.value[operatorIndex].chatRelays, + onUpdate = { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + }, + onUpdateRelays = { relays -> + userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( - smpServers = l + chatRelays = relays ) } } - } + ) } SectionBottomSpacer() @@ -435,7 +500,7 @@ fun OperatorInfoView(serverOperator: ServerOperator) { Text(d) } val website = serverOperator.info.website - Text(website, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(website) }) + Text(website, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openExternalLink(website) }) } } } @@ -446,7 +511,7 @@ fun OperatorInfoView(serverOperator: ServerOperator) { SectionView { SectionItemView { val (text, link) = selfhost - Text(text, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(link) }) + Text(text, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openExternalLink(link) }) } } } @@ -458,6 +523,7 @@ private fun UseOperatorToggle( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -485,6 +551,7 @@ private fun UseOperatorToggle( currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = operatorIndex, rhId = rhId, close = close @@ -510,6 +577,7 @@ private fun SingleOperatorUsageConditionsView( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long?, close: () -> Unit @@ -612,13 +680,14 @@ private fun SingleOperatorUsageConditionsView( } } +val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" + @Composable fun ConditionsTextView( rhId: Long? ) { val conditionsData = remember { mutableStateOf?>(null) } val failedToLoad = remember { mutableStateOf(false) } - val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" val scope = rememberCoroutineScope() // can show conditions when animation between modals finishes to prevent glitches val canShowConditionsAt = remember { System.currentTimeMillis() + 300 } @@ -718,7 +787,7 @@ private fun ConditionsLinkView(conditionsLink: String) { SectionItemView { val uriHandler = LocalUriHandler.current Text(stringResource(MR.strings.operator_conditions_failed_to_load), color = MaterialTheme.colors.onBackground) - Text(conditionsLink, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(conditionsLink) }) + Text(conditionsLink, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openExternalLink(conditionsLink) }) } } @@ -752,13 +821,13 @@ fun ConditionsLinkButton() { val commit = chatModel.conditions.value.currentConditions.conditionsCommit ItemAction(stringResource(MR.strings.operator_open_conditions), painterResource(MR.images.ic_draft), onClick = { val mdUrl = "https://github.com/simplex-chat/simplex-chat/blob/$commit/PRIVACY.md" - uriHandler.openUriCatching(mdUrl) showMenu.value = false + uriHandler.openExternalLink(mdUrl) }) ItemAction(stringResource(MR.strings.operator_open_changes), painterResource(MR.images.ic_more_horiz), onClick = { val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit" - uriHandler.openUriCatching(commitUrl) showMenu.value = false + uriHandler.openExternalLink(commitUrl) }) } IconButton({ showMenu.value = true }) { @@ -769,11 +838,7 @@ fun ConditionsLinkButton() { private fun internalUriHandler(parentUriHandler: UriHandler): UriHandler = object: UriHandler { override fun openUri(uri: String) { - if (uri.startsWith("https://simplex.chat/contact#")) { - openVerifiedSimplexUri(uri) - } else { - parentUriHandler.openUriCatching(uri) - } + parentUriHandler.openExternalLink(uri) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index ccad962313..01630a2b52 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -36,6 +36,7 @@ fun ProtocolServerView( serverProtocol: ServerProtocol, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, onDelete: () -> Unit, onUpdate: (UserServer) -> Unit, close: () -> Unit, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 63bf8b1dc4..3be2456b72 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch fun ModalData.YourServersView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -40,7 +41,7 @@ fun ModalData.YourServersView( LaunchedEffect(userServers) { snapshotFlow { userServers.value } .collect { updatedServers -> - validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors, serverWarnings = serverWarnings) } } @@ -51,9 +52,10 @@ fun ModalData.YourServersView( scope, userServers, serverErrors, + serverWarnings, operatorIndex, navigateToProtocolView = { serverIndex, server, protocol -> - navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + navigateToProtocolView(userServers, serverErrors, serverWarnings, operatorIndex, rhId, serverIndex, server, protocol) }, currentUser, rhId, @@ -72,6 +74,7 @@ fun YourServersViewLayout( scope: CoroutineScope, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, currentUser: User?, @@ -81,7 +84,21 @@ fun YourServersViewLayout( val duplicateHosts = findDuplicateHosts(serverErrors.value) Column { + if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { + val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) + SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + userServers.value[operatorIndex].chatRelays.forEachIndexed { i, relay -> + if (relay.deleted) return@forEachIndexed + ChatRelayViewLink(relay, duplicateRelayAddresses) { + navigateToChatRelayView(userServers, serverErrors, serverWarnings, operatorIndex, i, relay, rhId) + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages_in_channels)) + } + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { + SectionDividerSpaced() SectionView(generalGetString(MR.strings.message_servers).uppercase()) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed @@ -150,7 +167,8 @@ fun YourServersViewLayout( if ( userServers.value[operatorIndex].smpServers.any { !it.deleted } || - userServers.value[operatorIndex].xftpServers.any { !it.deleted } + userServers.value[operatorIndex].xftpServers.any { !it.deleted } || + userServers.value[operatorIndex].chatRelays.any { !it.deleted } ) { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) } @@ -159,7 +177,7 @@ fun YourServersViewLayout( SettingsActionItem( painterResource(MR.images.ic_add), stringResource(MR.strings.smp_servers_add), - click = { showAddServerDialog(scope, userServers, serverErrors, rhId) }, + click = { showAddServerDialog(scope, userServers, serverErrors, serverWarnings, rhId) }, disabled = testing.value, textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary @@ -171,6 +189,12 @@ fun YourServersViewLayout( ServersErrorFooter(serversErr) } } + val serversWarn = globalServersWarning(serverWarnings.value) + if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } + } SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) SectionView { @@ -178,21 +202,30 @@ fun YourServersViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, - ) { p, l -> - when (p) { - ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - xftpServers = l - ) - } + chatRelays = userServers.value[operatorIndex].chatRelays, + onUpdate = { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + }, + onUpdateRelays = { relays -> + userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( - smpServers = l + chatRelays = relays ) } } - } + ) HowToButton() } @@ -204,16 +237,20 @@ fun YourServersViewLayout( fun TestServersButton( smpServers: List, xftpServers: List, + chatRelays: List = emptyList(), testing: MutableState, - onUpdate: (ServerProtocol, List) -> Unit + onUpdate: (ServerProtocol, List) -> Unit, + onUpdateRelays: ((List) -> Unit)? = null ) { val scope = rememberCoroutineScope() - val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value } + val disabled = derivedStateOf { + (smpServers.none { it.enabled } && xftpServers.none { it.enabled } && chatRelays.filter { !it.deleted }.none { it.enabled }) || testing.value + } SectionItemView( { scope.launch { - testServers(testing, smpServers, xftpServers, chatModel, onUpdate) + testServers(testing, smpServers, xftpServers, chatRelays, chatModel, onUpdate, onUpdateRelays) } }, disabled = disabled.value @@ -226,6 +263,7 @@ fun showAddServerDialog( scope: CoroutineScope, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long? ) { AlertManager.shared.showAlertDialogButtonsColumn( @@ -235,7 +273,7 @@ fun showAddServerDialog( SectionItemView({ AlertManager.shared.hideAlert() ModalManager.start.showCustomModal { close -> - NewServerView(userServers, serverErrors, rhId, close) + NewServerView(userServers, serverErrors, serverWarnings, rhId, close) } }) { Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) @@ -250,6 +288,7 @@ fun showAddServerDialog( server, userServers, serverErrors, + serverWarnings, rhId, close = close ) @@ -260,6 +299,14 @@ fun showAddServerDialog( Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.start.showCustomModal { close -> + NewChatRelayView(userServers, serverErrors, serverWarnings, rhId, close) + } + }) { + Text(stringResource(MR.strings.chat_relay), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } } ) @@ -288,7 +335,7 @@ private fun HowToButton() { SettingsActionItem( painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.how_to_use_your_servers), - { uriHandler.openUriCatching("https://simplex.chat/docs/server.html") }, + { uriHandler.openExternalLink("https://simplex.chat/docs/server.html") }, textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary ) @@ -303,20 +350,28 @@ private suspend fun testServers( testing: MutableState, smpServers: List, xftpServers: List, + chatRelays: List, m: ChatModel, - onUpdate: (ServerProtocol, List) -> Unit + onUpdate: (ServerProtocol, List) -> Unit, + onUpdateRelays: ((List) -> Unit)? ) { + val relaysResetStatus = resetRelayTestStatus(chatRelays) + onUpdateRelays?.invoke(relaysResetStatus) val smpResetStatus = resetTestStatus(smpServers) onUpdate(ServerProtocol.SMP, smpResetStatus) val xftpResetStatus = resetTestStatus(xftpServers) onUpdate(ServerProtocol.XFTP, xftpResetStatus) testing.value = true + val relayFailures = runRelaysTest(relaysResetStatus) { onUpdateRelays?.invoke(it) } val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) } val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) } testing.value = false - val fs = smpFailures + xftpFailures - if (fs.isNotEmpty()) { - val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") + val failures = mutableListOf() + failures += relayFailures.map { (name, f) -> "$name: ${f.localizedDescription}" } + failures += smpFailures.map { (srv, f) -> "$srv: ${f.localizedDescription}" } + failures += xftpFailures.map { (srv, f) -> "$srv: ${f.localizedDescription}" } + if (failures.isNotEmpty()) { + val msg = failures.joinToString("\n") AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_test_failed), text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg @@ -354,6 +409,37 @@ private suspend fun runServersTest(servers: List, m: ChatModel, onUp return fs } +private fun resetRelayTestStatus(relays: List): List { + val copy = ArrayList(relays) + for ((index, relay) in relays.withIndex()) { + if (relay.enabled && !relay.deleted) { + copy.removeAt(index) + copy.add(index, relay.copy(tested = null)) + } + } + return copy +} + +private suspend fun runRelaysTest(relays: List, onUpdated: (List) -> Unit): Map { + val fs: MutableMap = mutableMapOf() + val updatedRelays = ArrayList(relays) + for ((index, relay) in relays.withIndex()) { + if (relay.enabled && !relay.deleted) { + interruptIfCancelled() + val relayState = mutableStateOf(relay) + val f = testRelayConnection(relayState) + updatedRelays.removeAt(index) + updatedRelays.add(index, relayState.value) + onUpdated(updatedRelays.toList()) + if (f != null) { + val name = relayState.value.displayName.ifEmpty { relayState.value.domains.firstOrNull() ?: relayState.value.address } + fs[name] = f + } + } + } + return fs +} + fun deleteXFTPServer( userServers: MutableState>, operatorServersIndex: Int, @@ -405,3 +491,28 @@ fun deleteSMPServer( } } } + +fun deleteChatRelay( + userServers: MutableState>, + operatorServersIndex: Int, + relayIndex: Int +) { + val relay = userServers.value[operatorServersIndex].chatRelays[relayIndex] + if (relay.chatRelayId == null) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + chatRelays = this[operatorServersIndex].chatRelays.toMutableList().apply { + this.removeAt(relayIndex) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + chatRelays = this[operatorServersIndex].chatRelays.toMutableList().apply { + this[relayIndex] = this[relayIndex].copy(deleted = true) + } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 96c539109d..95ec53287a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -26,7 +26,7 @@ خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك سيتم تغيير عنوان الاستلام إلى خادم مختلف. سيتم إكمال تغيير العنوان بعد اتصال المرسل بالإنترنت. هذا الرابط ليس رابط اتصال صالح! - يسمح + اسمح أضِف خوادم مُعدة مسبقًا أضِف إلى جهاز آخر سيتم حذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا! @@ -106,7 +106,7 @@ تجزئة رسالة سيئة معرّف رسالة سيئ انتهت المكالمة - تغير + غيِّر لون إضافي ثانوي " \nمتوفر في v5.1" @@ -122,7 +122,7 @@ 1 دقيقة 30 ثانية ألغِ الرسالة الحيّة - إلغاء + ألغِ لكل جهة اتصال وعضو في المجموعة\n. الرجاء ملاحظة: إذا كان لديك العديد من الاتصالات، فقد يكون استهلاك البطارية وحركة المرور أعلى بكثير وقد تفشل بعض الاتصالات.]]> جارٍ الاتصال… مكالمة صوتية @@ -162,19 +162,19 @@ مفعّل مفعّلة لك يمكن لجهات الاتصال تحديد الرسائل لحذفها؛ ستتمكن من مشاهدتها. - جار الاتصال… + يتصل… خطأ في الإتصال (المصادقة) خطأ في حذف جهة الاتصال جهة الاتصال مخفية: - نسخ + انسخ اتصل متصل انضمام إلى المجموعة؟ اتصل عبر رابط لمرة واحدة؟ تغيير عنوان الاستلام؟ نٌسخت إلى الحافظة - مسح - امسح الدردشة + امحُ + امحُ الدردشة أنشئ عنوان الدردشات تأكيد عبارة المرور الجديدة… @@ -199,19 +199,19 @@ تأكد من بيانات اعتمادك أنشئ عنوان SimpleX متابعة - تحدث مع المطورين + دردش مع المطوِّرين سياق الأيقونة إحباط تغيير العنوان؟ إحباط سيتم إحباط تغيير العنوان. سيتم استخدام عنوان الاستلام القديم. - مسح الدردشة؟ + محو الدردشة؟ وحدة تحكم الدردشة ضبط خوادم ICE الاتصال ملف تعريف الدردشة الإصدار الأساسي: v%s أنشئ ملف تعريف - جار الاتصال… + يتصل… انتهى متصل %1$d تخطت الرسائل @@ -221,13 +221,13 @@ خطأ في تعمية قاعدة البيانات توقفت الدردشة مكتمل - جاري الاتصال (أعلن) + يتصل (أُعلن) الاتصال إحباط تغيير العنوان أنشئ مجموعة سرية قارن رموز الأمان مع جهات اتصالك. الواجهة الصينية والاسبانية - مسح + امحُ %1$s يريد التواصل معك عبر جارِ تغيير العنوان… جارِ تغيير العنوان ل%s… @@ -240,17 +240,17 @@ تغيير وضع التدمير الذاتي تغيير رمز المرور التدمير الذاتي تأكيد ترقيات قاعدة البيانات - الاتصال (دعوة مقدمة) - مسح + يتصل (دعوة مقدمة) + امحُ خطأ في إنشاء رابط المجموعة (حاضِر) فعّل أبقِ TCP على قيد الحياة - جار الاتصال… - جار الاتصال… + يتصل… + يتصل… أرسلت طلب الاتصال! حُذفت قاعدة بيانات الدردشة جارِ تغيير العنوان… - جار الاتصال (قُبِل) + يتصل (قُبِل) فُحصت جهة الاتصال %1$s أعضاء أنشئ رابط المجموعة @@ -271,7 +271,7 @@ اتصل عبر عنوان التواصل؟ خطأ في حذف رابط المجموعة التحقق من الرسائل الجديدة كل 10 دقائق لمدة تصل إلى دقيقة واحدة - جار الاتصال + يتصل خطأ خطأ في حذف ملف تعريف المستخدم زر الاغلاق @@ -279,7 +279,7 @@ تغيير الدور أدخل كلمة المرور في البحث تسمح جهة الاتصال - تأكيد + أكِّد جهة الاتصال ليست متصلة بعد! اتصال تواصل عبر الرابط @@ -294,7 +294,7 @@ خطأ في حذف اتصال جهة الاتصال المنتظر أدخل رسالة ترحيب… متصل - جار الاتصال + يتصل مفعّلة للاتصال تغيير رمز المرور متصل @@ -308,18 +308,18 @@ تواصل عبر الرابط / رمز QR أنشئ رابط دعوة لمرة واحدة تحقق من عنوان الخادم وحاول مرة أخرى. - امسح التحقُّق + امحُ التحقُّق أنشئ عنوانًا للسماح للأشخاص بالتواصل معك. أدخل الخادم يدويًا ملون لدى جهة الاتصال التعمية بين الطريفين أنشئ أنشئ ملف تعريفك - مكالمة جارية... + مكالمة جارية فعّل التدمير الذاتي الموافقة على التعمية… الموافقة على التعمية لـ%s… - متصل (مقدم) + يتصل (مقدم) وافق التعمية التعمية نعم التعمية نعم ل%s @@ -335,7 +335,7 @@ احذف جميع الملفات احذف بعد احذف الملف - حذف + احذف حذف رسالة العضو؟ احذف احذف الرسائل @@ -398,7 +398,7 @@ مخصص تخصيص ومشاركة سمات الألوان. الخروج بدون حفظ - أدوات المطور + أدوات المطوِّر احذف قائمة الانتظار خطأ في تحديث خصوصية المستخدم مسح رمز QR.]]> @@ -471,9 +471,8 @@ يمكن للأعضاء إرسال الملفات والوسائط. تفضيلات المجموعة سريع ولا تنتظر حتى يصبح المرسل متصلاً بالإنترنت! - إخفاء + أخفِ كيفية الاستخدام - كيف يعمل SimpleX التخفي عبر رابط عنوان جهة الاتصال رمز الأمان غير صحيحة! الإشعارات الفورية مُعطَّلة @@ -602,7 +601,7 @@ مصادقة الجهاز غير مفعّلة. يمكنك تشغيل قفل SimpleX عبر الإعدادات، بمجرد تفعيل مصادقة الجهاز. نزّل الملف عطّل قفل SimpleX - تحرير + حرّر اسم ملف التعريف: البريد الإلكتروني أدخل أسمك: @@ -709,7 +708,7 @@ خطأ في تسليم الرسالة الشبكة والخوادم إشراف - فتح في تطبيق الجوال، ثم انقر فوق اتصال في التطبيق.]]> + فتح في تطبيق الجوال، ثم انقر فوق اتصال في التطبيق.]]> تحت الإشراف في: %s ردود الفعل الرسائل ممنوعة في هذه الدردشة. أُشرف بواسطة %s @@ -798,7 +797,7 @@ جارِ فتح قاعدة البيانات… جهة اتصالك فقط يمكنها إرسال رسائل صوتية. ألصق - لم يُعثر على عبارة المرور في Keystore، يُرجى إدخالها يدويًا. ربما حدث هذا إذا استعدت بيانات التطبيق باستخدام أداة النسخ الاحتياطي. إذا لم يكن الأمر كذلك، يُرجى التواصل مع المطورين. + لم يُعثر على عبارة المرور في Keystore، يُرجى إدخالها يدويًا. ربما حدث هذا إذا استعدت بيانات التطبيق باستخدام أداة النسخ الاحتياطي. إذا لم يكن الأمر كذلك، يُرجى التواصل مع المطوِّرين. افتح الدردشة قد يؤدي فتح الرابط في المتصفح إلى تقليل خصوصية الاتصال وأمانه. ستظهر روابط SimpleX غير الموثوقة باللون الأحمر. أنت فقط يمكنك إضافة ردود الفعل على الرسالة. @@ -816,7 +815,7 @@ مكالمة قيد الانتظار تقوم أجهزة العميل فقط بتخزين ملفات تعريف المستخدمين وجهات الاتصال والمجموعات والرسائل. صفّر الألوان - حفظ + احفظ عنوان الخادم المُعد مسبقًا حفظ وإشعار أعضاء المجموعة دوري @@ -827,18 +826,18 @@ صورة ملف التعريف الإشعارات خاصة يُرجى تخزين عبارة المرور بشكل آمن، فلن تتمكن من الوصول إلى الدردشة إذا فقدتها. - يُرجى تحديث التطبيق والتواصل مع المطورين. + يُرجى تحديث التطبيق والتواصل مع المطوِّرين. دليل المستخدم.]]> غيّر ملفات تعريف الدردشة اسحب الوصول - كشف + اكشف سيتم إيقاف استلام الملف. رفض قيم التطبيق منفذ احفظ إعدادات عنوان SimpleX إعادة تعريف الخصوصية - الرجاء الإبلاغ للمطورين. + يُرجى إبلاغ المطوِّرين بذلك. الخصوصية والأمان أزل إزالة عبارة المرور من Keystore؟ @@ -853,7 +852,7 @@ استلمت إجابة… مستودع GitHub.]]> رفض - يحمي خادم الترحيل عنوان IP الخاص بك، ولكن يمكنه مراقبة مدة المكالمة. + يحمي خادم المُرحل عنوان IP الخاص بك، ولكن يمكنه مراقبة مُدّة المكالمة. الرجاء إدخال كلمة المرور السابقة بعد استعادة نسخة احتياطية لقاعدة البيانات. لا يمكن التراجع عن هذا الإجراء. استعادة النسخة الاحتياطية لقاعدة البيانات؟ حفظ @@ -910,7 +909,7 @@ أُزيل يُرجى تذكرها أو تخزينها بأمان - لا توجد طريقة لاستعادة كلمة المرور المفقودة! معاينة - من المحتمل أن الملف المرجعي للشهادة في عنوان الخادم غير صحيح + البصمة في عنوان الخادم لا تتطابق مع الشهادة. يتم استلام الرسائل… يُرجى الاتصال بمُدير المجموعة. أعد التفاوض @@ -924,7 +923,7 @@ صفّر المنفذ %d خادم مُعد مسبقًا - يتم استخدام خادم الترحيل فقط إذا لزم الأمر. يمكن لطرف آخر مراقبة عنوان IP الخاص بك. + يُستخدم خادم المُرحل فقط إذا لزم الأمر. يمكن لطرف آخر مراقبة عنوان IP الخاص بك. حفظ وإشعار جهة الاتصال إعادة التشغيل استلمت في: %s @@ -946,7 +945,7 @@ إرسال رسالة إرسال أرسل رسالة حيّة - فشلت تجربة الخادم! + فشل اختبار الخادم! احفظ عبارة المرور في Keystore أرسل رسالة مباشرة إرسال عبر @@ -960,8 +959,8 @@ رسالة مرسلة عيّن تفضيلات المجموعة عيّنها بدلاً من استيثاق النظام. - مشاركة - إرسال + شارك + أرسل احفظ عبارة المرور وافتح الدردشة حدد جهات الاتصال تعيين يوم واحد @@ -1048,10 +1047,10 @@ يبدأ… شُغّل قفل SimpleX أظهر: - أظهر خيارات المطور + أظهر خيارات المطوِّر simplexmq: v%s (%2s) - يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور - يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور + يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور. + يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور. أظهر جهة الاتصال فقط مكالمات SimpleX Chat خدمة SimpleX Chat @@ -1093,7 +1092,7 @@ اختبر الخوادم لا معرّفات مُستخدم دعم SIMPLEX CHAT - تبديل + بدِّل العنوان الرئيسي سيتم وضع علامة على الرسالة على أنها تحت الإشراف لجميع الأعضاء. انقر للانضمام @@ -1117,7 +1116,7 @@ لاستلام الإشعارات، يُرجى إدخال عبارة مرور قاعدة البيانات مصادقة النظام يعمل التعمية واتفاقية التعمية الجديدة غير مطلوبة. قد ينتج عن ذلك أخطاء في الاتصال! - لا يمكن فك ترميز الصورة. من فضلك، جرب صورة مختلفة أو تواصل مع المطورين. + لا يمكن فك ترميز الصورة. من فضلك، جرّب صورة مختلفة أو تواصل مع المطوِّرين. سيتم حذف الرسالة لجميع الأعضاء. الصور كثيرة! مقاطع الفيديو كثيرة! @@ -1128,16 +1127,16 @@ معرف الرسالة التالية غير صحيح (أقل أو يساوي السابق). \nيمكن أن يحدث ذلك بسبب بعض العلل أو عندما يُخترق الاتصال. أزل من المفضلة - محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه. + محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من هذا الاتصال. اختيار ملف إرسال غير مصرح به - محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه (خطأ: %1$s). + محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه (خطأ: %1$s). تشغيل خوادم WebRTC ICE أنت تستخدم ملف تعريف متخفي لهذه المجموعة - لمنع مشاركة ملفك التعريفي الرئيسي الذي يدعو جهات الاتصال غير مسموح به غيّرتَ دور %s إلى %s نعم - أنت متصل بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه. + أنت متصل بالخادم المستخدم لاستلام الرسائل من هذا الاتصال. أنت لقد شاركت رابط لمرة واحدة سيتم إرسال ملف التعريفك إلى جهة الاتصال التي استلمت منها هذا الرابط. @@ -1253,13 +1252,13 @@ إلغاء إخفاء ملف تعريف يجب أن تكون جهة الاتصال متصلة بالإنترنت حتى يكتمل الاتصال. \nيمكنك إلغاء هذا الاتصال وإزالة جهة الاتصال (والمحاولة لاحقًا باستخدام رابط جديد). - فتح في تطبيق الجوال.]]> + فتح في تطبيق الجوّال.]]> استخدم للاتصالات الجديدة استخدم الخادم عنوان خادمك قاعدة بيانات دردشتك أنت مدعو إلى المجموعة. انضم للتواصل مع أعضاء المجموعة. - لقد انضممت إلى هذه المجموعة. جارِ الاتصال بدعوة عضو المجموعة. + لقد انضممت إلى هذه المجموعة. يتصل بدعوة عضو المجموعة. غيّرتَ العنوان ل%s إلغاء إخفاء ملف تعريف الدردشة الرسائل الصوتية ممنوعة في هذه الدردشة. @@ -1284,7 +1283,7 @@ مكالمة فيديو الرسائل الصوتية ممنوعة. فتح القفل - رفع الملف + ارفع الملف لا يمكن التحقق منك؛ الرجاء المحاولة مرة اخرى. رسالة صوتية رسالة صوتية… @@ -1292,14 +1291,14 @@ أنت المراقب! تحتاج إلى السماح لجهة اتصالك بإرسال رسائل صوتية لتتمكن من إرسالها. أرسلت جهة اتصالك ملفًا أكبر من الحجم الأقصى المعتمد حاليًا (%1$s). - الاتصال بمطوري SimpleX Chat لطرح أي أسئلة وتلقي التحديثات.]]> + الاتصال بمطوِّري SimpleX Chat لطرح أي أسئلة وتلقي التحديثات.]]> خادمك يُخزن ملف تعريفك على جهازك ومشاركته فقط مع جهات اتصالك. لا تستطيع خوادم SimpleX رؤية ملف تعريفك. الفيديو مقفل الفيديو مُشغَّل مع رسالة ترحيب اختيارية. قريباً! - هذه الميزة ليست مدعومة بعد. جرب الإصدار القادم. + هذه الميزة ليست مدعومة بعد. جرّب الإصدار القادم. عطّل (الاحتفاظ بتجاوزات المجموعة) فعِّل لجميع المجموعات إرسال الإيصالات مفعّلة لـ%d مجموعات @@ -1366,7 +1365,7 @@ فعّل وضع التخفي عند الاتصال. أرسل لاتصال طُلب اتصال - جارٍ الاتصال بالفعل! + يتصل بالفعل! مجموعات أفضل و%d أحداث أخرى جارٍ انضمام بالفعل إلى المجموعة! @@ -1388,7 +1387,7 @@ اتصل بنفسك؟ سطح المكتب متصل بسطح المكتب - جار الاتصال بسطح المكتب + الاتصال بسطح المكتب أجهزة سطح المكتب الاسم الصحيح لـ%s؟ حذف %d رسالة؟ @@ -1408,10 +1407,10 @@ مُكتشف عبر الشبكة المحلية قطع اتصال سطح المكتب؟ إصدار تطبيق سطح المكتب %s غير متوافق مع هذا التطبيق. - توسيع + وسِّع هل تريد تكرار طلب الاتصال؟ خطأ في إعادة التفاوض بشأن التعمية - %1$s.]]> + %1$s.]]> خطأ لقد انضممت بالفعل إلى المجموعة عبر هذا الرابط. %s و%s @@ -1429,7 +1428,7 @@ (جديد)]]> فك ربط سطح المكتب؟ خيارات سطح المكتب المرتبطة - لا يمكن فك تشفير الفيديو. من فضلك، جرب مقطع فيديو مختلفًا أو اتصل بالمطورين. + لا يمكن فك ترميز الفيديو. من فضلك، جرّب مقطع فيديو مختلفًا أو اتصل بالمطوِّرين. %s متصل أسطح المكتب المرتبطة مجموعات التخفي @@ -1466,7 +1465,7 @@ تحقق من الرمز على الجوّال أدخل اسم الجهاز هذا… خطأ - لقد شاركت مسار ملف غير صالح. أبلغ عن المشكلة لمطوري التطبيق. + لقد شاركت مسار ملف غير صالح. أبلغ عن المشكلة لمطوِّري التطبيق. اسم غير صالح! ألصق عنوان سطح المكتب %1$s!]]> @@ -1483,13 +1482,13 @@ تحقق من الاتصال أعِد التحميل عشوائي - في انتظار اتصال الجوال: - للسماح لتطبيق الجوال بالاتصال بسطح المكتب، افتح هذا المنفذ في جدار الحماية لديك، إذا فعلته + في انتظار اتصال الجوّال: + للسماح لتطبيق الجوّال بالاتصال بسطح المكتب، افتح هذا المنفذ في جدار الحماية لديك، إذا فعّلته أنشئ ملف تعريف الدردشة عرض التحطم فتح منفذ في جدار الحماية - اقطع اتصال الجوالات - لا يوجد جوال متصل + اقطع اتصال الجوّالات + لا يوجد جوّال متصل خطأ في إظهار المحتوى خطأ في إظهار الرسالة انتهت المكالمة %1$s @@ -1526,30 +1525,26 @@ أظهر الأخطاء الداخلية خطأ فادح خطأ داخلي - يُرجى إبلاغ المطورين بذلك: -\n%s - يُرجى إبلاغ المطورين بذلك: -\n%s -\n -\nيوصى بإعادة تشغيل التطبيق. + يُرجى إبلاغ المطوِّرين بذلك: \n%s + يُرجى إبلاغ المطوِّرين بذلك: \n%s \n \nيوصى بإعادة تشغيل التطبيق. أعد تشغيل الدردشة - %s غير نشط]]> + %s غير نشط]]> أظهر مكالمات API البطيئة غير معروف حدّثت ملف التعريف - %s مفقود]]> - %s لديه إصدار غير مدعوم. يُرجى التأكد من استخدام نفس الإصدار على كلا الجهازين]]> - %s في حالة سيئة]]> + %s مفقود]]> + %s لديه إصدار غير مدعوم. يُرجى التأكد من استخدام نفس الإصدار على كلا الجهازين]]> + %s في حالة سيئة]]> اسم العرض غير صالح! اسم العرض هذا غير صالح. الرجاء اختيار اسم آخر. توقف الاتصال توقف الاتصال - %s بسبب: %s]]> + %s بسبب: %s]]> قُطع الاتصال بسبب: %s - %s]]> - %s]]> + %s]]> + %s]]> سطح المكتب غير نشط - %s مشغول]]> + %s مشغول]]> انتهت المهلة أثناء الاتصال بسطح المكتب قُطع اتصال سطح المكتب الاتصال بسطح المكتب في حالة سيئة @@ -1558,7 +1553,7 @@ يحتوي سطح المكتب على إصدار غير مدعوم. يُرجى التأكد من استخدام نفس الإصدار على كلا الجهازين العضو %1$s وظيفة بطيئة - خيارات المطور + خيارات المطوِّر تغيّر العضو %1$s إلى %2$s أزلت عنوان الاتصال أزلت صورة ملف التعريف @@ -1581,7 +1576,7 @@ حدث خطأ أثناء إنشاء الرسالة حدث خطأ أثناء حذف الملاحظات الخاصة ملاحظات خاصة - مسح الملاحظات الخاصة؟ + محو الملاحظات الخاصة؟ أُنشئ في: %s رسالة محفوظة إلغاء حظر العضو للجميع؟ @@ -1604,7 +1599,7 @@ مكالمة فيديو مكالمة صوتية أنهيّ المكالمة - متصفح الويب الافتراضي مطلوب للمكالمات. يُرجى تضبيط المتصفح الافتراضي في النظام، ومشاركة المزيد من المعلومات مع المطورين. + متصفح الويب الافتراضي مطلوب للمكالمات. يُرجى تضبيط المتصفح الافتراضي في النظام، ومشاركة المزيد من المعلومات مع المطوِّرين. حدث خطأ أثناء فتح المتصفح أرشف وأرفع يمكن للمُدراء حظر عضو للجميع. @@ -1648,7 +1643,7 @@ تحقق من عبارة المرور تأكد من أنك تتذكر عبارة مرور قاعدة البيانات لترحيلها. التحقق من عبارة مرور قاعدة البيانات - خطأ في عرض الإشعار، تواصل بالمطورين. + خطأ في عرض الإشعار، تواصل بالمطوِّرين. مكالمات صورة في صورة استخدم التطبيق أثناء المكالمة. رحّل إلى جهاز آخر عبر رمز QR. @@ -1819,8 +1814,7 @@ معلومات قائمة انتظار الرسائل احمِ عنوان IP الخاص بك من مُرحلات المُراسلة التي اختارتها جهات اتصالك. \nفعّل في إعدادات *الشبكة والخوادم*. سمات دردشة جديدة - حدث خطأ أثناء تهيئة WebView. حدّث نظامك إلى الإصدار الجديد. يُرجى التواصل بالمطورين. -\nError: %s + حدث خطأ أثناء تهيئة WebView. حدّث نظامك إلى الإصدار الجديد. يُرجى التواصل بالمطوِّرين. \nError: %s تحسين تسليم الرسائل مع انخفاض استخدام البطارية. مفتاح خاطئ أو عنوان مجموعة الملف غير معروف - على الأرجح حُذف الملف. @@ -1834,8 +1828,7 @@ حالة الرسالة: %s خطأ في النسخ استُخدم هذا الرابط مع جوّال آخر، يُرجى إنشاء رابط جديد على سطح المكتب. - يُرجى التحقق من اتصال الهاتف المحمول وسطح المكتب بنفس الشبكة المحلية، وأن جدار حماية سطح المكتب يسمح بالاتصال. -\nيُرجى مشاركة أي مشاكل أُخرى مع المطورين. + يُرجى التحقق من اتصال الهاتف المحمول وسطح المكتب بنفس الشبكة المحلية، وأن جدار حماية سطح المكتب يسمح بالاتصال. \nيُرجى مشاركة أي مشاكل أُخرى مع المطوِّرين. لا يمكن إرسال الرسالة تفضيلات الدردشة المحدّدة تحظر هذه الرسالة. التفاصيل @@ -1919,7 +1912,7 @@ رُفع القطع متصل الخوادم المتصلة - جارِ الاتصال + يتصل الاتصالات النشطة ملف التعريف الحالي أخطاء الحذف @@ -1988,7 +1981,7 @@ المكالمات ممنوعة! لا يمكن مكالمة أحد أعضاء المجموعة لا يمكن إرسال رسالة إلى عضو المجموعة - جارِ الاتصال بجهة الاتصال، يُرجى الانتظار أو التحقق لاحقًا! + يتصل بجهة الاتصال، يُرجى الانتظار أو التحقق لاحقًا! جهات الاتصال المؤرشفة ادعُ لا توجد جهات اتصال مُصفاة @@ -1998,7 +1991,7 @@ يُرجى الطلب من جهة اتصالك تفعيل المكالمات. حذف %d رسائل الأعضاء؟ سيتم وضع علامة على الرسائل للحذف. سيتمكن المُستلم/(المُستلمون) من الكشف عن هذه الرسائل. - حدد + حدّد سيتم حذف الرسائل لجميع الأعضاء. سيتم وضع علامة على الرسائل على أنها تحت الإشراف لجميع الأعضاء. الرسالة @@ -2030,7 +2023,6 @@ ادعُ خيارات الوسائط الجديدة شغّل من قائمة الدردشة. - تبديل قائمة الدردشة: يمكنك تغييره في إعدادات المظهر. نزّل الإصدارات الجديدة من GitHub. ترقية التطبيق تلقائيًا @@ -2211,7 +2203,7 @@ الإشعارات والبطارية فقط مالكي الدردشة يمكنهم تغيير التفضيلات. الخصوصية لعملائك. - الجوالات عن بُعد + الجوّالات عن بُعد ادعُ للدردشة مغادرة المجموعة؟ سيتم إزالة العضو من الدردشة - لا يمكن التراجع عن هذا! @@ -2352,12 +2344,11 @@ إلغاء حظر الأعضاء للجميع؟ حظر الأعضاء للجميع؟ سيتم عرض رسائل من هؤلاء الأعضاء! - لا يمكن قراءة عبارة المرور في Keystore، يُرجى إدخالها يدويًا. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطورين. + لا يمكن قراءة عبارة المرور في Keystore، يُرجى إدخالها يدويًا. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطوِّرين. سيتم إزالة الأعضاء من المجموعة - لا يمكن التراجع عن هذا! المشرفين - لا يمكن قراءة عبارة المرور في Keystore. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطورين. + لا يمكن قراءة عبارة المرور في Keystore. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطوِّرين. موافقة الانتظار - ضبّط مُشغلي الخادم سياسة الخصوصية وشروط الاستخدام. لا يمكن الوصول إلى الدردشات الخاصة والمجموعات وجهات اتصالك لمشغلي الخادم. باستخدام SimpleX Chat، توافق على:\n- إرسال المحتوى القانوني فقط في المجموعات العامة.\n- احترام المستخدمين الآخرين – لا سبام. @@ -2396,7 +2387,7 @@ الكل دخول العضو راجع الأعضاء قبل القبول (الطرق). - حدث خطأ أثناء حذف الدردشة مع العضو. + خطأ في حذف الدردشة لا يمكن إرسال الرسائل رُفض طلب الانضمام غادرت @@ -2515,6 +2506,152 @@ افتح الرابط النظيف افتح الرابط الكامل أزل تتبع الروابط - رابط مُرحل SimpleX - خطأ في وضع علامة \"مقروءة\" على الدردشة مع العضو + عنوان مُرحل SimpleX + خطأ في وضع علامة \"مقروءة\" + البصمة في عنوان الخادم الوجهة لا تتطابق مع الشهادة: %1$s. + البصمة في عنوان خادم التحويل لا تتطابق مع الشهادة: %1$s. + البصمة في عنوان الخادم لا تتطابق مع الشهادة: %1$s. + لا اشتراك + أنت غير متصل بالخادم المستخدم لاستقبال الرسائل من هذا الاتصال (لا يوجد اشتراك). + احذف رسائل العضو + حذف رسائل العضو؟ + احذف الرسائل + ستُحذف رسائل العضو - ولا يمكن التراجع عن ذلك! + أزل واحذف الرسائل + كل الرسائل + فشل الاتصال + فشل + ملفات + تصفية + صور + روابط + ابحث عن ملفات + ابحث عن صور + ابحث عن روابط + ابحث عن فيديوهات + ابحث عن رسائل صوتية + فيديوهات + رسائل صوتية + إذا انضممت إلى قنوات أو أنشأتها، فستتوقف عن العمل نهائيًا. + %1$d/%2$d مُرحلات نشطة + %1$d/%2$d مُرحلات نشطة، %3$d فشلت + %1$d/%2$d مُرحلات متصلة + %1$d/%2$d مُرحلات متصلة، %3$d خطأ + %1$d مشترك + %1$d مشترك + وافقت + نشط + احظر المشترك للكل؟ + مُرحلات الدردشة + مُرحلات الدردشة + احذف القناة + احذف القناة؟ + حُذفت + حُذفت القناة + احذف المُرحل + إذاعة + إلغاء إنشاء القناة؟ + اختبر المُرحل لاسترداد اسمه.]]> + %1$s !]]> + قناة + قناة + قناة + اسم القناة بالكامل + رابط القناة + أعضاء القناة + اسم القناة + يُخزّن ملف تعريف القناة على أجهزة المشتركين وعلى مُرحلات الدردشة. + حُدِّث ملف تعريف القناة + ستُحذف القناة لجميع المشتركين - لا يمكن التراجع عن هذا الإجراء! + ستُحذف القناة من عِندك - لا يمكن التراجع عن هذا الإجراء! + ستبدأ القناة بالعمل مع %1$d من أصل %2$d من المُرحلات. أتود المتابعة؟ + مُرحل الدردشة + مُرحلات الدردشة + مُرحلات الدردشة توجّه الرسائل في القنوات التي تنشئها. + مُرحلات الدردشة توجّه الرسائل في القنوات في القناة. + تحقق من عنوان المُرحل وحاول مرة أخرى. + تحقق من اسم المُرحل وحاول مرة أخرى. + اضبط المُرحلات + اتصل + متصل + يتصل + أنشئ قناة عامة + أنشئ قناة عامة + أنشئ قناة عامة (تجريبي) + ينشئ قناة + %d أحداث القناة + فك ترميز الرابط + تم الإسقاط (%1$d محاولات) + حرّر ملف تعريف القناة + فعّل مُرحل دردشة واحد على الأقل لإنشاء قناة. + أدخل اسم المُرحل… + خطأ في إضافة المُرحل + خطأ في إنشاء القناة + خطأ في فتح القناة + خطأ: %s + خطأ في حفظ ملف تعريف القناة + فشل + فشل + احصل على الرابط + عنوان المُرحل غير صالح! + اسم المُرحل غير صالح! + مدعو + انضم للقناة + غادِر القناة + مغادرة القناة؟ + الرابط + رسالة خطأ + جديد + مُرحل دردشة جديد + لا مُرحلات دردشة + لا مُرحلات دردشة مفعّلة. + ليس كل المُرحلات متصلة + افتح قناة + افتح قناة جديدة + المالك + المالكون + عنوان المُرحل مسبق الضبط + اسم المُرحل مسبق الضبط + مُرحل + مُرحل + عنوان المُرحل + عنوان المُرحل + فشل اتصال المُرحل + رابط المُرحل + فشل اختبار المُرحل + أزِل المشترك + إزالة المشترك؟ + احفظ وأرسل إشعار للمشتركين في القناة + احفظ ملف تعريف القناة + يتطلب الخادم تفويضًا للاتصال بالمُرحل، يُرجى التحقق من كلمة المرور. + تحذير من الخادم + شارك عنوان المُرحل + مشترك + المشتركون + يستخدم المشتركون رابط المُرحل للاتصال بالقناة.\nاُستخدم عنوان المُرحل لإعداد هذا المُرحل للقناة. + ستُزيل المشترك من القناة - لا يمكن التراجع عن هذا الإجراء! + انقر انضم للقناة + فشل الاختبار عند الخطوة %s. + اختبر المُرحل + أُزيل التطبيق هذه الرسالة بعد %1$d محاولات لاستلامها. + عنوان مُرحل الدردشة هذا لا يمكن استخدامه للاتصال. + إلغاء حظر المشترك للجميع؟ + ملف القناة التعريفي حُدِّث + استخدم للقنوات الجديدة + استخدم مُرحل + تحقق + عبر %1$s + تسجيل الصوت غير مدعوم على منصتك + انتظر + رد الانتظار + أنت + أنت مشترك + بإمكانك مشاركة رابط أو رمز QR - وسيتمكن أي شخص من الانضمام إلى القناة. + لقد اتصلت بالقناة عبر رابط المُرحل هذا. + قناتك + قناتك + سيتم مشاركة ملف تعريفك %1$s مع مُرحلات القناة والمشتركين.\nيمكن للمُرحلات الوصول إلى رسائل القناة. + عنوان مُرحلك + اسم مُرحلك + ستتوقف عن تلقي الرسائل من هذه القناة، وسيتم الاحتفاظ بسجل الدردشة. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b13e3f4046..375edecd44 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -27,14 +27,18 @@ Invalid file path You shared an invalid file path. Report the issue to the app developers. View crashed + App is already running + Another app instance may be running or did not exit properly. Start anyway? connected error connecting - You are connected to the server used to receive messages from this contact. - Trying to connect to the server used to receive messages from this contact (error: %1$s). - Trying to connect to the server used to receive messages from this contact. + no subscription + You are connected to the server used to receive messages from this connection. + Trying to connect to the server used to receive messages from this connection. + Error connecting to the server used to receive messages from this connection: %1$s. + You are not connected to the server used to receive messages from this connection (no subscription). deleted @@ -52,6 +56,7 @@ %d messages blocked by admin sending files is not supported yet receiving files is not supported yet + Voice recording is not supported on your platform you unknown message format invalid message format @@ -71,6 +76,7 @@ end-to-end encryption.]]> end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.]]> quantum resistant e2e encryption with perfect forward secrecy, repudiation and break-in recovery.]]> + not end-to-end encrypted. Chat relays can see these messages.]]> This chat is protected by end-to-end encryption. This chat is protected by quantum resistant end-to-end encryption. @@ -98,7 +104,7 @@ SimpleX one-time invitation SimpleX group link SimpleX channel link - SimpleX relay link + SimpleX relay address via %1$s SimpleX links Description @@ -139,6 +145,8 @@ No servers to receive files. For chat profile %s: Errors in servers configuration. + No chat relays enabled. + Server warning Error accepting conditions Spam Content violates conditions of use @@ -186,6 +194,8 @@ Please check that you used the correct link or ask your contact to send you another one. Unsupported connection link This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Channel temporarily unavailable + Channel has no active relays. Please try to join later. Connection error (AUTH) 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. Connection blocked @@ -199,6 +209,7 @@ Error deleting group Error deleting private notes Error deleting contact request + Error deleting message Error deleting pending contact connection Error changing address Error aborting address change @@ -366,6 +377,18 @@ Edit Info Search + Search images + Search videos + Search voice messages + Search files + Search links + Images + Videos + Voice messages + Files + Links + All messages + Filter Archive Archive report Archive reports @@ -404,6 +427,7 @@ The messages will be marked as moderated for all members. Delete for me For everyone + From history Stop file Stop sending file? Sending file will be stopped. @@ -446,6 +470,15 @@ Tap to start a new chat Chat with the developers You have no chats + Talk to someone + Let someone connect to you + Connect via link or QR code + Create your link + Invite someone privately + A link for one person to connect + Create your public address + Your public address + For anyone to reach you Loading chats… No filtered chats No chats in list %s. @@ -478,6 +511,7 @@ Favorites Contacts Groups + Channels Businesses Notes Reports @@ -500,8 +534,11 @@ Your contact Bot Tap Join group + Tap Join channel Your group + Your channel Group + Channel Business connection Your business contact @@ -511,8 +548,21 @@ Share file… Forward message… Forward messages… + Share channel… Cannot send message Selected chat preferences prohibit this message. + Share via chat + Tap to open + Channel link + Group link + Business address + Contact address + One-time link + (from owner) + (signed) + Error sharing channel + Link signature verified. + ⚠️ Signature verification failed: %s. Attach @@ -547,6 +597,8 @@ Report sent to moderators You can view your reports in Chat with admins. Join group + Join channel + Broadcast Add message Connect Send contact request? @@ -561,12 +613,15 @@ not synchronized contact disabled you are observer + you are subscriber Please contact group admin. + channel request to join rejected group is deleted removed from group you left can\'t send messages + can\'t broadcast you are observer reviewed by admins member has old version @@ -863,7 +918,7 @@ New chat New message Add contact - Scan / Paste link + Paste link / Scan Paste link One-time invitation link 1-time link @@ -1016,7 +1071,7 @@ for each contact and group member.\nPlease note: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]> Update transport isolation mode? Use .onion hosts to No if SOCKS proxy does not support them.]]> - Please note: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]> + Please note: message and file relays are connected via SOCKS proxy. Calls use direct connection.]]> Private routing Always Unknown servers @@ -1091,12 +1146,12 @@ All your contacts will remain connected. All your contacts will remain connected. Profile update will be sent to your contacts. Share link - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. Create an address to let people connect with you. Create SimpleX address - Share with contacts - Share address with contacts? - Profile update will be sent to your contacts. + Share with SimpleX contacts + Share address with SimpleX contacts? + Profile update will be sent to your SimpleX contacts. Stop sharing address? Stop sharing Auto-accept @@ -1113,6 +1168,11 @@ Or to share privately SimpleX address or 1-time link? Create 1-time link + New 1-time link + Send the link via any messenger - it\'s secure. Ask to paste into SimpleX. + Or show QR in person or via video call. + Use this address in your social media profile, website, or email signature. + Or use this QR - print or show online. Address settings Business address Add your team members to the conversations. @@ -1147,6 +1207,7 @@ Save and notify contact Save and notify contacts Save and notify group members + Save and notify channel subscribers Exit without saving @@ -1235,9 +1296,27 @@ Make a private connection Migrate from another device How it works + Be free\nin your network + Private and secure messaging. + The first network where you own\nyour contacts and groups. + Get started + Why SimpleX is built. + Your profile + On your phone, not on servers. + No account. No phone. No email. No ID.\nThe most secure encryption. + Enter profile name… + Migrate - - How SimpleX works + + You were born without an account. + Nobody tracked your conversations. No one drew a map of where you\'d been. Privacy was never a feature — it was the way of life. + Then we moved online, and every platform asked for a piece of you — your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way — telephone, email, messengers, social media. It seemed the only way possible. + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + Not a better lock on someone else\'s door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it — you are sovereign. + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + The oldest human freedom — to speak to another person without being watched — built on infrastructure that cannot betray it. + Because we destroyed the power to know who you are. So that your power can never be taken. + Be free in your network. To protect your privacy, SimpleX uses separate IDs for each of your contacts. Only client devices store user profiles, contacts, groups, and messages. end-to-end encrypted, with post-quantum security in direct messages.]]> @@ -1264,11 +1343,10 @@ Use random passphrase - Private chats, groups and your contacts are not accessible to server operators. - By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam. + Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code + You commit to:\n- Only legal content in public groups\n- Respect other users — no spam Privacy policy and conditions of use. Accept - Configure server operators Server operators Network operators SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. @@ -1285,6 +1363,15 @@ Update Continue + + Your network + Network routers cannot know\nwho talks to whom + Setup routers + Setup notifications + + + Network commitments + Incoming video call Incoming audio call @@ -1320,6 +1407,7 @@ Open SimpleX Chat to accept call Enable calls from lock screen via Settings. Open + Open external link? e2e encrypted @@ -1352,6 +1440,10 @@ bad message hash bad message ID duplicate message + dropped (%1$d attempts) + error: %s + Message error + The app removed this message after %1$d attempts to receive it. Skipped messages It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised. Bad message hash @@ -1613,7 +1705,8 @@ Confirm database upgrades Reachable app toolbars Reachable chat toolbar - Toggle chat list: + Bottom bar + Top bar You can change it in Appearance settings. Show console in new window Show chat list in new window @@ -1624,6 +1717,7 @@ different migration in the app/database: %s / %s Migrations: %s Warning: you may lose some data! + If you joined or created channels, they will stop working permanently. Chat is stopped @@ -1641,8 +1735,10 @@ You joined this group. Connecting to inviting group member. Leave Leave group? + Leave channel? Leave chat? You will stop receiving messages from this group. Chat history will be preserved. + You will stop receiving messages from this channel. Chat history will be preserved. You will stop receiving messages from this chat. Chat history will be preserved. Invite members Group inactive @@ -1679,7 +1775,9 @@ removed %1$s removed you deleted group + deleted channel updated group profile + updated channel profile invited via your group link requested connection New member wants to join the group. @@ -1690,6 +1788,7 @@ you removed %1$s you left group profile updated + channel profile updated you accepted this member Please wait for group moderators to review your request to join the group. @@ -1698,6 +1797,7 @@ %s, %s and %s connected %s, %s and %d other members connected %d group events + %d channel events and %d other events %s and %s %s, %s and %d members @@ -1741,6 +1841,7 @@ moderator admin owner + relay rejected @@ -1789,24 +1890,33 @@ %1$s MEMBERS you: %1$s Delete group + Delete channel + Cancel and delete channel Delete chat Delete group? + Delete channel? Delete chat? Group will be deleted for all members - this cannot be undone! + Channel will be deleted for all subscribers - this cannot be undone! Chat will be deleted for all members - this cannot be undone! Group will be deleted for you - this cannot be undone! + Channel will be deleted for you - this cannot be undone! Chat will be deleted for you - this cannot be undone! Leave group + Leave channel Leave chat Edit group profile + Edit channel profile Add welcome message Welcome message Group link + Channel link Create group link Create link Delete link? Delete link You can share a link or a QR code - anybody will be able to join the group. You won\'t lose members of the group if you later delete it. + You can share a link or a QR code - anybody will be able to join the channel. All group members will remain connected. Error creating group link Error updating group link @@ -1814,6 +1924,7 @@ Error creating member contact Error sending invitation Only group owners can change group preferences. + Only channel owners can change channel preferences. Only chat owners can change preferences. Address Share address @@ -1823,7 +1934,10 @@ Receipts are disabled This group has over %1$d members, delivery receipts are not sent. Invite + Link Chat with admins + Channel members + Chat relays FOR CONSOLE @@ -1858,15 +1972,22 @@ Remove member? + Remove subscriber? Remove members? + Delete member messages? Remove member + Delete member messages Chat with member Send direct message Member will be removed from group - this cannot be undone! + Subscriber will be removed from channel - this cannot be undone! Members will be removed from group - this cannot be undone! Member will be removed from chat - this cannot be undone! Members will be removed from chat - this cannot be undone! + Member messages will be deleted - this cannot be undone! Remove + Remove and delete messages + Delete messages Remove member Block member? Block member @@ -1887,6 +2008,7 @@ Blocked by admin blocked disabled + failed inactive MEMBER Role @@ -1905,6 +2027,7 @@ Group Chat Connection + CONNECTION FAILED direct indirect (%1$s) Message queue info @@ -1951,6 +2074,7 @@ Fully decentralized – visible only to members. Enter group name: Group full name: + Channel full name: Short description: Description too large Your chat profile will be sent to group members @@ -1959,8 +2083,11 @@ Group profile is stored on members\' devices, not on the servers. + Channel profile is stored on subscribers\' devices and on the chat relays. Save group profile + Save channel profile Error saving group profile + Error saving channel profile Preset servers @@ -2140,6 +2267,7 @@ Chat preferences Contact preferences Group preferences + Channel preferences Set group preferences Set member admission Your preferences @@ -2241,6 +2369,35 @@ History is not sent to new members. Members can report messsages to moderators. Reporting messages is prohibited in this group. + Chat with admins + Allow members to chat with admins. + Prohibit chats with admins. + Members can chat with admins. + Chats with admins are prohibited. + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + Enable chats with admins? + Enable + + + Subscriber reports + Allow sending direct messages to subscribers. + Prohibit sending direct messages to subscribers. + Send up to 100 last messages to new subscribers. + Do not send history to new subscribers. + Subscribers can send disappearing messages. + Subscribers can send direct messages. + Direct messages between subscribers are prohibited. + Subscribers can irreversibly delete sent messages. (24 hours) + Subscribers can add message reactions. + Subscribers can send voice messages. + Subscribers can send files and media. + Subscribers can send SimpleX links. + Subscribers can report messsages to moderators. + Up to 100 last messages are sent to new subscribers. + History is not sent to new subscribers. + Allow subscribers to chat with admins. + Subscribers can chat with admins. + Delete after %d sec %ds @@ -2277,6 +2434,7 @@ Chats with members No chats with members + Chats with members are disabled Delete chat Delete chat with member? @@ -2485,6 +2643,17 @@ Share your address 4 new interface languages Catalan, Indonesian, Romanian and Vietnamese - thanks to our users! + Public channels - speak freely 🚀 + Reliability: many relays per channel. + Ownership: you can run your own relays. + Security: owners hold channel keys. + Privacy: for owners and subscribers. + Easier to invite your friends 👋 + We made connecting simpler for new users. + Safe web links + - opt-in to send link previews.\n- use SOCKS proxy if enabled.\n- prevent hyperlink phishing.\n- remove link tracking. + Non-profit governance + To make SimpleX Network last. View updated conditions @@ -2773,4 +2942,160 @@ You can mention up to %1$s members per message! + + + Subscribers + Owners + %1$d subscriber + %1$d subscribers + you + + + Chat relay + New chat relay + Preset relay name + Preset relay address + Your relay name + Your relay address + Enter relay name… + Use relay + Test relay + Use for new channels + Delete relay + Test relay to retrieve its name.]]> + Relay test failed! + Get link + Decode link + Connect + Wait response + Verify + Test failed at step %s. + Server requires authorization to connect to relay, check password. + Invalid relay name! + Check relay name and try again. + Invalid relay address! + Check relay address and try again. + Error adding relay + + + Chat relays + Chat relays forward messages in channels you create. + + + Chat relays + No chat relays + Chat relays forward messages to channel subscribers. + connected + connecting + deleted + failed + removed by operator + removed + new + invited + accepted + active + inactive + rejected + Status + rejected by relay operator + + + All relays removed + All relays failed + No active relays + %1$d relays removed + %1$d relays failed + %1$d relays not active + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d relays active, %3$d removed + %1$d/%2$d relays active, %3$d errors + %1$d/%2$d relays active + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d relays connected + No relays + Add relays to restore message delivery. + Waiting for channel owner to add relays. + + + RELAY + OWNER + SUBSCRIBER + Channel + Relay link + Relay address + via %1$s + Share relay address + Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel. + You connected to the channel via this relay link. + Remove subscriber + Remove relay + Remove relay? + Relay will be removed from channel - this cannot be undone! + This is the last active relay. Removing it will prevent message delivery to subscribers. + Block subscriber for all? + + + Create public channel + Create public channel + Create public channel (BETA) + Channel name + Creating channel + Error creating channel + Relay results: + The connection reached the limit of undelivered messages + Network error + Error + Cancel creating channel? + Your new channel %1$s is connected to %2$d of %3$d relays.\nIf you cancel, the channel will be deleted - you can create it again. + Enable at least one chat relay to create a channel. + Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages. + Configure relays + failed + Add + Add relay + Add relays + No available relays + Error adding relays + Relays added: %1$s. + Select relays + No relays selected + %d relay(s) selected + Relay connection failed + Not all relays connected + Wait + Channel will start working with %1$d of %2$d relays. Continue? + + + Relay address + This is a chat relay address, it cannot be used to connect. + Open channel + Open new channel + Your channel + %1$s!]]> + Error opening channel + + + Unblock subscriber for all? + + + Enable link previews? + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + Link preview will be requested via SOCKS proxy. DNS lookup may still happen locally via your DNS resolver. + Enable + Disable + + + Minimize to tray? + If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings. + Close the app + Minimize to tray + Show SimpleX + Quit SimpleX + SimpleX + SimpleX — %d unread + Minimize to tray when closing window + Keep SimpleX running in the background to receive messages. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index bf04fd6a1e..c691447b32 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -764,7 +764,6 @@ Информация Инсталирай SimpleX Chat за терминал Как работи - Как работи SimpleX Защитен от спам Игнорирай Покани членове @@ -1038,7 +1037,7 @@ SimpleX Лого SimpleX Екип SMP сървъри - Сподели адреса с контактите\? + Сподели адреса с контактите? Сподели линк Сподели с контактите Спри споделянето @@ -1182,7 +1181,7 @@ Тази настройка се прилага за съобщения в текущия ви профил За да се защити поверителността, SimpleX използва идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). + Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). Тестът е неуспешен на стъпка %s. Базата данни не работи правилно. Докоснете, за да научите повече Изображението не може да бъде декодирано. Моля, опитайте с друго изображение или се свържете с разработчиците. @@ -2040,7 +2039,6 @@ Пропусни тази версия Провери за актуализации БАЗА ДАННИ - Превключване на чат списъка: Можете да изпращате съобщения до %1$s от архивираните контакти. Достъпен панел Изпращането на съобщения на груповия член не е налично @@ -2328,7 +2326,6 @@ Чат с член Разговаряйте с членовете, преди да се присъединят. Нарушение на правилата на общността - Конфигуриране на сървърни оператори Свързване Свържете се по-бързо! 🚀 Връзката е блокирана diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index c9b1f79694..7ab3f5a381 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1216,7 +1216,6 @@ Sense identificadors d\'usuari. Per protegir la vostra privadesa SimpleX utilitza identificadors separats per a cadascun dels vostres contactes.. Obrir SimpleX - Com funciona SimpleX Només els dispositius client emmagatzemen perfils d\'usuari, contactes, grups i missatges. Notificacions privades Com afecta la bateria @@ -1555,12 +1554,12 @@ Obrint la base de dades… Comproveu que l\'enllaç SimpleX sigui correcte. l\'enviament de fitxers encara no està suportat - Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte. - S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). + Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquesta connexió. + S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). Usar perfil actual Usar nou perfil incògnit Error aplicació - Esteu connectat al servidor utilitzat per rebre missatges d\'aquest contacte. + Esteu connectat al servidor utilitzat per rebre missatges d\'aquesta connexió. El teu perfil s\'enviarà al contacte del qual has rebut aquest enllaç. Heu compartit una ruta de fitxer no vàlida. Informeu-ne als desenvolupadors de l\'aplicació. Us connectareu amb tots els membres del grup. @@ -1613,9 +1612,9 @@ La connexió ha arribat al límit de missatges no lliurats, és possible que el vostre contacte estigui fora de línia. Missatges no lliurats A menys que el vostre contacte hagi suprimit la connexió o aquest enllaç ja s\'ha utilitzat, pot ser que sigui un error; si us plau, informeu-ho.\nPer connectar-vos, demaneu al vostre contacte que creï un altre enllaç de connexió i comproveu que teniu una connexió de xarxa estable. - Possiblement, l\'empremta digital del certificat a l\'adreça del servidor és incorrecta - El servidor requereix autorització per crear cues, comproveu la contrasenya - El servidor requereix autorització per carregar, comproveu la contrasenya + L\'empremta digital a l\'adreça del servidor no coincideix amb el certificat. + El servidor requereix autorització per crear cues, comproveu la contrasenya. + El servidor requereix autorització per carregar, comproveu la contrasenya. La prova ha fallat al pas %s. Cua segura Carrega fitxer @@ -1975,7 +1974,6 @@ Barres d\'eines d\'aplicacions accessible Barres d\'eines de xat accessible Mostra la llista de xat en una finestra nova - Commuta la llista de xat: Mostrar consola en finestra nova Podeu canviar-la a la configuració de l\'aparença. Actualitzar i obrir el xat @@ -2342,7 +2340,6 @@ Els xats privats, els grups i els vostres contactes no són accessibles per als operadors de servidor. Acceptar En utilitzar SimpleX Chat accepteu:\n- enviar només contingut legal en grups públics.\n- Respectar els altres usuaris, sense correu brossa. - Configurar els operadors de servidor Enllaç al canal SimpleX Aquest enllaç requereix una versió de l\'aplicació més recent. Actualitzeu l\'aplicació o demaneu al vostre contacte que enviï un enllaç compatible. Enllaç de connexió no compatible @@ -2381,7 +2378,7 @@ No hi ha xats amb membres Acceptar com a observador Desar la configuració d\'admissió? - Error en suprimir el xat amb membre + Error en suprimir el xat %d xat(s) el(la) membre té una versió antiga Un(a) nou(va) membre vol unir-se al grup. @@ -2497,4 +2494,15 @@ Eliminar el seguiment de l\'enllaç Opcions obsoletes Enllaç de servidor SimpleX + Error marcant com a llegit + L\'empremta digital a l\'adreça del servidor de destinació no coincideix amb el certificat: %1$s. + L\'empremta digital a l\'adreça del servidor de reenviament no coincideix amb el certificat: %1$s. + L\'empremta digital a l\'adreça del servidor no coincideix amb el certificat: %1$s. + cap subscripció + No esteu connectat(da) al servidor que s\'utilitza per rebre missatges d\'aquesta connexió (sense subscripció). + Suprimir missatges de membre + Suprimir missatges de membre? + Suprimir missatges + Els missatges de membre s\'eliminaran; això no es pot desfer! + Eliminar membre i els seus missatges diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 4d6aa47fd3..df4907885c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -49,7 +49,7 @@ Vytvoření tajné skupiny Zadejte název skupiny: Úplný název skupiny: - Váš chat profil bude zaslán členům skupiny + Váš profil chatu bude zaslán členům skupiny Profil skupiny je uložen v zařízeních členů, nikoli na serverech. Uložit Aktualizovat nastavení sítě\? @@ -228,7 +228,7 @@ Použít proxy server SOCKS\? Použít přímé připojení k internetu\? Ne - Chat profil + Profil chatu Připojení simplexmq: v%s (%2s) Vytvořit adresu @@ -242,7 +242,6 @@ probíhající hovor Decentralizovaná Jak to funguje - Jak funguje SimpleX Pouze klientská zařízení ukládají uživatelské profily, kontakty, skupiny a zprávy. Soukromé oznámení Pravidelné @@ -276,9 +275,9 @@ Chyba při exportu databáze chatu Import Restartujte aplikaci, abyste mohli používat importovanou databázi chatu. - Smazat chat profil\? + Smazat profil chatu? Tuto akci nelze vzít zpět! Váš profil, kontakty, zprávy a soubory budou nenávratně ztraceny. - Restartujte aplikaci a vytvořte nový chat profil. + Restartujte aplikaci a vytvořte nový profil chatu. Nejnovější verzi databáze chatu musíte používat POUZE v jednom zařízení, jinak se může stát, že přestanete přijímat zprávy od některých kontaktů. Soubory a média Smazat soubory a média\? @@ -315,7 +314,7 @@ změnila se vaše adresa Rozšířit výběr rolí Nelze pozvat kontakt! - Již máte chat profil se stejným názvem. Zvolte prosím jiné jméno. + Již máte profil chatu se stejným názvem. Zvolte prosím jiné jméno. Vytvořit frontu Zabezpečit frontu Okamžitá oznámení! @@ -356,7 +355,7 @@ Nastavit 1 den Chyba spojení (AUTH) Odesílatel možná smazal požadavek připojení - Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo + Server vyžaduje autorizaci pro vytvoření front, zkontrolujte heslo. Odstranit frontu Smazat Smazat čekající připojení\? @@ -372,7 +371,7 @@ Vaše nastavení Vaše SimpleX adresa Přístupová fráze k databázi a export - Vaše chat profily + Profily chatu Zaslat otázky a nápady Test serveru Servery ICE (jeden na řádek) @@ -462,7 +461,7 @@ Chyba při odebrání člena Chyba při ukládání profilu skupiny vteřiny - Umožňuje mít v jednom chat profilu mnoho anonymních spojení bez sdílení údajů mezi nimi. + Umožňuje mít v jednom profilu chatu mnoho anonymních spojení bez sdílení údajů mezi nimi. Pokud s někým sdílíte inkognito profil, bude použit pro skupiny, do kterých vás pozve. Systémové Hlasové zprávy @@ -484,7 +483,7 @@ Ověření zabezpečení připojení Francouzské rozhraní Díky uživatelům - překládejte prostřednictvím Weblate! - Více chat profilů + Více profilů chatu Různá jména, avataři a izolace přenosu. Návrh zprávy Zachování posledního návrhu zprávy s přílohami. @@ -493,7 +492,7 @@ Připojtíte se ke všem členům skupiny. Připojení Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu. - Pokoušíte se připojit k serveru používaném pro příjem zpráv od tohoto kontaktu (chyba: %1$s). + Pokoušíte se připojit k serveru používaném pro příjem zpráv od tohoto kontaktu (chyba: %1$s). označeno jako smazáno Odesílání souborů zatím není podporováno přijímání souborů zatím není podporováno @@ -519,7 +518,7 @@ Chyba mazání žádosti kontaktu Chyba mazání probíhajícího připojení kontaktu Test selhal v kroku %s. - Je možné, že otisk certifikátu v adrese serveru je nesprávný. + Otisk adresy serveru neodpovídá certifikátu. Připojit Odpojit Chyba mazání uživatelského profilu @@ -615,7 +614,7 @@ Adresa vašeho serveru Použít server Použít pro nová připojení - Servery pro nová připojení vašeho aktuálního chat profilu. + Servery pro nová připojení v rámci vašeho aktuálního profilu chatu. Použít SimpleX Chat servery\? Použití SimpleX Chat serverů. Uložené servery WebRTC ICE budou odstraněny. @@ -629,7 +628,7 @@ Onion hostitelé budou použiti, pokud jsou k dispozici. Onion hostitelé nebudou použiti. Izolace přenosu - for each chat profile you have in the app.]]> + pro každý profil chatu, který máte v aplikaci.]]> pro všechny kontakty a členy skupin. \nUpozornění: Pokud máte mnoho připojení, může být spotřeba baterie a provoz podstatně vyšší a některá připojení mohou selhat.]]> Vzhled Verze aplikace @@ -726,7 +725,7 @@ Chyba při mazání databáze chatu Chyba při importu databáze chatu Databáze chatu odstraněna - Odstranit soubory všech chat profilů + Odstranit soubory všech profilů chatu Odstranit všechny soubory Tuto akci nelze vrátit zpět - všechny přijaté a odeslané soubory a média budou smazány. Obrázky s nízkým rozlišením zůstanou zachovány. Žádné přijaté ani odeslané soubory @@ -734,7 +733,7 @@ nikdy %s sekund(y) Zprávy - Toto nastavení se vztahuje na zprávy ve vašem aktuálním chat profilu. + Toto nastavení se vztahuje na zprávy ve vašem aktuálním profilu chatu. Smazat zprávy po Povolit automatické mazání zpráv\? Tuto akci nelze vzít zpět - zprávy odeslané a přijaté dříve, než bylo zvoleno, budou smazány. Může to trvat několik minut. @@ -862,7 +861,7 @@ Povolit TCP keep-alive Aktualizovat Smazat profil chatu? - Smazat chat profil pro + Smazat profil chatu pro Profil a připojení k serveru Pouze místní data profilu Režim inkognito chrání vaše soukromí používáním nového náhodného profilu pro každý kontakt. @@ -872,12 +871,12 @@ Kontakt povolil zaplé vyplé - Chat předvolby + Předvolby chatu Předvolby kontaktu Předvolby skupiny Přímé zprávy Mazání všem - zapnuty + zapnuto povoleno vám vypnuty Mizící zprávy zakázány. @@ -923,15 +922,14 @@ Italské rozhraní Díky uživatelům - překládejte prostřednictvím Weblate! Budete připojeni, jakmile bude zařízení vašeho kontaktu online, vyčkejte prosím nebo se podívejte později! - Váš chat profil bude odeslán -\nvašemu kontaktu + Váš profil chatu bude odeslán \nvašemu kontaktu Konverzace Sdílet jednorázovou pozvánku koncově šifrované moderované moderovaný %s Smazat zprávu člena\? - moderovaný + Moderovat Kontaktujte prosím správce skupiny. jste pozorovatel pozorovatel @@ -953,7 +951,7 @@ Chyba aktualizace soukromí uživatele Správa skupin Uvítací zpráva skupin - Skryté chat profily + Skryté profily chatu Hesla skrytých profilů Skrýt Skrýt profil @@ -964,7 +962,7 @@ \n- zakázat členy (role "pozorovatel") Uložit heslo profilu Ztlumit - Chraňte své chat profily heslem! + Chraňte své profily chatu pomocí hesla! Uložit a aktualizovat profil skupiny Ztlumit při neaktivitě! Heslo k zobrazení @@ -976,7 +974,7 @@ Uvítací zpráva Uvítací zpráva Zrušit ztlumení - Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce Chat profily. + Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce Profily chatu. Stále budete přijímat volání a upozornění od umlčených profilů pokud budou aktivní. Můžete skrýt nebo ztlumit uživatelský profil - Podržte pro menu. Odkrýt @@ -998,10 +996,10 @@ Zobrazit: ID databáze a možnost Izolace přenosu. Soubor bude přijat, jakmile váš kontakt dokončí nahrávání. - Smazat chat profil + Smazat profil chatu Smazat profil Heslo profilu - Odkrýt chat profil + Odkrýt profil chatu Odkrýt profil Žádost o přijetí videa Současně lze odeslat pouze 10 videí @@ -1016,7 +1014,7 @@ Chyba načítání serverů XFTP Chyba ukládání XFTP serverů Ujistěte se, že adresy XFTP serverů jsou ve správném formátu s oddělenými řádky a nejsou duplicitní. - Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo + Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo. Porovnat soubor Vytvořit soubor Smazat soubor @@ -1104,7 +1102,7 @@ Heslo pro sebedestrukci změněno! Další zbarvení Sekundární - Vytvořit prázdný chat profil se zadaným názvem a otevřít aplikaci jako obvykle. + Vytvořit prázdný profil chatu se zadaným názvem a otevřít aplikaci jako obvykle. Pokud tento přístupový kód zadáte při otevření aplikace, všechna data budou nenávratně smazána! Další sekundární Pozadí @@ -1114,10 +1112,10 @@ Chyba načítání podrobností Info Hledat - Změnit chat profily + Změnit profily chatu Historie Přijatá zpráva - Poslaná zpráva + Odeslaná zpráva Mizící zpráva Poslat mizící zprávu 1 minutu @@ -1169,7 +1167,7 @@ BARVY MOTIVU Přizpůsobit motiv Aktualizace profilu bude zaslána vašim kontaktům. - Sdílet adresu s kontakty\? + Sdílet adresu s kontakty? Přestat sdílet adresu\? Vytvořit adresu, aby se s vámi lidé mohli spojit. Uložit nastavení SimpleX adresy @@ -1248,7 +1246,7 @@ šifrování povoleno pro %s vyžadováno opětovné vyjednávání šifrování pro %s Odesílání potvrzení o doručení je vypnuto pro %d kontakty. - Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných chat profilech. + Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných profilech chatu. Toto nastavení je pro váš aktuální profil opětovné vyjednávání šifrování povoleno opětovné vyjednávání šifrování povoleno pro %s @@ -1406,7 +1404,7 @@ Odpojit počítač? Vytvořit skupinu Kód relace - Vložit adres počítače + Vložit adresu počítače Blokovaný Lepší skupiny Rozbalit @@ -1471,7 +1469,7 @@ Blokovat člena Použít z PC Opakovat požadavek na připojení? - Vytvořit chat profil + Vytvořit profil chatu Zobrazit havarované Smazat a informovat kontakt Již se připojujete přes tento jednorázový odkaz! @@ -1544,7 +1542,7 @@ blokováno %s kontakt %1$s změnen na %2$s Vytvořeno v - Blok všem + Blokovat všem Blokovat člena všem? Chyba blokování člena všem Vylepšené doručovaní zpráv @@ -1592,7 +1590,7 @@ Chyba otevření prohlížeče odstraněn profilový obrázek nastavit novou kontaktní adresu - nastavit nový profilový obrázek + nastavil nový profilový obrázek Uložené zprávy Pomalá funkce Soukromé poznámky @@ -1744,7 +1742,7 @@ Kamera Kamera a mikrofon SimpleX odkazy jsou zakázány. - koncovým šifrováním s dokonalým dopředným utajením, odmítnutím a obnovením po vloupání.]]> + koncovým šifrováním s dokonalým dopředným utajením, odmítnutím a obnovením po vloupání.]]> Pokročilé nastavení Všechny barevné režimy Překročená kapacita - příjemci neobdrží dříve poslané zprávy. @@ -1902,7 +1900,7 @@ Chat databáze exportována Členu skupiny nelze odeslat zprávu Požádejte váš kontakt ať povolí volání. - Odeslaných odpovědí + Odeslaná odpověď Škálovat Přizpůsobit Rozmazání pro lepší soukromí. @@ -1946,7 +1944,7 @@ Smazat bez upozornění hledat Chyba přepínání profilu - Vyberte chat profil + Vyberte profil chatu zpráva otevřít Kontakt smazán! @@ -2055,7 +2053,7 @@ Žádné servery pro soukromé směrování chatů. Žádné servery pro příjem souborů. Žádné servery pro příjem zpráv. - Všechny chaty budou ze seznamu odebrány %s, a seznam bude smazán + Všechny chaty budou ze seznamu %s odebrány, a seznam bude smazán Pro sociální sítě Vzdálené telefony %s.]]> @@ -2213,7 +2211,7 @@ Chyba dočasného souboru Přesunout sezení TCP připojení - Použité servery + Použít servery Použit %s Pro příjem Systém @@ -2241,14 +2239,13 @@ Můžete nastavit operátory v nastavení sítě a serverů. Ocas Zastavíte přijímání zpráv z tohoto chatu. Chat historie bude zachována. - Servery pro nové soubory vašeho aktuálního chat profilu + Servery pro nové soubory vašeho aktuálního profilu chatu Protokolu serveru se změnil. Operátor serveru se změnil. Zoom Nastavit výchozí téma Nahráno Ano - Přepnout chat seznam: Tuto akci nelze zrušit - zprávy odeslané a přijaté v tomto chatu dříve než vybraná, budou smazány. Statistiky serverů budou obnoveny - nemůže být vráceno! Odešlete soukromý report @@ -2266,7 +2263,7 @@ Použit pro zprávy Server přidán k operátoru %s. Průhlednost - Přepínání chat profilu pro 1-rázové pozvánky. + Přepínání profilu chatu pro jednorázové pozvánky. video Sdílet profil Reportování zpráv je zakázáno v této skupině. @@ -2288,7 +2285,7 @@ Pro ochranu vaší IP adresy, soukromé směrování používá vaše servery SMP k doručování zpráv. Použít web portu TCP port pro zprávy - Váš chat profil bude zaslán členům + Váš profil chatu bude zaslán členům chatu Režim systému Zmínky členů 👋 Organizujte konverzace do seznamů @@ -2331,7 +2328,7 @@ Použít TCP port %1$s, když není zadán žádný port. Tento odkaz byl použit s jiným mobilním zařízením, vytvořte na počítači nový odkaz. Získejte upozornění, když jste zmíněni. - SimpleX adresa a 1 rázové odkazy je bezpečné sdílet přes všechny komunikátory. + SimpleX adresa a jednorázové odkazy je bezpečné sdílet přes všechny komunikátory. Zprávy budou smazány pro všechny členy. Aplikace vyžaduje potvrzení stahování z neznámých serverů (s výjimkou .onion nebo při aktivaci SOCKS proxy). Musíte povolit kontaktům volání, abyste jim mohli zavolat. @@ -2367,7 +2364,6 @@ Členové budou odstraněny z chatu - toto nelze zvrátit! Použitím SimpleX chatu souhlasíte že:\n- ve veřejných skupinách budete zasílat pouze legální obsah.\n- budete respektovat ostatní uživatele – žádný spam. Přijmout - Nastavit operátora serveru Zásady ochrany soukromí a podmínky používání. Soukromé konverzace, skupiny a kontakty nejsou přístupné provozovatelům serverů. Nepodporovaný odkaz k připojení @@ -2395,8 +2391,8 @@ Chat s adminy Přijmout Přijmout člena - schválen adminy - Upgradovat adresu + čeká na schválení adminy + Povýšit adresu přijat %1$s Vás přijal schválení @@ -2427,7 +2423,7 @@ Přijmout jako člena Přijmout jako pozorovatele Odmítnout člena? - Chyba odstranění chatu se členem + Chyba odstranění chatu Použït profil inkognito Otevřít chat Otevřít nový chat @@ -2468,5 +2464,96 @@ Odstranit zprávy a blokovat členy. koncovým šifrováním.]]> SimpleX relé odkaz - Bez soukromého směrování sezení + Sezení bez soukromého směrování + 4 nové jazyky rozhraní + Přijmout žádost o kontakt + Přijmout žádost o kontakt + Přidat zprávu + Povolit soubory a média pouze pokud, je váš kontakt povolí. + Povolit vašim kontaktům odesílání souborů a médii. + Bio: + Bio příliš velké + Bot + Vy i vaše kontakty můžete posílat soubory a média. + Obchodní spojení + Nelze změnit profil + Katalánština, Indonéština, Rumunština a Vietnamština - díky našim uživatelům! + až bude váš požadavek přijat.]]> + Chat se správci + Chat se členy než se připojí. + Chyba při označení jako přečteno + Otisk adresy cílového serveru neodpovídá certifikátu: %1$s. + Otisk adresy přeposílacího serveru neodpovídá certifikátu: %1$s. + Otisk adresy serveru neodpovídá certifikátu: %1$s. + Chatujte okamžitě po připojení. + požadováno spojení ze skupiny %1$s + požadavek odeslán + Zkontrolovat členy skupiny + Odeslat žádost o kontakt? + Odeslat žádost + Odeslat žádost bez zprávy + Posílání soukromé zpětné vazby do skupin. + Pošlete kontaktu po připojení. + Nastavení bio profilu a uvítací zprávy. + Sdílení staré adresy + Sdílení starého odkazu + Sdílet vaši adresu + Stručný popis: + Krátké SimpleX adresy + Klepněte na Připojte se k chatu + Klepněte na Připojit k odeslání požadavku + Klepněte na Připojit k použití bota + Klepněte na Připojit skupinu + Vypršelo TCP připojení na pozadí + Adresa bude krátká a Váš profil bude sdílen prostřednictvím adresy. + Odkaz bude krátký a profil skupiny bude sdílen prostřednictvím odkazu. + Odesílatel NEBUDE informován. + Toto nastavení je pro váš aktuální profil + Čas mizení, je nastaven pouze pro nové kontakty. + Pro odeslání příkazů musíte být připojen. + Pro použití jiného profilu po pokusu o připojení, smažte chat a znovu použijte odkaz. + Aktualizovat vaši adresu + Povýšit + Povýšit adresu? + Povýšit odkaz skupiny + Povýšit odkaz skupiny? + Uvítací zpráva + Přivítejte vaše kontakty 👋 + Vaše bio: + Váš obchodní kontakt + Váš kontakt + Vaše skupina + Váš profil + Všechny zprávy + Smazat zprávy člena + Smazat zprávy člena? + Smazat zprávy + Soubory + Filtr + Obrázky + Odkazy + Zprávy člena budou smazány - nemůže být zrušeno! + bez předplatného + Odebrat a smazat zprávy + Hledat soubory + Hledat obrázky + Hledat odkazy + Hledat videa + Hledat hlasové zprávy + Videa + Hlasové zprávy + Nejste připojen k serveru, který se používá k přijímání zpráv z tohoto připojení (bez předplatného). + Připojení selhalo + selhal + Pokud jste se připojili k nějakým kanálům nebo je vytvořili, přestanou trvale fungovat. + aktivní + Narodili jste se bez účtu. + Nikdo nesledoval vaše konverzace. Nikdo nevytvořil mapu, kde jste byli. Soukromí nikdy nebylo funkcí - byl to způsob života. + Pak jsme se přesunuli na internet a každá platforma chtěla o vás něco vědět - vaše jméno, vaše číslo, vaše přátele. Smířili jsme se s tím, že cenou za komunikaci s ostatními je dát někomu vědět, s kým mluvíme. Každá generace, lidská i technická, to tak měla - telefon, e-mail, komunikátory, sociální sítě. Zdálo se, že je to jediný možný způsob. + Existuje i jiný způsob. Síť bez telefonních čísel. Bez uživatelských jmen. Bez účtů. Bez jakékoli uživatelské identity. Síť, která spojuje lidi a přenáší šifrované zprávy, aniž by bylo známo, kdo je připojen. + Nejde o to mít lepší zámek na dveřích někoho jiného. Ani o to mít nájemce, který respektuje vaše soukromí, ale vede evidenci všech vašich návštěvníků. Nejste host. Jste doma. Ani král k vám nemůže vstoupit - jste suverén. + Vaše konverzace patří vám, jako tomu bylo vždy před internetem. Síť není místo, které navštěvujete. Je to místo, které vytváříte a vlastníte. A nikdo vám ho nemůže vzít, ať už je soukromé, nebo veřejné. + Nejstarší lidská svoboda - mluvit s druhým člověkem, aniž by byl sledován - postavena na infrastruktuře, která ji nemůže zradit. + Protože jsme zničili sílu vědět, kdo jste. Aby vám vaši moc nikdo nemohl vzít. + Buďte svobodní ve své síti. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index c4f84d4397..38507cc228 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -178,7 +178,7 @@ fejl forbinder Du har forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt. - Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt (fejl: %1$s). + Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt (fejl: %1$s). Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt. slettet markeret som slettet @@ -715,4 +715,150 @@ Verificier sikkerhedsnummer Brug inkognito -profil Send besked + 4 nye interface -sprog + Tillad kun filer og medier, hvis din kontakt tillader dem. + Lad dine kontakter sende filer og medier. + Udseende + App krypterer nye lokale filer (undtagen videoer). + Appikon + Anvende + Ansøg på + App adgangskode + App adgangskode + App-adgangskode erstattes med selvdestruktionsskode. + App -session + App -tema + App værktøjslinjer + Appopdatering er downloadet + App version + App version: v%s + Arabisk, bulgarsk, finsk, hebraisk, thailandsk og ukrainsk - takket være brugerne og Weblate. + Arkiv og upload + Arkivkontakter for at chatte senere. + Arkiverede kontakter + Arkiveringsdatabase + Spørge + forsøg + Lyd- og videoopkald + lydopkald + lydopkald (ikke E2E krypteret) + Lyd fra + Lyd på + Audio & videoopkald + Audio/videoopkald + Audio/videoopkald er forbudt. + Autentificering annulleret + forfatter + Auto-accept + Auto-accept-kontaktanmodninger + Auto-accept-billeder + \nFås i v5.1 + Tilbage + Baggrund + Dårlig skrivebordsadresse + Dårlig besked hash + Dårlig besked hash + Dårligt besked -id + Dårligt besked -id + Beta + Bedre opkald + Bedre grupper + Bedre grupper ydeevne + Bedre meddelelsesdatoer. + Bedre beskeder + Bedre privatlivets fred og sikkerhed + Bedre sikkerhed ✅ + Bedre brugeroplevelse + Bio: + Bio for stor + Sort + Blok + blokeret + Blokeret af admin + blokeret %s + Blok for alle + Blokergruppemedlemmer + Blok medlem + Blok medlem? + Blokermedlem for alle? + Blokermedlem for alle? + Bluetooth + Sløret + Slør for bedre privatliv. + Slør medier + fed + Bot + Både dig og din kontakt kan tilføje meddelelsesreaktioner. + Både dig og din kontakt kan irreversibelt slette sendte beskeder. (24 timer) + Både dig og din kontakt kan foretage opkald. + Både dig og din kontakt kan sende forsvindende beskeder. + Både dig og din kontakt kan sende filer og medier. + Både dig og din kontakt kan sende stemmemeddelelser. + Forretningsadresse + Forretningschats + Med chatprofil (standard) eller ved forbindelse (beta). + Ved at bruge simplex chat accepterer du:\n- Send kun lovligt indhold i offentlige grupper.\n- Respekter andre brugere - ingen spam. + Opkald allerede afsluttet! + Opkald sluttede + Opkald sluttede %1$s + Opkaldsfejl + Ringer … + ring i gang + Ring i gang + Opkald + Opkald på låseskærmen: + Kalder forbudt! + Kamera + Kamera + Kamera og mikrofon + Kamera ikke tilgængeligt + Annuller + Annulleret %s + Annuller linkeksempel + Annuller live -meddelelsen + Annuller migration + Kan ikke få adgang til Keystore for at gemme databaseadgangskode + Kan ikke ringe til kontakten + Kan ikke ringe til gruppemedlem + Kan ikke ændre profil + Kan ikke invitere kontakt! + Kan ikke invitere kontakter! + Kan ikke sende besked til gruppemedlem + Katalansk, indonesisk, rumænsk og vietnamesisk - takket være vores brugere! + med kun en kontakt - Del personligt eller via enhver messenger.]]> + ende-til-ende krypteret med sikkerhed efter kvantet i direkte meddelelser.]]> + for hver chatprofil, du har i appen.]]> + til hvert kontakt- og gruppemedlem.\n Bemærk : Hvis du har mange forbindelser, kan dit batteri og trafikforbrug være væsentligt højere, og nogle forbindelser kan mislykkes.]]> + Tilføj kontakt: Sådan opretter du et nyt invitationslink eller opretter forbindelse via et link, du har modtaget.]]> + Bedst til batteri. Du modtager kun meddelelser, når appen løber (ingen baggrundstjeneste).]]> + Opret gruppe: At oprette en ny gruppe.]]> + godt til batteri . App kontrollerer meddelelser hvert 10. minut. Du kan gå glip af opkald eller presserende beskeder.]]> + Bemærk: Meddelelse og filrelæer er tilsluttet via SOCKS -proxy. Opkald og afsendelse af link -forhåndsvisninger Brug direkte forbindelse.]]> + Bemærk : Brug af den samme database på to enheder vil bryde dekryptering af meddelelser fra dine forbindelser som en sikkerhedsbeskyttelse.]]> + Bemærk : Du vil ikke være i stand til at gendanne eller ændre adgangskode, hvis du mister den.]]> + bruger mere batteri ! App løber altid i baggrunden - underretninger vises øjeblikkeligt.]]> + ADVARSEL : Arkivet slettes.]]> + Forbinde + Forbind automatisk + Opret forbindelse direkte? + tilsluttet + tilsluttet + tilsluttet + Tilsluttet + Forbundet desktop + Forbundet mobil + Forbundet servere + Forbundet til desktop + Forbundet til mobil + Forbind hurtigere! 🚀 + Tilslutning + Forbindelse… + Forbindelse + Forbindelse (accepteret) + Forbindelse (annonceret) + Tilslutning af opkald … + Tilslutning af opkald + Tilslutning (introduceret) + Overfør fra en anden enhed på den nye enhed og scan QR-koden.]]> + Overfør fra en anden enhed diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index db5e983983..8700ade74e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -13,9 +13,9 @@ Verbunden Fehler Verbinde - Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - Beim Versuch, die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %1$s). - Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. + Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + Beim Versuch, die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %1$s). + Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. Gelöscht als gelöscht markiert @@ -28,7 +28,7 @@ verbindung %1$d verbindung hergestellt für eine Verbindung eingeladen - verbinde… + Verbinde… sie haben einen Einmal-Link geteilt sie haben einen Einmal-Link inkognito geteilt über einen Gruppen-Link @@ -77,10 +77,10 @@ Fehler beim Löschen der ausstehenden Kontaktaufnahme Fehler beim Wechseln der Empfängeradresse Der Test ist beim Schritt %s fehlgeschlagen. - Um Warteschlangen zu erzeugen, benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort. - Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig. + Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein. Verbinde - Erzeuge Warteschlange + Warteschlange erstellen Sichere Warteschlange Lösche Warteschlange Trenne Verbindung @@ -172,11 +172,11 @@ Willkommen! Dieser Text ist in den Einstellungen verfügbar. Chats - verbinde … + Verbinde… Sie sind zu der Gruppe eingeladen Beitreten als %s - verbinde … - Zum Starten eines neuen Chats tippen + Verbinde… + Tippen, um einen neuen Chat zu starten Chatten Sie mit den Entwicklern Sie haben keine Chats @@ -469,11 +469,10 @@ Sie entscheiden, wer sich mit Ihnen verbinden kann. Dezentral Jeder kann seine eigenen Server aufsetzen. - Erstellen Sie Ihr Profil + Ihr Profil erstellen Stellen Sie eine private Verbindung her Wie es funktioniert - Wie SimpleX funktioniert SimpleX nutzt individuelle Kennungen für jeden Ihrer Kontakte, um Ihre Privatsphäre zu schützen. Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten. GitHub-Repository mehr dazu.]]> @@ -547,7 +546,7 @@ Privatsphäre App-Bildschirm schützen Bilder automatisch akzeptieren - Link-Vorschau senden + Linkvorschau senden App-Datensicherung MEINE DATEN @@ -695,7 +694,7 @@ Die Gruppeneinladung ist abgelaufen hat %1$s eingeladen. - verbunden + Verbunden hat die Gruppe verlassen änderte die Rolle von %s auf %s änderte Ihre Rolle auf %s @@ -726,13 +725,13 @@ Gruppe gelöscht Eingeladen Verbindung (erstellt) - Verbinde (nach einer Einladung) + Verbindung (nach einer Einladung) Verbindung (angenommen) Verbindung (angekündigt) Verbunden Vollständig Ersteller - verbinden + Verbinde Keine Kontakte zum Hinzufügen Neue Mitgliedsrolle @@ -757,7 +756,7 @@ Gruppe verlassen Gruppenprofil bearbeiten Gruppen-Link - Link erzeugen + Link erstellen Link löschen? Link löschen Sie können diesen Link oder QR-Code teilen – damit kann jede Person der Gruppe beitreten. Wenn Sie den Link später löschen, werden Sie keine Gruppenmitglieder verlieren, die der Gruppe darüber beigetreten sind. @@ -944,7 +943,7 @@ Sicherheits-Gutachten Die Sicherheit von SimpleX Chat wurde von Trail of Bits überprüft. Was ist neu - Administratoren können Links für den Beitritt zu Gruppen erzeugen. + Administratoren können Links für den Beitritt zu Gruppen erstellen. Kontaktanfragen automatisch annehmen Vergleichen Sie die Sicherheitscodes mit Ihren Kontakten. App-Bildschirm in aktuellen Anwendungen verbergen. @@ -1040,7 +1039,7 @@ Für die Anzeige das Passwort im Suchfeld eingeben Privates Profil erzeugen! Stummschalten - Zum Aktivieren des Profils tippen. + Tippen, um das Profil zu aktivieren. Stummschaltung aufheben Bei Inaktivität stummgeschaltet! Schützen Sie Ihre Chat-Profile mit einem Passwort! @@ -1097,7 +1096,7 @@ Host Fehler beim Speichern der XFTP-Server Fehler beim Laden der SMP-Server - Bitte das Passwort überprüfen – für den Upload benötigt der Server eine Berechtigung + Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort. Datei herunterladen Datei vergleichen Datei löschen @@ -1191,20 +1190,20 @@ Sie werden Ihre damit verbundenen Kontakte nicht verlieren, wenn Sie diese Adresse später löschen. Design anpassen INTERFACE-FARBEN - Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet. + Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre SimpleX-Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre SimpleX-Kontakte gesendet. Alle Ihre Kontakte bleiben verbunden. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet. Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können. SimpleX-Adresse erstellen - Mit Kontakten teilen + Mit SimpleX-Kontakten teilen Ihre Kontakte bleiben weiterhin verbunden. Automatisch akzeptieren Geben Sie eine Begrüßungsmeldung ein … (optional) Freunde einladen Lassen Sie uns über SimpleX Chat schreiben - Profil-Aktualisierung wird an Ihre Kontakte gesendet. + Profil-Aktualisierung wird an Ihre SimpleX-Kontakte gesendet. SimpleX-Adress-Einstellungen speichern Einstellungen speichern\? - Die Adresse mit Kontakten teilen\? + Die Adresse mit SimpleX-Kontakten teilen? Teilen beenden Das Teilen der Adresse beenden\? Keine Adresse erstellt @@ -1398,7 +1397,7 @@ Das Senden von Bestätigungen ist für %d Gruppen deaktiviert Das Senden von Bestätigungen ist für %d Gruppen aktiviert Für alle Gruppen deaktivieren - deaktiviert + Deaktiviert Bestätigungen sind deaktiviert Keine Information über die Zustellung Zustellung @@ -1438,7 +1437,7 @@ Datenbank-Ordner öffnen Das Passwort wird in Klartext in den Einstellungen gespeichert, nachdem Sie es geändert oder die App neu gestartet haben. Das Passwort wurde in Klartext in den Einstellungen gespeichert. - Bitte beachten Sie: Die Nachrichten- und Datei-Relais sind per SOCKS-Proxy verbunden. Anrufe und gesendete Link-Vorschaubilder nutzen eine direkte Verbindung.]]> + Bitte beachten Sie: Die Nachrichten- und Datei-Relais sind per SOCKS-Proxy verbunden. Anrufe nutzen eine direkte Verbindung.]]> Lokale Dateien verschlüsseln Öffnen Gespeicherte Dateien & Medien verschlüsseln @@ -1511,7 +1510,7 @@ Fehler bei der Neuverhandlung der Verschlüsselung Neuverhandlung der Verschlüsselung fehlgeschlagen Gruppenmitglieder blockieren - Erstellen Sie eine Gruppe mit einem zufälligen Profil. + Gruppe mit einem zufälligen Profil erstellen. Verbundener Desktop Desktop-Adresse Bessere Gruppen @@ -1607,7 +1606,7 @@ Kontakt hinzufügen Zum Scannen tippen Behalten - Zum Link einfügen tippen + Tippen, um den Link einzufügen Suchen oder SimpleX-Link einfügen Der Chat wurde gestoppt. Wenn diese Datenbank bereits auf einem anderen Gerät von Ihnen verwendet wurde, sollten Sie diese dorthin zurück übertragen, bevor Sie den Chat starten. Chat starten? @@ -1652,8 +1651,8 @@ Private Notizen Es werden alle Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! Private Notizen entfernen? - es wurde %s blockiert - Es wurden %s freigegeben + hat %s blockiert + hat %s freigegeben Sie haben %s blockiert Sie haben %s freigegeben Mitglied für Alle blockieren? @@ -1665,7 +1664,7 @@ Für Alle freigeben Mitglied für Alle freigeben? wurde blockiert - ist vom Administrator blockiert worden + wurde vom Administrator blockiert wurde vom Administrator blockiert Für Alle blockiert Erstellt um @@ -1928,7 +1927,7 @@ Bitte überprüfen Sie, ob sich das Mobiltelefon und die Desktop-App im gleichen lokalen Netzwerk befinden, und die Desktop-Firewall die Verbindung erlaubt. \nBitte teilen Sie weitere mögliche Probleme den Entwicklern mit. Nachricht wurde nicht gesendet - Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt. + Diese Nachricht ist wegen der gewählten Chat-Präferenzen nicht erlaubt. Bitte versuchen Sie es später erneut. Fehler beim privaten Routing Die Nachricht kann später zugestellt werden, wenn das Mitglied aktiv wird. @@ -1971,7 +1970,7 @@ Abgelaufen Server-Einstellungen öffnen Andere Fehler - Proxy + Proxy-vermittelt Fehler beim Empfang Neu verbinden Deaktiviert @@ -1990,7 +1989,7 @@ Daten-Pakete heruntergeladen Verbundene Server Gelöscht - deaktiviert + Deaktiviert Fehler beim Wiederherstellen der Verbindung zum Server Nachricht weitergeleitet Dateien @@ -2001,7 +2000,7 @@ Dateispeicherort öffnen andere Die Server-Adresse ist nicht mit den Netzwerkeinstellungen kompatibel: %1$s. - Link scannen / einfügen + Link einfügen / Scannen Alle Server neu verbinden Server neu verbinden? Alle Server neu verbinden? @@ -2108,7 +2107,6 @@ Erstellen Laden Sie neue Versionen von GitHub herunter. Direkt aus der Chat-Liste abspielen. - Chat-Liste umschalten: Kann von Ihnen in den Erscheinungsbild-Einstellungen geändert werden. Kontakte für spätere Chats archivieren. Ihre IP-Adresse und Verbindungen werden geschützt. @@ -2307,7 +2305,7 @@ Schutz der Privatsphäre Ihrer Kunden. Zur Verbindung aufgefordert Bitte verkleinern Sie die Nachrichten-Größe oder entfernen Sie Medien und versenden Sie diese erneut. - Nur Chat-Eigentümer können die Präferenzen ändern. + Präferenzen können nur von Chat-Eigentümern geändert werden. Bitte verkleinern Sie die Nachrichten-Größe und versenden Sie diese erneut. Die Rolle wird auf %s geändert. Im Chat wird Jeder darüber informiert. Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten. @@ -2447,15 +2445,14 @@ Moderatoren Mitglieder für Alle blockieren? Alle neuen Nachrichten dieser Mitglieder werden nicht angezeigt! - Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden.\n- andere Nutzer zu respektieren - kein Spam. + Sie verpflichten sich dazu:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden.\n- andere Nutzer zu respektieren - kein Spam. Datenschutz- und Nutzungsbedingungen. Annehmen - Server-Betreiber konfigurieren - Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich. + Betreiber verpflichten sich:\n- Unabhängig zu bleiben\n- Metadaten auf ein Minimum zu reduzieren\n- Geprüften Open‑Source‑Code einzusetzen Verbindungs-Link wird nicht unterstützt Verkürzter Link Vollständiger Link - SimpleX-Kanal-Link + SimpleX-Kanallink Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden. Alle Server Aus @@ -2500,7 +2497,7 @@ Sie haben die Gruppe verlassen Kontakt deaktiviert Nicht synchronisiert - Fehler beim Löschen des Chats mit dem Mitglied + Fehler beim Löschen des Chats Kontakt nicht bereit Sie können keine Nachrichten senden! Chat löschen @@ -2605,6 +2602,275 @@ Link-Tracking entfernen Verbinden tippen, um den Bot zu nutzen. Um Befehle senden zu können, müssen Sie verbunden sein. - SimpleX Relais-Link - Fehler beim Versuch, den Chat mit einem Mitglied als gelesen zu markieren + SimpleX Relais-Adresse + Fehler beim Markieren als gelesen + Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Kein Abonnement + Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement). + Mitgliedsnachrichten löschen + Mitgliedsnachrichten löschen? + Mitgliedsnachrichten löschen + Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden! + Mitglied entfernen und Nachrichten löschen + Alle Nachrichten + Dateien + Filter + Bilder + Links + Dateien suchen + Bilder suchen + Links suchen + Videos suchen + Sprachnachrichten suchen + Videos + Sprachnachrichten + VERBINDUNG FEHLGESCHLAGEN + Fehlgeschlagen + Kanäle, welche Sie erstellt haben oder denen Sie beigetreten sind, werden dauerhaft deaktiviert. + %1$d/%2$d Relais aktiv + %1$d/%2$d Relais aktiv, %3$d fehlgeschlagen + %1$d/%2$d Relais verbunden + %1$d/%2$d Relais verbunden, %3$d Fehler + %1$d Abonnent + %1$d Abonnenten + Angenommen + Aktiv + Abonnent für alle blockieren? + Broadcast + Kanalerstellung abbrechen? + Relais testen, um dessen Namen abzurufen.]]> + %1$s!]]> + Kanal + Kanal + Kanal + Kanallink + Kanal-Mitglieder + Kanalname + Der Kanal wird für alle Abonnenten gelöscht. Dies kann nicht rückgängig gemacht werden! + Der Kanal wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden! + Der Kanal wird mit %1$d von %2$d Relais gestartet. Fortfahren? + Chat-Relais + Chat-Relais + Chat-Relais + Chat-Relais + Chat‑Relais leiten Nachrichten in den von Ihnen erstellten Kanälen weiter. + Chat‑Relais leiten Nachrichten an Kanal-Abonnenten weiter. + Relais-Adresse überprüfen und erneut versuchen. + Relais-Name überprüfen und erneut versuchen. + Relais konfigurieren + Verbinden + Verbunden + Verbinde + Öffentlichen Kanal erstellen + Öffentlichen Kanal erstellen + Öffentlichen Kanal erstellen (BETA) + Kanal wird erstellt + Link dekodieren + Kanal löschen + Kanal löschen? + Gelöscht + Relais löschen + Kanalprofil bearbeiten + Aktiviere mindestens ein Chat‑Relais, um einen Kanal zu erstellen. + Relais-Name eingeben… + Fehler beim Hinzufügen des Relais + Fehler beim Erstellen des Kanals + Fehler beim Öffnen des Kanals + Fehlgeschlagen + Fehlgeschlagen + Link erhalten + Ungültige Relais-Adresse! + Ungültiger Relais-Name! + Eingeladen + Kanal beitreten + Kanal verlassen + Kanal verlassen? + Link + Neu + Neues Chat-Relais + Keine Chat-Relais + Es sind keine Chat-Relais aktiviert. + Es sind nicht alle Relais verbunden + Kanal öffnen + Neuen Kanal öffnen + EIGENTÜMER + Eigentümer + Voreingestellte Relais-Adresse + Voreingestellter Relais-Name + Relais + RELAIS + Relais-Adresse + Relais-Adresse + Relais-Verbindung fehlgeschlagen + Relais-Link + Relais-Test fehlgeschlagen! + Abonnent entfernen + Abonnent entfernen? + Der Server erfordert eine Autorisierung, um eine Verbindung zum Relais herzustellen. Bitte Passwort überprüfen. + Serverwarnung + Relais-Adresse teilen + ABONNENT + Abonnenten + Abonnenten verbinden sich über den Relais‑Link mit dem Kanal.\nDie Relais-Adresse wurde zur Einrichtung dieses Relais für diesen Kanal verwendet. + Abonnent wird aus dem Kanal entfernt. Dies kann nicht rückgängig gemacht werden! + Tippen, um dem Kanal beizutreten + Der Test ist bei Schritt %s fehlgeschlagen. + Relais testen + Dies ist eine Chat‑Relais-Adresse, welche nicht zum Verbinden verwendet werden kann. + Abonnent für alle freigeben? + Für neue Kanäle verwenden + Relais nutzen + Überprüfen + via %1$s + Sprachaufnahmen werden auf Ihrer Plattform nicht unterstützt. + Abwarten + Antwort abwarten + Sie + Sie sind Abonnent + Sie können einen Link oder QR-Code teilen - damit kann jeder dem Kanal beitreten. + Sie haben sich über diesen Relais‑Link mit dem Kanal verbunden. + Ihr Kanal + Ihr Kanal + Ihr Profil %1$s wird mit den Kanal‑Relais und -Abonnenten geteilt.\nRelais können auf Kanalnachrichten zugreifen. + Ihre Relais-Adresse + Ihr Relais-Name + Sie werden keine Nachrichten mehr aus diesem Kanal erhalten. Der Chatverlauf bleibt erhalten. + Vollständiger Name des Kanals: + Das Kanalprofil wird auf den Geräten der Abonnenten und auf den Chat‑Relais gespeichert. + Kanalprofil wurde aktualisiert + %d Kanalereignisse + Kanal gelöscht + Verworfen (%1$d Versuche) + Fehler: %s + Fehler beim Speichern des Kanalprofils + Übertragungsfehler + Speichern und Abonnenten des Kanals informieren + Kanalprofil speichern + Die App hat diese Nachricht nach %1$d Empfangsversuchen entfernt. + Kanalprofil aktualisiert + %1$d/%2$d Relais aktiv, %3$d Fehler + %1$d/%2$d Relais aktiv, %3$d entfernt + %1$d/%2$d Relais verbunden, %3$d fehlgeschlagen + %1$d/%2$d Relais verbunden, %3$d entfernt + %1$d Relais fehlgeschlagen + %1$d Relais nicht aktiv + %1$d Relais entfernt + Das Hinzufügen von Relais wird zu einem späteren Zeitpunkt unterstützt. + Alle Relais fehlgeschlagen + Alle Relais entfernt + Broadcast nicht möglich + Der Kanal hat keine aktiven Relais. Bitte später erneut versuchen. + Der Kanal ist vorübergehend nicht erreichbar + Inaktiv + Keine aktiven Relais + Vom Betreiber entfernt + Warte auf das Hinzufügen von Relais durch den Eigentümer des Kanals. + Geschäftliche Adresse + Kanallink + Kontaktadresse + Deaktivieren + Aktivieren + Linkvorschau aktivieren? + Fehler + Fehler beim Teilen des Kanals + (vom Eigentümer) + Gruppen-Link + Linksignatur erfolgreich überprüft. + Netzwerk-Fehler + Einmal-Link + Relay‑Status: + Das Senden einer Link-Vorschau kann Ihre IP‑Adresse an die Website übermitteln. Sie können dies später in den Datenschutzeinstellungen ändern. + Kanal teilen… + Per Chat teilen + ⚠️ Signaturüberprüfung fehlgeschlagen: %s. + (signiert) + Zum Öffnen tippen + Die Verbindung hat das Limit für nicht zugestellte Nachrichten erreicht + Kanal-Präferenzen + Kanal-Präferenzen können nur von Kanal-Eigentümern geändert werden. + Verbindungs-Link für eine Person + Kanäle + Über einen Link oder QR-Code verbinden + Ihren Link erstellen + Ihre öffentliche Adresse erstellen + Freunde einladen – jetzt noch einfacher 👋 + Damit Sie jeder erreichen kann + Für privaten Chat einladen + Jemand mit Ihnen verbinden lassen + Neuer Einmal-Link + Non‑Profit‑Governance + - Opt‑in zum Senden von Linkvorschauen.\n- SOCKS-Proxy verwenden, falls aktiviert.\n- Hyperlink‑Phishing verhindern.\n- Link‑Tracking entfernen. + Oder den QR‑Code persönlich oder per Videoanruf zeigen. + Oder diesen QR‑Code verwenden – ausgedruckt oder online. + Volle Kontrolle: Sie können Ihre eigenen Relais betreiben. + Privatsphäre: für Besitzer und Abonnenten. + Öffentliche Kanäle – frei sprechen 🚀 + Zuverlässigkeit: mehrere Relais pro Kanal. + Sichere Web-Links + Sicherheit: Eigentümer besitzen die Kanalschlüssel. + Den Link über einen beliebigen Messenger versenden – es ist sicher. Bitte in SimpleX einfügen. + Mit Jemandem sprechen + Für ein dauerhaftes SimpleX-Netzwerk. + Diese Adresse in Ihrem Social‑Media‑Profil, auf Ihrer Webseite oder in Ihrer E‑Mail‑Signatur verwenden. + Wir haben das Verbinden für neue Nutzer vereinfacht. + Ihre öffentliche Adresse + Sie wurden ohne eine Benutzerkennung geboren. + Niemand verfolgte Ihre Gespräche. Niemand erstellte eine Karte, wo Sie sich aufgehalten haben. Privatsphäre war nie ein Feature - sie war selbstverständlich. + Dann sind wir online gegangen, und jede Plattform wollte Etwas von Ihnen - Ihren Namen, Ihre Nummer, Ihre Freunde. Wir akzeptierten, dass es der Preis mit Anderen zu kommunizieren ist, Jemandem preiszugeben, mit wem und wie wir miteinander kommunizieren. Jede Generation, Menschen und Technologien, kannten es nur so - Telefon, E-Mail, Messenger, soziale Medien. Es schien der einzig mögliche Weg zu sein. + Es gibt einen anderen Weg. Ein Netzwerk ohne Telefonnummern, ohne Benutzernamen, ohne Benutzerkennungen und ohne jegliche Benutzeridentität. Ein Netzwerk, welches Menschen verbindet und verschlüsselte Nachrichten überträgt, ohne zu wissen, wer mit wem verbunden ist. + Nicht ein besseres Schloss an der Tür eines Anderen. Kein freundlicher Vermieter, der Ihre Privatsphäre respektiert, aber dennoch jeden Besucher registriert. Sie sind kein Gast. Sie sind zu Hause. Kein Vermieter, kein Fremder kann es betreten - Sie sind souverän. + Ihre Kommunikation gehört Ihnen, so wie es immer war, bevor es das Internet gab. Das Netzwerk ist kein Ort, den Sie besuchen. Es ist ein Ort, den Sie erschaffen und besitzen und Niemand kann es Ihnen nehmen, egal ob Sie es privat oder öffentlich machen. + Die älteste Freiheit des Menschen - mit einem anderen Menschen sprechen zu können, ohne beobachtet zu werden - gestützt auf einer Infrastruktur, die Sie nicht verraten kann. + Weil wir die Macht zerstört haben, zu wissen, wer Sie sind. Damit Ihnen Ihre Macht niemals genommen werden kann. + Genießen Sie die Freiheit in Ihrem Netzwerk. + + Abonnenten-Meldungen + Das Senden von Direktnachrichten an Abonnenten erlauben. + Das Senden von Direktnachrichten an Abonnenten nicht erlauben. + Bis zu 100 der letzten Nachrichten an neue Abonnenten senden. + Den Nachrichtenverlauf nicht an neue Abonnenten senden. + Abonnenten können verschwindende Nachrichten versenden. + Abonnenten können Direktnachrichten versenden. + Direktnachrichten zwischen Abonnenten sind nicht erlaubt. + Abonnenten können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden) + Abonnenten können eine Reaktion auf Nachrichten geben. + Abonnenten können Sprachnachrichten versenden. + Abonnenten können Dateien und Medien versenden. + Abonnenten können SimpleX-Links versenden. + Abonnenten können Nachrichten an Moderatoren melden. + Bis zu 100 der letzten Nachrichten werden an neue Abonnenten gesendet. + Der Nachrichtenverlauf wird nicht an neue Abonnenten gesendet. + Mitgliedern den Chat mit Administratoren erlauben. + Abonnenten den Chat mit Administratoren erlauben. + Seien Sie frei\nin Ihrem Netzwerk + Chats mit Administratoren sind nicht erlaubt. + Chats mit Administratoren in öffentlichen Kanälen sind nicht Ende‑zu‑Ende‑verschlüsselt – bitte nur über vertrauenswürdige Chat‑Relais nutzen. + Chats mit Mitgliedern sind deaktiviert + Chat mit Administratoren + Aktivieren + Chats mit Administratoren aktivieren? + Geben Sie einen Profilnamen ein… + Jetzt starten + Mitglieder können mit Administratoren chatten. + nicht Ende‑zu‑Ende‑verschlüsselt. Chat‑Relais können sie einsehen.]]> + Migrieren + Netzwerk‑Verpflichtungen + Netzwerk‑Router können nicht erkennen,\nwer mit wem kommuniziert + Kein Account. Keine Telefonnummer. Keine E‑Mail. Keine ID.\nDie sicherste Verschlüsselung. + Auf Ihrem Gerät, nicht auf Servern. + Externen Link öffnen? + Private und sichere Kommunikation. + Chat mit Administratoren nicht erlauben. + Benachrichtigungen einrichten + Router einrichten + Abonnenten können mit Administratoren chatten. + Das erste Netzwerk,\nin dem Sie Ihre Kontakte und Gruppen besitzen. + Warum SimpleX entwickelt wurde. + Ihr Netzwerk + Ihr Profil + Untere Leiste + Die Linkvorschau wird über einen SOCKS-Proxy angefordert. DNS-Abfragen können dennoch lokal über Ihren DNS-Resolver erfolgen. + Obere Leiste diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 2d27bb3592..47cfd90ad6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -1,39 +1,39 @@ - 1 μέρα + 1 ημέρα 1 μήνας Για το SimpleX - Σαρώστε τον QR κωδικό + Σάρωσε τον QR κωδικό α + β Για το SimpleX Chat - Σαρώστε τον κωδικό ασφαλείας από την εφαρμογή επαφών σας + Σάρωσε τον κωδικό ασφαλείας από την εφαρμογή επαφών σου Ασφαλή ουρά δε Κωδικός ασφαλείας - Σαρώστε τον κωδικό QR διακομιστή + Σάρωσε τον QR κωδικό του διακομιστή μυστικό 1 εβδομάδα αξιολόγηση ασφαλείας - Συναινώ + Επέτρεψε Αποδοχή Αποδοχή ανώνυμης περιήγησης Προσθήκη προκαθορισμένου διακομιστή Προσθήκη σε άλλη συσκευή - Όλες οι επαφές σας θα παραμείνουν ενεργές. + Όλες οι επαφές σου θα παραμείνουν ενεργές. Αποδοχή διαχειριστής - Προσθέστε μήνυμα καλωσορίσματος + Πρόσθεσε μήνυμα καλωσορίσματος Όλα τα μέλη της ομάδας θα παραμήνουν συνδεδεμένα. Προσθήκη προφίλ - Προφορά + Χρώμα έμφασης πάντα Αποδοχή - Επιτρέψτε τα μηνύματα που εξαφανίζονται μόνο εάν το επιτρέπει η επαφή σας. - Επιτρέψτε στις επαφές σας να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. - Επιτρέψτε στις επαφές σας να στέλνουν μηνύματα που εξαφανίζονται. - Επιτρέπονται τα φωνητικά μηνύματα μόνο εάν τα επιτρέπει η επαφή σας. - Επιτρέψτε στις επαφές σας να σας καλέσουν. - Επιτρέψτε στις επαφές σας να στέλνουν φωνητικά μηνύματα. + Επιτρέψτε τα μηνύματα που εξαφανίζονται μόνο εάν το επιτρέπει η επαφή σου. + Επέτρεψε στις επαφές σου να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. (24 ώρες) + Επέτρεψε στις επαφές σου να στέλνουν μηνύματα που εξαφανίζονται. + Επέτρεψε τα φωνητικά μηνύματα μόνο εάν τα επιτρέπει η επαφή σου. + Επέτρεψε στις επαφές σου να σε καλέσουν. + Επέτρεψε στις επαφές σου να στέλνουν φωνητικά μηνύματα. Να επιτρέπεται η αποστολή άμεσων μηνυμάτων στα μέλη. Επιτρέπεται η αποστολή μηνυμάτων που εξαφανίζονται. Επιτρέπεται η αποστολή φωνητικών μηνυμάτων. @@ -41,70 +41,69 @@ Αποδοχή Αποδοχή αιτήματος σύνδεσης; αποδεκτή κλήση - Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. + Πρόσβαση στους διακομιστές μέσω διαμομιστή μεσολάβησης SOCKS στη θύρα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. Προσθήκη διακομιστή Προχωρημένες ρυθμίσεις δικτύου Προσθήκη διακομιστών μέσω σάρωσης QR κωδικών. Οι διαχειριστές μπορούν να δημιουργήσουν τους συνδέσμους συμμετοχής σε ομάδες. Όλες οι συνομιλίες και τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Όλα τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Τα μηνύματα θα διαγραφούν ΜΟΝΟ για εσάς. - Επιτρέψτε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν σας το επιτρέπει η επαφή σας. - Επιτρέπονται οι κλήσεις μόνο εάν η επαφή σας τις επιτρέπει. - Επιτρέψτε τη μη αναστρέψιμη διαγραφή των απεσταλμένων μηνυμάτων. + Επέτρεψε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν το επιτρέπει η επαφή σου. (24 ώρες) + Επιτρέπονται οι κλήσεις μόνο εάν η επαφή σου τις επιτρέπει. + Επιτρέψτε τη μη αναστρέψιμη διαγραφή των απεσταλμένων μηνυμάτων. (24 ώρες) Να επιτρέπονται τα φωνητικά μηνύματα; Πάντα ενεργό Να χρησιμοποιείται πάντα αναμεταδότη - Αναζήτηση + Αναζήτησε Ανενεργό - "Το προφίλ σας %1$s θα μοιραστεί" - Η SimpleX διεύθυνση σας + Το προφίλ σου %1$s θα διαμοιραστεί + Η SimpleX διεύθυνση σου Αντίγραφο δεδομένων εφαρμογής 5 λεπτά - Θα συνδεθείτε όταν η συσκευή της επαφής σας είναι συνδεμένει, παρακαλώ περιμένετε ή ελέγξτε αργότερα! - Ο ICE διακομιστής σας + Θα συνδεθείς όταν η συσκευή της επαφής σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Ο ICE διακομιστής σου Έκδοση εφαρμογής - Στείλατε πρόσκληση ομάδας + Έστειλες πρόσκληση ομάδας 1 λεπτό - Ο διακομιστής σας + Ο διακομιστής σου Διεύθυνση Ακύρωση Πίσω 30 δευτερόλεπτα - Θα συνδεθείτε με όλα τα μέλη της ομάδας. + Θα συνδεθείς με όλα τα μέλη της ομάδας. %1$s θέλει να συνδεθεί μαζί σου μέσω - Επιτρέπονται αντιδράσεις μηνύματος. - Ο διακομιστής XFTP σας - Η διεύθυνση του διακομιστή σας - Το προφίλ, επαφές και παραδομένα μηνύματα σας είναι αποθηκευμένα στην συσκευή σας. - Ο διακομιστής SMP σας + Επέτρεψε τις αντιδράσεις σε μηνύματα. + Ο διακομιστής XFTP σου + Η διεύθυνση του διακομιστή σου + Το προφίλ, επαφές και παραδομένα μηνύματα σου είναι αποθηκευμένα στην συσκευή σου. + Ο διακομιστής SMP σου Επιτρέπεται να σταλούν αρχεία και μέσα. - Το τυχαίο προφίλ σας + Το τυχαίο προφίλ σου %1$s ΜΕΛΗ - Επιτρέπεται - Οι προτιμήσεις σας + Αποδοχή + Οι προτιμήσεις σου Συντάκτης - Ο ΙCE διακομιστής σας - Κωδικός εφαρμογής + Ο ΙCE διακομιστής σου + Κωδικός πρόσβασης εφαρμογής Αίτημα σύνδεσης θα σταλεί σε αυτό το μέλος της ομάδας. ΟΙΚΟΝΑ ΕΦΑΡΜΟΓΗΣ Εφαρμογή - Οι ρυθμίσεις σας + Οι ρυθμίσεις σου Έκδοση εφαρμογής: v%s σφάλμα κλήσης - "ακυρώθηκε %s" + ακυρώθηκε %s ακύρωση πρόβλεψη συνδέσμου - Αλλαγή κωδικού πρόσβασης βάση δεδομένων? + Αλλαγή φράσης πρόσβασης της βάσης δεδομένων? Αλλαγή κωδικού πρόσβασης Αλλαγή ρόλου του %s σε %s Φόντο Δεν είναι δυνατή η προετοιμασία της βάσης δεδομένων - Ένα νέο τυχαίο προφίλ θα μοιραστεί. + Ένα νέο τυχαίο προφίλ θα διαμοιραστεί. Δεν είναι δυνατή η πρόσκληση επαφών! Αλλαγή διεύθυνσης λήψης Πιστοποίηση μη διαθέσιμη - Αλλαγή - " -\nΔιαθέσιμο στην έκδοση 5.1" + Άλλαξε + \nΔιαθέσιμο στην έκδοση 5.1 Τέλος κλήσης ΚΛΗΣΕΙΣ Αυτόματη αποδοχή @@ -126,25 +125,25 @@ Όλα τα δεδομένα της εφαρμογής διαγράφηκαν. Εμφάνιση Τέλος κλήσης %1$s - Ακύρωση + Ακύρωσε Αλλαγή διεύθυνσης λήψης; αλλαγή διεύθυνσης… Αλλαγή λειτουργίας αυτοκαταστροφής Κάμερα κλήση σε εξέλιξη Αυτόματη αποδοχή εικόνων - Αλλαγή του ρόλου σας σε %s + Αλλαγή του ρόλου σου σε %s Κλήση σε εξέλιξη Πιστοποίηση απέτυχε - "σύνδεση %1$d" + σύνδεση %1$d Δημιουργία διεύθυνση SimpleX - επαφή έχει κρυπτογράφηση από άκρο σε άκρο + η επαφή έχει κρυπτογράφηση από άκρη-σε-άκρη Δημιουργία μια ομάδας χρησιμοποιώντας ένα τυχαίο προφίλ. Δημιουργία ομάδας Δημιουργία προφίλ Η επαφή και όλα τα μηνύματα θα διαγραφούν - αυτό δεν μπορεί να αναιρεθεί! Δημιουργία προφίλ - Οι επαφές μπορούν να επισημάνουν μηνύματα προς διαγραφή; θα μπορείτε να τα δείτε. + Οι επαφές μπορούν να επισημάνουν μηνύματα προς διαγραφή, τα οποία θα μπορείς να τα δεις. Σύνδεση μέσω μιας εφάπαξ σύνδεσης; Δημιουργία σύνδεσμου Σύνδεση μέσω σύνδεσμο/κωδικό γρήγορης ανταπόκρισης @@ -153,11 +152,11 @@ συνδέεται… Όνομα επαφής Δημιουργία διεύθυνσης - Αντιγραφή + Αντέγραψε Συνέχεια Σύνδεση μέσω σύνδεσμο; Επαφή υπάρχει ήδη - Σύνδεση στον εαυτό σας; + Σύνδεση στον εαυτό σου; Δημιουργία μυστικής ομάδας Συνδεδεμένη σε επιφάνεια εργασίας δημιουργός @@ -180,8 +179,8 @@ Συνδε Σύνδεση ανώνυμης περιήγησης σύνδεση επετεύχθη - επαφή δεν έχει κρυπτογράφηση από άκρο σε άκρο - Επαφή επιτρέπει + η επαφή δεν έχει κρυπτογράφηση από άκρη-σε-άκρη + Η επαφή επιτρέπει σύνδεση (ανακοινώθηκε) Συνδεδεμένος συνδέεται… @@ -194,29 +193,29 @@ Δημιουργία μυστικής ομάδας Σφάλμα σύνδεσης Η επαφή δεν είναι συνδράμει αυτή τη στιγμή! - Συνδεδεμένο απευθείας - Η ιδιωτικότητά σας - Η επαφή σας έστειλε ένα αρχείο το οποίο είναι μεγαλύτερο από το παρόν υποστηριζόμενο μέγεθος (%1$s). - Το προφίλ της συνομιλίας σας θα σταλεί στην επαφή σας + αιτούμενη σύνδεση + Η ιδιωτικότητά σου + Η επαφή σου έστειλε ένα αρχείο το οποίο είναι μεγαλύτερο από το παρόν υποστηριζόμενο μέγεθος (%1$s). + Το προφίλ της συνομιλίας σου θα σταλεί\nστην επαφή σου Χρήση νέου ανώνυμου προφίλ Ήδη συνδέεται - Απορρίψατε την πρόσκληση της ομάδας + Απέρριψες την πρόσκληση της ομάδας Σύνδεση μέσω διεύθυνση επαφής - Χρήση του τρέχων προφίλ - Σύνδεση - Το τρέχον προφίλ σας - αφαιρέσατε %1$s - "Συμμετοχή ομάδας;" - Οι επαφες σας θα παραμένουν συνδεδεμένες. - Προσπάθεια σύνδεσης με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν την επαφή. + Χρήση του τρέχοντος προφίλ + Συνδέσου + Το τρέχον προφίλ σου + αφαίρεσες %1$s + Συμμετοχή ομάδας; + Οι επαφες σου θα παραμένουν συνδεδεμένες. + Προσπάθεια σύνδεσης με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν τη σύνδεση. Το προφίλ σου θα σταλεί στην επαφή από την οποία έλαβες αυτόν τον σύνδεσμο. σφάλμα Άνοιγμα βάση δεδομένων… Η προβολή συνετρίβη συνδεδεμένο - Κοινοποιήσατε μια μη έγκυρη διαδρομή αρχείου. Αναφέρετε το πρόβλημα στους προγραμματιστές της εφαρμογής. + Κοινοποίησες μία μη έγκυρη διαδρομή αρχείου. Ανέφερε το πρόβλημα στους προγραμματιστές της εφαρμογής. Μη έγκυρη διαδρομή αρχείου - Είστε συνδεδεμένοι στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν την επαφή. + Είσαι συνδεδεμένος στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν τη σύνδεση. %d μύνημα επισημάνθηκε ως διαγραμμένο %1$d μυνήματα συντονίζονται από %2$s επισημάνθηκε ως διαγραμμένο @@ -230,22 +229,22 @@ Η αλλαγή διεύθυνσης θα ακυρωθεί. Θα χρησιμοποιηθεί η παλιά διεύθυνση παραλαβής. Ενεργές συνδέσεις Προχωρημένες ρυθμίσεις - Πρόσθετη προφορά + Επιπρόσθετο χρώμα έμφασης Προσθήκη επαφής - Διακοπή αλλαγής διεύθυνσης + Ακύρωση αλλαγής διεύθυνσης Προχωρημένες ρυθμίσεις Οι διαχειριστές μπορούν να αποκλείσουν ένα μέλος για όλους. Αναγνωρισμένο παραπάνω, λοιπόν: - Προσθέστε τη διεύθυνση στο προφίλ σας, έτσι ώστε οι επαφές σας να μπορούν να τη μοιραστούν με άλλα άτομα. Το ενημέρωμένο προφίλ θα σταλεί στις επαφές σας. + Πρόσθεσε τη διεύθυνση στο προφίλ σου, έτσι ώστε οι επαφές σου να μπορούν να τη διαμοιραστούν με άλλα άτομα. Το ενημέρωμένο σου προφίλ θα αποσταλεί στις επαφές σου. διαχειριστές Λάθη αναγνώρισης - Προειδοποίηση: το αρχείο θα διαγραφεί.]]> + Προειδοποίηση: το αρχείο αρχειοθέτησης θα διαγραφεί.]]> Υπέρβαση χωρητικότητας - ο παραλήπτης δεν έλαβε μηνύματα που στάλθηκαν προηγουμένως. αποκλεισμένος από τον διαχειριστή Συνομιλίες όλα τα μέλη - Όλες οι επαφές σας θα παραμείνουν ενεργές. Το ανανεωμένο προφίλ σας θα αποσταλεί στις επαφές σας. + Όλες οι επαφές σου θα παραμείνουν ενεργές. Το ανανεωμένο προφίλ σου θα αποσταλεί στις επαφές σου. Να χρησιμοποιείται πάντα ιδιωτική δρομολόγηση. Ένα κενό προφίλ συνομιλίας με το παρεχόμενο όνομα δημιουργείται και η εφαρμογή ανοίγει ως συνήθως. Η βάση δεδομένων της συνομιλίας διαγράφηκε @@ -263,7 +262,7 @@ Η εφαρμογή κρυπτογραφεί νέα τοπικά αρχεία (εκτός απο βίντεο). Καλύτερες ομάδες Γίνεται ήδη συμμετοχή στην ομάδα! - Αρχειοθέτηση και αποστολή + Αρχειοθέτηση και ανέβασμα %1$d διαφορετικό/κα σφάλμα/τα αρχείου/ων. Η υπηρεσία παρασκηνίου λειτουργεί πάντα - οι ειδοποιήσεις θα εμφανίζονται μόλις τα μηνύματα είναι διαθέσιμα. %1$d αρχείο/α ακόμα κατεβαίνουν. @@ -272,10 +271,10 @@ %1$d αρχείο/α δεν κατέβηκε/καν. %1$s μήνυμα/τα δεν προωθήθηκε/καν Προφίλ συνομιλίας - για κάθε προφίλ συνομιλίας που έχετε στην εφαρμογή.]]> - Παρακαλώ σημειώστε: οι αναμεταδότες μηνυμάτων και αρχείων συνδέονται μέσω διακομιστή μεσολάβησης SOCKS. Οι κλήσεις και οι προεπισκοπήσεις συνδέσμων αποστολής χρησιμοποιούν άμεση σύνδεση.]]> + για κάθε προφίλ συνομιλίας που έχεις στην εφαρμογή.]]> + Παρακαλώ σημείωσε: οι αναμεταδότες μηνυμάτων και αρχείων συνδέονται μέσω διακομιστή μεσολάβησης SOCKS. Οι κλήσεις και οι προεπισκοπήσεις συνδέσμων αποστολής χρησιμοποιούν άμεση σύνδεση.]]> Πάντα - Η ενημέρωση της εφαρμογής κατεβαίνει + Η ενημέρωση της εφαρμογής κατέβηκε Έλεγχος για ενημερώσεις Οποιοσδήποτε μπορεί να φιλοξενήσει διακομιστές. κλήση ήχου (χωρίς κρυπτογράφηση e2e) @@ -291,21 +290,21 @@ Χρώματα συνομιλίας ΒΑΣΗ ΔΕΔΟΜΕΝΩΝ ΣΥΝΟΜΙΛΙΑΣ Η συνομιλία εκτελείται - Παρακαλώ σημειώστε: ΔΕΝ θα μπορείτε να ανακτήσετε ή να αλλάξετε τη φράση πρόσβασης εάν τη χάσετε.]]> + Παρακαλώ σημείωσε: ΔΕΝ θα μπορείς να ανακτήσεις ή να αλλάξεις τη φράση πρόσβασης εάν τη χάσεις.]]> Αποκλεισμός για όλους - Και εσείς και η επαφή σας μπορείτε να προσθέστε αντιδράσεις μηνυμάτων. - Και εσείς και η επαφή σας μπορείτε να κάνετε κλήσεις. + Και εσύ και η επαφή σου μπορείτε να προσθέστε αντιδράσεις μηνυμάτων. + Και εσύ και η επαφή σου μπορείτε να κάνετε κλήσεις. Επιτρέψτε την αποστολή συνδέσμων SimpleX. Αραβικά, Βουλγαρικά, Φινλανδικά, Εβραϊκά, Ταϊλανδέζικα και Ουκρανικά - χάρη στους χρήστες και το Weblate. Μεταφορά δεδομένων εφαρμογής Θάμπωμα για καλύτερη ιδιωτικότητα. Η συνομιλία έχει μεταφερθεί! Αρχειοθέτηση της βάσης δεδομένων - Όλες οι επαφές, συζητήσεις και αρχεία θα κρυπτογραφηθούν με ασφάλεια και θα μεταφορτωθούν σε διαμορφωμένα κομμάτια αναμετάδοσης XFTP. + Όλες οι επαφές, οι συζητήσεις και τα αρχεία θα κρυπτογραφηθούν με ασφάλεια και θα μεταφορτωθούν τμηματικά σε διαμορφωμένους αναμεταδότες XFTP. Κινητή τηλεφωνία - Δημιουργία ομάδας : για την δημιουργίας νέας ομάδας.]]> - Ελέγξτε τη σύνδεσή σας στο διαδίκτυο και δοκιμάστε ξανά - Συζήτηση με τους προγραμματιστές + Δημιουργία ομάδας : για να δημιουργήσεις μία νέα ομάδα.]]> + Έλεγξε τη σύνδεσή σου στο διαδίκτυο και δοκίμασε ξανά + Συνομίλησε με τους προγραμματιστές Ζήτησε να λάβει το βίντεο Δεν είναι δυνατή η αποστολή μηνυμάτων στο μέλος της ομάδας Αλλαγή λειτουργίας κλειδώματος @@ -313,16 +312,16 @@ άλλαξε η διεύθυνση για εσάς και %d άλλες εκδηλώσεις Μαύρο - Πρόσθετο δευτερεύον - Και εσείς και η επαφή σας μπορείτε να διαγράψετε απεσταλμένα μηνύματα χωρίς ανατροπή. (24 ώρες) - Και εσείς και η επαφή σας μπορείτε να στείλετε ηχητικά μηνύματα. + Επιπρόσθετο δευτερεύων + Και εσύ και η επαφή σου μπορείτε να διαγράψετε απεσταλμένα μηνύματα χωρίς ανατροπή. (24 ώρες) + Και εσύ και η επαφή σου μπορείτε να στείλετε ηχητικά μηνύματα. Η συνομιλία σταμάτησε - Η συνομιλία έχει διακοπεί. Εάν χρησιμοποιήσατε ήδη αυτήν τη βάση δεδομένων σε άλλη συσκευή, θα πρέπει να τη μεταφέρετε πίσω προτού ξεκινήσετε τη συνομιλία. - Η λειτουργία βελτιστοποίησης της μπαταρίας είναι ενεργή, η υπηρεσία παρασκηνίου και τα περιοδικά αιτήματα για νέα μηνύματα θα απενεργοποιηθούν. Μπορείτε να τα ενεργοποιήσετε ξανά μέσω των ρυθμίσεων. - σύνδεσμος μιας χρήσης + Η συνομιλία έχει διακοπεί. Εάν ήδη χρησιμοποίησες αυτήν τη βάση δεδομένων σε άλλη συσκευή, θα πρέπει να τη μεταφέρεις πίσω προτού ξεκινήσεις τη συνομιλία. + Η λειτουργία βελτιστοποίησης της μπαταρίας είναι ενεργή, η υπηρεσία παρασκηνίου και τα περιοδικά αιτήματα για νέα μηνύματα θα απενεργοποιηθούν. Μπορείς να τα ενεργοποιήσεις ξανά μέσω των ρυθμίσεων. + σύνδεσμος 1-χρήσης Κλήσεις ήχου & βίντεο Κλήσεις ήχου/βίντεο - Κωδικός εφαρμογής + Κωδικός πρόσβασης εφαρμογής Συνεδρία εφαρμογής Η συνομιλία σταμάτησε Έλεγχος για ενημερώσεις @@ -331,47 +330,47 @@ Bluetooth έντονο Κονσόλα συνομιλίας - Παρακαλώ σημειώστε: η χρήση της ίδιας βάσης δεδομένων σε δύο συσκευές θα διακόψει την αποκρυπτογράφηση των μηνυμάτων από τις συνδέσεις σας, ως προστασία ασφαλείας.]]> + Παρακαλώ σημείωσε: η χρήση της ίδιας βάσης δεδομένων σε δύο συσκευές θα διακόψει την αποκρυπτογράφηση των μηνυμάτων από τις συνδέσεις σου, ως προστασία ασφαλείας.]]> Χρησιμοποιεί περισσότερη μπαταρία! Η εφαρμογή εκτελείται πάντα στο παρασκήνιο - οι ειδοποιήσεις εμφανίζονται αμέσως.]]> Η βάση δεδομένων της συνομιλίας εξάχθηκε κλήση Κακή διεύθυνση Desktop - Μεταφορά απο άλλη συσκευή στη νέα συσκευή και σαρώστε τον κωδικό QR.]]> + Μεταφορά από άλλη συσκευή στη νέα συσκευή και σάρωσε τον κωδικό QR.]]> Με προφίλ συνομιλίας (προεπιλογή) ή μέσω σύνδεσης (BETA). Κάμερα και μικρόφωνο 6 νέες γλώσσες διεπαφής - Καλό για την μπαταρία. Η εφαρμογή ελέγχει για την παραλαβή μηνυμάτων κάθε 10 λεπτά. Ενδέχεται να χάσετε κλήσεις ή επείγοντα μηνύματα.]]> + Καλό για την μπαταρία. Η εφαρμογή ελέγχει για την παραλαβή μηνυμάτων κάθε 10 λεπτά. Ενδέχεται να χάσεις κλήσεις ή επείγοντα μηνύματα.]]> Επισύναψη - Διακοπή αλλαγής διεύθυνσης; + Ακύρωση αλλαγής διεύθυνσης; Επιλέξτε ένα αρχείο Όλα τα νέα μηνύνματα απο %s θα αποκρυφθούν! Δεν είναι δυνατή η λήψη του αρχείου Πιστοποίηση Όλα τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! - Ελέγχει νέα μηνύματα κάθε 10 λεπτά για έως και 1 λεπτό + Ελέγχει νέα μηνύματα κάθε 10 λεπτά για εώς και 1 λεπτό Η εφαρμογή μπορεί να λαμβάνει ειδοποιήσεις μόνο όταν εκτελείται, καμία υπηρεσία δεν θα ξεκινήσει στο παρασκήνιο Μπορεί να απενεργοποιηθεί μέσω των ρυθμίσεων – οι ειδοποιήσεις θα εξακολουθούν να εμφανίζονται ενώ η εφαρμογή εκτελείται.]]> - Επιτρέψτε τις επαφές σας να χρησιμοποιούν αντιδράσεις μηνυμάτων. - Και εσείς και η επαφή σας μπορείτε να στείλετε μηνύματα που εξαφανίζονται. + Επέτρεψε στις επαφές σου να χρησιμοποιούν αντιδράσεις μηνυμάτων. + Και εσύ και η επαφή σου μπορείτε να στείλετε μηνύματα που εξαφανίζονται. Κάμερα μη διαθέσιμη Ελέγξτε την διεύθυνση του διακομιστή και δοκιμάστε ξανά. - Επιτρέψτε αντιδράσεις μηνυμάτων εφόσον οι επαφές σας το επιτρέπουν. + Επέτρεψε αντιδράσεις μηνυμάτων εφόσον οι επαφές σου το επιτρέπουν. %1$d μήνυμα/τα παραλήφθηκε/καν. Κλήσεις απογορευμένες! Δεν είναι δυνατή η αποστολή μηνύματος Η κλήση έχει ήδη τερματιστεί! Ο κωδικός πρόσβασης της εφαρμογής αντικαθίσταται με κωδικό πρόσβασης αυτοκαταστροφής. Το Android Keystore θα χρησιμοποιηθεί για την ασφαλή αποθήκευση της φράσης πρόσβασης μετά την επανεκκίνηση της εφαρμογής ή την αλλαγή της φράσης πρόσβασης - θα επιτρέπει τη λήψη ειδοποιήσεων. - Δεν είναι δυνατή η πρόσβαση στο Keystore για αποθήκευση του κωδικού πρόσβασης της βάσης δεδομένων + Δεν είναι δυνατή η πρόσβαση στο Keystore για αποθήκευση του κωδικού της βάσης δεδομένων Αποκλεισμός μέλους Αποκλεισμός μέλους; Προτιμήσεις συνομιλίας Καλύτερα μηνύματα Εφαρμογή - Συνέναιση υποβάθμισης + Συναίνεση υποβάθμισης Κάμερα κλήση ήχου - Αρχειοθετήστε τις επαφές για να συνομιλήσετε αργότερα. + Αρχειοθέτησε τις επαφές για να συνομιλήσεις αργότερα. Όλα τα προφίλ %1$d μηνύμα/τα παραλείφθηκε/καν κακό μήνυμα hash @@ -380,7 +379,7 @@ Κακό αναγνωριστικό μηνύματος ΣΥΝΟΜΙΛΙΕΣ Η βάση δεδεδομένων της συνομιλίας εισάχθηκε - "συμφωνία κρυπτογράφησης για %s…" + συμφωνία κρυπτογράφησης για %s… Να επιτραπούν οι κλήσεις; Αποκλεισμός μέλους για όλους; Κλήσεις ήχου και βίντεο @@ -390,9 +389,2136 @@ Καλύτερη εμπειρία χρήστη Δεν είναι δυνατή η κλήση μέλους ομάδας Ζήτησε να λάβει την εικόνα - για κάθε επαφή και μέλος ομάδας .\nΛάβετε υπόψη: εάν έχετε πολλές συνδέσεις, η κατανάλωση της μπαταρίας και της κυκλοφορίας μπορεί να είναι σημαντικά υψηλότερη και ορισμένες συνδέσεις μπορεί να αποτύχουν.]]> - Προσθήκη επαφής : για να δημιουργήσετε έναν νέο σύνδεσμο πρόσκλησης ή να συνδεθείτε μέσω ενός συνδέσμου που λάβατε.]]> - Καλύτερο για τη ζωή της μπαταρίας . Θα λαμβάνετε ειδοποιήσεις μόνο όταν εκτελείται η εφαρμογή (ΧΩΡΙΣ υπηρεσία παρασκηνίου).]]> + για κάθε επαφή και μέλος ομάδας .\nΛάβε υπόψη: εάν έχεις πολλές συνδέσεις, η κατανάλωση της μπαταρίας και της χρήσης ίντερνετ μπορεί να είναι σημαντικά υψηλότερη και ορισμένες συνδέσεις μπορεί να αποτύχουν.]]> + Προσθήκη επαφής : για να δημιουργήσεις ένα νέο σύνδεσμο πρόσκλησης ή να συνδεθείς μέσω ενός συνδέσμου που έλαβες.]]> + Καλύτερο για τη ζωή της μπαταρίας . Θα λαμβάνεις ειδοποιήσεις μόνο όταν εκτελείται η εφαρμογή (ΧΩΡΙΣ υπηρεσία παρασκηνίου).]]> Beta Καλύτερες κλήσεις + %1$d σφάλμα/τα αρχείου/ων:\n%2$s + 1 συνομιλία με ένα μέλος + 1 αναφορά + 1 χρόνος + Σχετικά με τους χειριστές + Αποδοχή + Αποδοχή + Αποδοχή ως μέλος + Αποδοχή ως παρατηρητής + Αποδοχή όρων + Αποδοχή αιτήματος επαφής + Αποδοχή αιτήματος επαφής + αποδέχτηκε %1$s + Αποδεχούμενοι όροι + αποδέχτηκε την πρόσκληση + σε αποδέχτηκε + Αποδοχή μέλους + Προστέθηκαν διακομιστές πολυμέσων και αρχείων + Προστέθηκε διακομιστής μυνημάτων + Προσθήκη φίλων + Επιπρόσθετο χρώμα έμφασης 2 + Προσθήκη λίστας + Προσθήκη μυνήματος + Διεύθυνση ή σύνδεσμος 1-χρήσης; + Ρυθμίσεις διεύθυνσης + Προσθήκη μέλη ομάδας + Προσθήκη στη λίστα + Πρόσθεσε τα μέλη της ομάδας σου στις συνομιλίες. + όλα + Όλες + Όλες οι συζητήσεις θα διαγραφτούν απο την λίστα %s, και η λίστα θα διαγραφτεί + Όλα τα καινούργια μυνήματα από αυτά τα μέλη θα είναι κρυμμένα! + Επιτρέψτε τα αρχεία και πολυμέσα μόνο αν η επαφή σου το επιτρέπει. + Επιτρέψτε την αναφορά μυνημάτων στους διαχειριστές. + Επέτρεψε στις επαφές σου να σου στέλνουν αρχεία και πολυμέσα. + Όλες η αναφορές θα αρχειοθετηθούν για εσένα. + Όλοι οι διακομιστές + Άλλος λόγος + Η εφαρμογή πάντα να τρέχει στο παρασκήνιο + Αρχειοθέτησε + Αρχειοθέτηση όλων των αναφορών; + αρχειοθετημένη αναφορά + 4 νέες γλώσσες διεπαφής + Γραμμές εργαλείων εφαρμογής + αρχειοθετημένη αναφορά από %s + Να αρχειοθετηθούν %d αναφορές; + Αρχειοθέτηση αναφοράς + Αρχειοθέτηση αναφοράς; + Αρχειοθέτηση αναφορών + Ερώτηση + Καλύτερη απόδοση ομάδων + Καλύτερη ιδιωτικότητα και ασφάλεια + Βιογραφικό: + Το βιογραφικό είναι πολύ μεγάλο + Αποκλεισμός μελών για όλους; + Θόλωμα + Μποτ + Εσύ και η επαφή σου, μπορείτε να στέλνετε αρχεία και πολυμέσα. + Διεύθυνση επιχείρησης + Επαγγελματικές συνομιλίες + Επαγγελματική σύνδεση + Επιχειρήσεις + Χρησιμοποιώντας το SimpleX Chat συμφωνείς να:\n- στέλνεις μόνο νόμιμο περιεχόμενο στις δημόσιες ομάδες.\n- σέβεσαι τους άλλους χρήστες – όχι spam. + Δεν μπορείς να αλλάξεις το προφίλ + δεν μπορείς να στείλεις μηνύματα + Καταλανικά, Ινδονησιακά, Ρουμανικά και Βιετναμέζικα – ευχαριστούμε τους χρήστες μας! + με μία μόνο επαφή - προσωπικό διαμοιρασμό ή μέσω οποιασδήποτε εφαρμογής μηνυμάτων.]]> + με κρυπτογράφηση από άκρη-σε-άκρη και με μετα-κβαντική ασφάλεια σε άμεσα μηνύματα.]]> + Επέτρεψε το στο επόμενο παράθυρο διαλόγου για να λαμβάνεις ειδοποιήσεις άμεσα.]]> + Συσκευές Xiaomi: ενεργοποίησε το Αυτόματο Ξεκίνημα στις ρυθμίσεις συστήματος για να λειτουργούν οι ειδοποιήσεις.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s βρίσκεται σε κακή κατάσταση]]> + Σάρωση QR κωδικού.]]> + %s με τον λόγο: %s]]> + σαρώσεις τον QR κωδικό στη βιντεοκλήση, ή η επαφή σου να διαμοιραστεί ένα σύνδεσμο πρόσκλησης.]]> + δείξε τον QR κωδικό στη βιντεοκλήση, ή μοιράσου το σύνδεσμο.]]> + (νέο)]]> + (αυτή η συσκευή v%s)]]> + κρυπτογράφηση από άκρη-σε-άκρη.]]> + κρυπτογράφηση από άκρη-σε-άκρη με πλήρη εμπιστευτικότητα, δυνατότητα απόρριψης και ανάκτηση μετά από παραβίαση.]]> + κβαντο-ανθεκτική κρυπτογράφηση e2e και με πλήρη εμπιστευτικότητα, δυνατότητα απόρριψης και ανάκτηση μετά από παραβίαση.]]> + %s έχει μη υποστηριζόμενη έκδοση. Βεβαιώσου ότι χρησιμοποιείς την ίδια έκδοση και στις δύο συσκευές.]]> + %s είναι απασχολημένο]]> + %s είναι ανενεργό]]> + %s δεν υπάρχει]]> + %s έχει αποσυνδεθεί]]> + %s έχει αποσυνδεθεί]]> + Άνοιγμα στην εφαρμογή κινητού, μετά πάτα Σύνδεση μέσα στην εφαρμογή.]]> + Χρήση από τον υπολογιστή στην εφαρμογή του κινητού και σκάναρε τον QR κωδικό.]]> + Οδηγό Χρήσης.]]> + αποθετήριό μας στο GitHub.]]> + Χρήση .onion hosts σε Όχι, αν ο διακομιστής μεσολάβησης SOCKS δεν τα υποστηρίζει.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %1$s!]]> + %s]]> + Χρήση μπαταρίας εφαρμογής / Απεριόριστη στις ρυθμίσεις της εφαρμογής.]]> + το SimpleX τρέχει στο παρασκήνιο αντί να χρησιμοποιεί ειδοποιήσεις push.]]> + Χρήση μπαταρίας εφαρμογής / Απεριόριστη στις ρυθμίσεις της εφαρμογής.]]> + %s, αποδέξου τους όρους χρήσης.]]> + %1$s.]]> + %1$s.]]> + %1$s.]]> + %1$s.]]> + πρέπει να χρησιμοποιείς την ίδια βάση δεδομένων σε δύο συσκευές.]]> + Άνοιγμα στην εφαρμογή κινητού κουμπί.]]> + συνδεθείς με τους δημιουργούς του SimpleX Chat για να κάνεις ερωτήσεις και να λαμβάνεις ενημερώσεις.]]> + μόνο αφού γίνει αποδεκτό το αίτημά σου.]]> + Να αλλάξεις την αυτόματη διαγραφή μηνυμάτων; + Αλλαγή των προφίλ συνομιλίας + Αλλαγή λίστας + Αλλαγή σειράς + Συνομιλία + Η συνομιλία υπάρχει ήδη! + Συνομιλίες με μέλη + Η συνομιλία θα διαγραφεί για όλα τα μέλη – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η συνομιλία θα διαγραφεί για εσένα – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Συνομίλησε με τους διαχειριστές + Συνομίλησε με τους διαχειριστές + Συνομίλησε με τους διαχειριστές + Συνομίλησε με μέλος + Συνομιλία με μέλη, πριν συνενωθούν. + Έλεγχος μηνυμάτων κάθε 10 λεπτά + Τα κομμάτια κατέβηκαν + Τα τμήματα των αρχείων ανέβηκαν + Καθάρισε + Καθαρισμός + Καθαρισμός + Καθαρισμός συνομιλίας + Καθαρισμός συνομιλίας; + Καθαρισμός ιδιωτικών σημειώσεων; + Καθαρισμός επαλήθευσης + Κάνε κλικ στο κουμπί πληροφοριών δίπλα στο πεδίο διεύθυνσης για να επιτρέψεις τη χρήση του μικροφώνου. + Κουμπί κλεισίματος + χρωματισμένο + Λειτουργία χρώματος + Έρχεται σύντομα! + Παράβαση των κατευθυντήριων γραμμών της κοινότητας + Σύγκριση αρχείου + Σύγκρινε τους κωδικούς ασφαλείας με τις επαφές σου. + ολοκληρώθηκε + Ολοκληρωμένο + Οι όροι έγιναν αποδεκτοί στις: %s. + Όροι χρήσης + Οι όροι θα γίνουν αποδεκτοί για τους ενεργούς χειριστές μετά από 30 ημέρες. + Οι όροι θα γίνουν αποδεκτοί στις: %s. + Οι όροι θα γίνουν αυτόματα αποδεκτοί για τους ενεργούς χειριστές στις: %s. + Διαμορφωμένοι SMP διακομιστές + Διαμορφωμένοι XFTP διακομιστές + Διαμορφωμένοι ICE διακομιστές + Επιβεβαίωσε + Επιβεβαίωση διαγραφής επαφής; + Επιβεβαίωση αναβαθμίσεων βάσης δεδομένων + Επιβεβαίωση αρχείων από άγνωστους διακομιστές. + Επιβεβαίωση ρυθμίσεων δικτύου + Επιβεβαίωση νέας φράσης πρόσβασης… + Επιβεβαίωση κωδικού πρόσβασης + Επιβεβαίωση κωδικού + Επιβεβαίωσε ότι θυμάσαι τη φράση πρόσβασης της βάσης δεδομένων για να τη μεταφέρεις. + Επιβεβαίωση μεταφόρτωσης + Επιβεβαίωση των διαπιστευτηρίων σου + σύνδεση + Σύνδεση + Σύνδεση + Σύνδεση + Αυτόματη σύνδεση + Απευθείας σύνδεση; + συνδεδεμένος + συνδεδεμένος + συνδεδεμένος + Συνδεδεμένος + Συνδεδεμένος + Συνδεδεμένος υπολογιστής + Συνδεδεμένοι διακομιστές + Συνδέσου γρηγορότερα! 🚀 + Συνδέεται + κλήση σε σύνδεση… + Σύνδεση κλήσης + σύνδεση (σε εξέλιξη) + συνδέεται (μέσω πρόσκλησης) + Σύνδεση με την επαφή, περίμενε ή δοκίμασε αργότερα! + Κατάσταση σύνδεσης και διακομιστών. + Σύνδεση μπλοκαρισμένη + Η σύνδεση έχει μπλοκαριστεί από τον χειριστή του διακομιστή:\n%1$s. + Η σύνδεση δεν είναι έτοιμη. + Η σύνδεση απαιτεί επαναδιαπραγμάτευση κρυπτογράφησης. + Συνδέσεις + Ασφάλεια σύνδεσης + Η σύνδεση διακόπηκε + Η σύνδεση διακόπηκε + Η σύνδεση με τον υπολογιστή βρίσκεται σε κακή κατάσταση + - σύνδεση με υπηρεσία καταλόγου (δοκιμαστικό)!\n- επιβεβαιώσεις παράδοσης (εώς 20 μέλη).\n- γρηγορότερα και πιο σταθερά. + Συνδέσου με τους φίλους σου πιο γρήγορα. + η επαφή %1$s άλλαξε σε %2$s + Η επαφή ελέγχθηκε + η επαφή διαγράφηκε + Η επαφή διαγράφηκε! + η επαφή απενεργοποιήθηκε + Κρυμμένη επαφή: + Η επαφή διαγράφηκε. + η επαφή δεν είναι έτοιμη + ΑΙΤΗΣΕΙΣ ΕΠΑΦΩΝ ΑΠΟ ΟΜΑΔΕΣ + Επαφές + η επαφή πρέπει να αποδεχτεί… + Η επαφή θα διαγραφεί – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Το περιεχόμενο παραβιάζει τους όρους χρήσης + Εικονίδιο περιεχομένου + Συνέχεια + Συνέχεια + Συνεισφορά + Έλεγξε το δίκτυό σου + Η συζήτηση διαγράφηκε! + Αντιγράφηκε στο πρόχειρο + Σφάλμα αντιγραφής + Έκδοση πυρήνα: v%s + Γωνία + Δημιουργία + Δημιουργία συνδέσμου 1-χρήσης + Δημιούργησε μια διεύθυνση για να μπορούν οι άλλοι να συνδεθούν μαζί σου. + Δημιουργήθηκε + Δημιουργήθηκε στις + Δημιουργήθηκε στις: %s + Δημιουργία λίστας + Δημιουργία νέου προφίλ στην εφαρμογή υπολογιστή. 💻 + Δημιουργία συνδέσμου 1-χρήσης + Δημιουργία ουράς + Δημιούργησε τη διεύθυνσή σου + Δημιουργία συνδέσμου αρχειοθέτησης + Δημιουργία συνδέσμου… + Κρίσιμο σφάλμα + (τρέχον) + Το κείμενο των τρεχουσών προϋποθέσεων δεν φορτώθηκε, μπορείς να τις δεις μέσω αυτού του συνδέσμου: + Το μέγιστο υποστηριζόμενο μέγεθος αρχείου αυτήν τη στιγμή είναι %1$s. + Τρέχων κωδικός πρόσβασης + Τρέχουσα φράση πρόσβασης… + Τρέχον προφίλ + προσαρμοσμένος + Προσαρμόσιμη μορφή μηνύματος. + Προσάρμοσε και μοίρασε τα θέματα χρωμάτων. + Προσαρμογή θέματος + Προσαρμοσμένα θέματα + Προσαρμοσμένος χρόνος + Σκούρο + Σκούρο + Σκοτεινή λειτουργία + Χρώματα σκοτεινής λειτουργίας + Σκούρο θέμα + Υποβάθμιση βάσης δεδομένων + Η βάση δεδομένων κρυπτογραφήθηκε! + Η φράση πρόσβασης για την κρυπτογράφηση της βάσης δεδομένων θα ενημερωθεί. + Η φράση πρόσβασης για την κρυπτογράφηση της βάσης δεδομένων θα ενημερωθεί και θα αποθηκευτεί στις ρυθμίσεις. + Η φράση κρυπτογράφησης της βάσης δεδομένων θα ενημερωθεί και θα αποθηκευτεί στο Keystore. + Σφάλμα βάσης δεδομένων + Αναγνωριστικό βάσης δεδομένων + Αναγνωριστικό βάσης δεδομένων: %d + Αναγνωριστικά βάσης δεδομένων και επιλογή απομόνωσης μεταφοράς. + Η βάση δεδομένων είναι κρυπτογραφημένη με τυχαία φράση πρόσβασης. Παρακαλώ άλλαξέ την πριν την εξαγωγή. + Η βάση δεδομένων είναι κρυπτογραφημένη με τυχαία φράση πρόσβασης, μπορείς να την αλλάξεις. + Η μετεγκατάσταση της βάσης δεδομένων βρίσκεται σε εξέλιξη.\nΜπορεί να χρειαστούν λίγα λεπτά. + Φράση πρόσβασης βάσης δεδομένων + Φράση πρόσβασης βάσης δεδομένων και εξαγωγή αυτής + Η φράση πρόσβασης της βάσης δεδομένων διαφέρει από αυτή που έχει αποθηκευτεί στο Keystore. + Η φράση πρόσβασης της βάσης δεδομένων απαιτείται για να ανοίξεις τη συνομιλία. + Αναβάθμιση βάσης δεδομένων + Η έκδοση της βάσης δεδομένων είναι νεότερη από την εφαρμογή, αλλά δεν υπάρχει δυνατότητα υποβάθμισης για: %s + Η βάση δεδομένων θα κρυπτογραφηθεί. + Η βάση δεδομένων θα κρυπτογραφηθεί και η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις. + Η βάση δεδομένων θα κρυπτογραφηθεί και η φράση πρόσβασης θα αποθηκευτεί στο Keystore. + ημέρες + %d συνομιλία/ες + %d συνομιλίες με μέλη + %d επαφή/ές επιλέχθηκε/καν + %dη + %d ημέρα + %d ημέρες + Αποστολή για αποσφαλμάτωση + Αποκεντρωμένο + Σφάλμα αποκωδικοποίησης + Σφάλμα αποκρυπτογράφησης + σφάλματα αποκρυπτογράφησης + προεπιλογή (%s) + προεπιλογή (%s) + Διέγραψε + Διαγραφή + Διαγραφή + Διαγραφή + Διαγραφή διεύθυνσης + Διαγραφή διεύθυνσης; + Διαγραφή μετά + Διαγραφή όλων των αρχείων + Διαγραφή και ειδοποίηση επαφής + Διαγραφή συνομιλίας + Διαγραφή συνομιλίας + Διαγραφή συνομιλίας; + Διαγραφή μηνυμάτων συνομιλίας από τη συσκευή σου. + Διαγραφή προφίλ συνομιλίας + Διαγραφή προφίλ συνομιλίας; + Διαγραφή προφίλ συνομιλίας; + Διαγραφή προφίλ συνομιλίας για + Διαγραφή συνομιλίας με μέλος; + Διαγραφή επαφής + Διαγραφή επαφής; + Διαγράφηκε + Διαγράφηκε στις + Διαγραφή βάσης δεδομένων + Διαγραφή βάσης δεδομένων από αυτήν τη συσκευή + Διαγράφηκε στις: %s + διεγραμμένη επαφή + διεγραμμένη ομάδα + Διαγραφή %d μηνυμάτων; + Διαγραφή %d μηνυμάτων μελών; + Διαγραφή αρχείου + Διαγραφή μηνυμάτων και πολυμέσων; + Διαγραφή μηνυμάτων για όλα τα προφίλ συνομιλίας + Διαγραφή για όλους + Διαγραφή για μένα + Διαγραφή ομάδας + Διαγραφή ομάδας; + Διαγραφή εικόνας + Διαγραφή συνδέσμου + Διαγραφή συνδέσμου; + Διαγραφή λίστας; + Διαγραφή μηνύματος μέλους; + Διαγραφή μηνυμάτων μέλους + Διαγραφή μηνυμάτων μέλους; + Διαγραφή μηνύματος; + Διαγραφή μηνυμάτων + Διαγραφή μηνυμάτων + Διαγραφή μηνυμάτων μετά + Διαγραφή ή διαχείριση εώς 200 μηνυμάτων. + Να διαγραφεί η εκκρεμής σύνδεση; + Διαγραφή προφίλ + Διαγραφή ουράς + Διαγραφή αναφοράς + Διαγραφή διακομιστή + Διαγραφή εώς 20 μηνυμάτων ταυτόχρονα. + Διαγραφή χωρίς ειδοποίηση + Σφάλματα διαγραφής + Παράδοση + Επιβεβαιώσεις παράδοσης! + Οι επιβεβαιώσεις παράδοσης είναι απενεργοποιημένες! + Απαρχαιωμένες επιλογές + Περιγραφή + Περιγραφή πολύ μεγάλη + Υπολογιστής + Διεύθυνση υπολογιστή + Η έκδοση της εφαρμογής για υπολογιστή %s δεν είναι συμβατή με αυτήν την εφαρμογή. + Συσκευές υπολογιστή + Ο υπολογιστής έχει μη υποστηριζόμενη έκδοση. Βεβαιώσου ότι χρησιμοποιείς την ίδια έκδοση και στις δύο συσκευές. + Ο υπολογιστής έχει λάθος κωδικό πρόσκλησης + Ο υπολογιστής είναι απασχολημένος + Ο υπολογιστής είναι ανενεργός + Ο υπολογιστής έχει αποσυνδεθεί + Η διεύθυνση διακομιστή προορισμού %1$s δεν είναι συμβατή με τις ρυθμίσεις του διακομιστή προώθησης %2$s. + Σφάλμα διακομιστή προορισμού: %1$s + Η έκδοση του διακομιστή προορισμού %1$s δεν είναι συμβατή με τον διακομιστή προώθησης %2$s. + Αναλυτικά στατιστικά + Λεπτομέρειες + Επιλογές προγραμματιστή + Εργαλεία προγραμματιστή + ΣΥΣΚΕΥΗ + Η επαλήθευση συσκευής είναι απενεργοποιημένη. Απενεργοποιείται το SimpleX Lock. + Η επαλήθευση συσκευής δεν είναι ενεργοποιημένη. Μπορείς να ενεργοποιήσεις το SimpleX Lock από τις Ρυθμίσεις, αφού πρώτα ενεργοποιήσεις την επαλήθευση συσκευής. + Συσκευές + %d αρχείο/α με συνολικό μέγεθος %s + %d συμβάντα ομάδας + %dω + %dώρα + %dώρες + διαφορετική μετεγκατάσταση στην εφαρμογή/βάση δεδομένων: %s / %s + Διαφορετικά ονόματα, avatar και απομόνωση μεταφοράς. + άμεσα + Άμεσα μηνύματα + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα. + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα σε αυτή τη συνομιλία + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα σε αυτήν την ομάδα. + Απενεργοποίηση + Απενεργοποίηση + Απενεργοποίηση αυτόματης διαγραφής μηνυμάτων; + απενεργοποιημένο + απενεργοποιημένο + Απενεργοποιημένο + Απενεργοποίηση διαγραφής μηνυμάτων + Απενεργοποίηση για όλους + Απενεργοποίηση για όλες τις ομάδες + πενεργοποίηση (διατήρηση παρακάμψεων ομάδας) + Απενεργοποίηση (διατήρηση παρακάμψεων) + Απενεργοποίηση ειδοποιήσεων + Απενεργοποίηση αναφορών παράδοσης; + Απενεργοποιίηση αναφορών παράδοσης για τις ομάδες; + Απερνεργοποίση SimpleX Lock + Μήνυμα που εξαφανίζεται + Μηνύματα που εξαφανίζονται + Μηνύματα που εξαφανίζονται + Είναι απαγορευμένα τα μηνύματα που εξαφανίζονται. + Είναι απαγορευμένα τα μηνύματα που εξαφανίζονται σε αυτήν τη συνομιλία. + Να εξαφανιστεί σε + Να εξαφανιστεί σε: %s + Αποσύνδεση + Αποσύνδεση + Αποσύνδεση υπολογιστή; + Αποσυνδεδεμένος + Αποσυνδεδεμένος με το λόγο: %s + Αποσύνδεση τηλεφώνων + Ανιχνεύσιμο μέσω τοπικού δικτύου + Ανακάλυψε και συνδέσου σε ομάδες + Ανακάλυψη μέσω τοπικού δικτύου + Το εμφανιζόμενο όνομα δεν μπορεί να περιέχει κενά. + %dμ + %dμηνύματα + %dμηνύματα μπλοκαρισμενα από το διαχειριστή + %d λπτ + %d λεπτά + %dμήνας + %d μήνες + %dμν + Να μη σταλεί το ιστορικό σε νέα μέλη. + ΜΗΝ στέλνεις μηνύματα απευθείας, ακόμα κι αν ο δικός σου διακομιστής ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Μην χρησιμοποιείς διαπιστευτήρια με το διακομιστή μεσολάβησης (proxy). + ΜΗΝ χρησιμοποιείς ιδιωτική δρομολόγηση. + Μην δημιουργήσεις διεύθυνση + Μην ενεργοποιήσεις + Μην χάσεις σημαντικά μηνύματα. + Να μην εμφανιστεί ξανά + Υποβάθμιση και άνοιγμα συνομιλίας + Κατέβασμα + Κατέβασμα + Κατέβηκε + Κατεβασμένα αρχεία + Σφάλματα λήψης + Η λήψη απέτυχε + Λήψη αρχείου + Η αναβάθμιση εφαρμογής βρίσκεται σε εξέλιξη, μην κλείσεις την εφαρμογή + Λήψη αρχείου αρχειοθέτησης + Λήψη λεπτομερειών συνδέσμου + Κατέβασε νέες εκδόσεις από το GitHub. + Κατέβασμα %s (%s) + %d αναφορές + %dδ + %dδευτ + %dδευτερόλεπτα + Διπλότυπο εμφανιζόμενο όνομα! + διπλότυπο μήνυμα + διπλότυπα + %dε + %d εβδομάδα + %d εβδομάδες + e2e κρυπτογραφημένο + e2e κρυπτογραφημένη φωνητική κλήση + e2e κρυπτογραφημένη βιντεοκλήση + Ακουστικό + Επεξεργάσου + Επεξεργασία + επεξεργάστηκε + Επεξεργασία προφίλ ομάδας + Επεξεργασία εικόνας + Email + Ενεργοποίηση + Ενεργοποίηση αυτόματης διαγραφής μηνυμάτων + Ενεργοποίηση φωνητικών κλήσεων από την οθόνη κλειδώματος μέσω των Ρυθμίσεων + Ενεργοποίηση πρόσβασης κάμερας + ενεργοποιημένο + Ενεργοποιημένο για + ενεργοποιημένο για την επαφή + ενεργοποιημένο για εσένα + Ενεργοποίηση μηνυμάτων που εξαφανίζονται από προεπιλογή. + Ενεργοποίησε το Flux στις ρυθμίσεις Δικτύου & διακομιστών για καλύτερη προστασία μεταδεδομένων. + Ενεργοποίηση για όλα + Ενεργοποίηση για όλες τις ομάδες + Ενεργοποίηση στις άμεσες συνομιλίες (ΔΟΚΙΜΑΣΤΙΚΟ)! + Ενεργοποίηση (διατήρηση παρακάμψεων ομάδας) + Ενεργοποίηση (διατήρηση παρακάμψεων) + Ενεργοποίηση κλειδώματος + Ενεργοποίηση αρχείων καταγραφής δραστηριότητας + Ενεργοποίηση αναφορών παράδοσης; + Ενεργοποίηση αναφορών παράδοσης για τις ομάδες; + Ενεργοποίηση αυτοκαταστροφής + Ενεργοποίηση κωδικού αυτοκαταστροφής + Ενεργοποίηση SImpleX Lock + Ενεργοποίηση TCP keep-alive + Κρυπτογράφηση + Κρυπτογράφηση βάσης δεδομένων; + Κρυπτογραφημένη βάση δεδομένων + κρυπτογράφηση συμφωνήθηκε + κρυπτογράφηση συμφωνήθηκε για %s + κρυπτογράφηση οκ + κρυπτογράφηση οκ για %s + επιτρέπεται επαναδιαπραγμάτευση κρυπτογράφησης + επιτρέπεται επαναδιαπραγμάτευση κρυπτογράφησης για %s + Σφάλμα κατά την επαναδιαπραγμάτευση κρυπτογράφησης + Αποτυχία κατά την επαναδιαπραγμάτευση κρυπτογράφησης + Επαναδιαπραγμάτευση κρυπτογράφησης σε εξέλιξη. + απαιτείται επαναδιαπραγμάτευση κρυπτογράφησης + απαιτείται επαναδιαπραγμάτευση κρυπτογράφησης για %s + Κρυπτογράφηση τοπικών αρχείων + Κρυπτογράφηση αποθηκευμένων αρχείων & πολυμέσων + Τερματισμός κλήσης + τερματίστηκε + Εισήγαγε σωστή φράση πρόσβασης. + Εισήγαγε όνομα ομάδας: + Εισήγαγε κωδικό πρόσβασης + Εισήγαγε φράση πρόσβασης + Εισήγαγε φράση πρόσβασης… + Εισήγαγε κωδικό στην αναζήτηση + Χειροκίνητη εισαγωγή διακομιστή + Εισήγαγε το όνομα συσκευής… + Εισήγαγε το μήνυμα καλωσορίσματος… + Εισήγαγε το μήνυμα καλωσορίσματος… (προαιρετικό) + Εισήγαγε το όνομά σου: + Σφάλμα + Σφάλμα + Σφάλμα + Σφάλμα + Σφάλμα: %1$s + Σφάλμα κατά την ακύρωση αλλαγής διεύθυνσης + Σφάλμα κατά την αποδοχή των όρων + Σφάλμα κατά την αποδοχή του αιτήματος επαφής + Σφάλμα κατά την αποδοχή μέλους + Επιδιόρθωση σύνδεσης; + Επιδιόρθωση σύνδεσης; + Διόρθωσε την κρυπτογράφηση μετά από επαναφορά αντιγράφων ασφαλείας. + Η επιδιόρθωση δεν υποστηρίζεται από την επαφή + Η επιδιόρθωση δεν υποστηρίζεται από μέλος ομάδας + Αντιστροφή κάμερας + Μέγεθος γραμματοσειράς + Για όλους τους διαχειριστές + για καλύτερη ιδιωτικότητα μεταδεδομένων + Για το προφίλ συνομιλίας %s: + ΓΙΑ ΚΟΝΣΟΛΑ + Για όλους + Για παράδειγμα, αν η επαφή σου λαμβάνει μηνύματα μέσω κάποιου SimpleX Chat διακομιιστή, η εφαρμογή σου θα τα παραδίδει μέσω ενός Flux διακομιστή. + Για μένα + Για ιδιωτική δρομολόγηση + Για μέσα κοινωνικής δικτύωσης + Προώθηση + Προώθηση %1$s μηνύματος/ων; + Προώθηση και αποθήκευση μηνυμάτων + προωθήθηκε + Προωθήθηκε + Προωθήθηκε από + Προώθηση %1$s μηνυμάτων + Διακομιστής προώθησης: %1$s\nΣφάλμα διακομιστή προορισμού: %2$s + Διακομιστής προώθησης: %1$s\nΣφάλμα: %2$s + Ο διακομιστής προώθησης %1$s δεν κατάφερε να συνδεθεί με τον διακομιστή προορισμού %2$s. Δοκίμασε ξανά αργότερα. + Η διεύθυνση του διακομιστή προώθησης είναι ασύμβατη με τις ρυθμίσεις του δικτύου: %1$s. + Η έκδοση του διακομιστή προώθησης είναι ασύμβατη με τις ρυθμίσεις του δικτύου: %1$s. + Προώθηση μηνύματος… + Προώθηση μηνυμάτων… + Προώθηση μηνυμάτων χωρίς τα αρχεία; + Προώθησε μέχρι και 20 μηνύματα μαζί. + Βρέθηκε υπολογιστής + Διεπαφή στα γαλλικά + Από τη Συλλογή + Πλήρης σύνδεσμος + Πλήρης σύνδεσμος + Πλήρες όνομα: + Πλήρως αποκεντρωμένο – ορατό μόνο στα μέλη. + Περαιτέρω μειωμένη κατανάλωση μπαταρίας + Λάβε ειδοποίηση όταν σε αναφέρουν. + Καλησπέρα! + Καλημέρα! + Χορήγησε στις ρυθμίσεις + Χορήγησε άδειες + Χορήγησε άδεια/ες για να κάνεις φωνητικές κλήσεις + Ομάδα + Ομάδα + Η ομάδα υπάρχει ήδη! + η ομάδα διαγράφηκε + Πλήρες όνομα ομάδας: + Ανενεργή ομάδα + Έληξε η πρόσκληση ομάδας + Η πρόσκληση για την ομάδα δεν ισχύει πια, αφαιρέθηκε από τον αποστολέα. + η ομάδα διαγράφηκε + Σύνδεσμος ομάδας + Σύνδεσμοι ομάδας + Διαχείριση ομάδας + Η ομάδα δεν βρέθηκε! + Προτιμήσεις ομάδας + Το προφίλ της ομάδας αποθηκεύεται στις συσκευές των μελών και όχι στους διακομιστές. + το προφίλ της ομάδας ανανεώθηκε + Ομάδες + Μήνυμα καλωσορίσματος ομάδας + Η ομάδα θα διαγραφεί για όλα τα μέλη – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η ομάδα θα διαγραφεί για σένα – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Τερματισμός κλήσης + Ακουστικά + βοήθεια + ΒΟΗΘΕΙΑ + Βοήθησε τους διαχειριστές να διαχειρίζονται τις ομάδες τους. + Γεια σου!\nΣυνδέσου μαζί μου μέσω SimpleX Chat: %s + Κρυφό + Κρυμμένα προφίλ συνομιλίας + Κρυφό συνθηματικό προφίλ + Κρύψε + Κρύψε + Κρύψε + Κρύψε: + Απόκρυψη της οθόνης της εφαρμογής στις πρόσφατες εφαρμογές. + Απόκρυψη επαφής και μηνύματος + Απόκρυψη προφίλ + Ιστορικό + Το ιστορικό δεν αποστέλλεται σε νέα μέλη. + Φιλοξένησε + ώρες + Πως επηρεάζει τη μπαταρία + Πως βοηθάει την ιδιωτικότητα + Πως δουλεύει + Πως να + Πως να το χρησιμοποιήσεις + Πως να χρησιμοποιήσεις markdown σύνταξη + Πως να χρησιμοποιήσεις τους διακομιστές σου + Διεπαφή στα Ουγγρικά και Τουρκικά + ICE διακομιστές (ένας σε κάθε γραμμή) + Αν δεν μπορείς να συναντηθείς προσωπικά, δείξε τον QR κωδικό σε μια βιντεοκλήση ή μοιράσου τον σύνδεσμο. + Αν επιλέξεις να απορρίψεις, ο αποστολέας ΔΕΝ θα ειδοποιηθεί. + Αν επιβεβαιώσεις, οι διακομιστές μηνυμάτων θα μπορούν να δουν τη διεύθυνση IP σου, και ο πάροχός σου – σε ποιους διακομιστές συνδέεσαι. + Αν εισάγεις αυτόν τον κωδικό κατά το άνοιγμα της εφαρμογής, όλα τα δεδομένα της εφαρμογής θα διαγραφούν οριστικά! + Αν εισάγεις τον κωδικό αυτοκαταστροφής κατά το άνοιγμα της εφαρμογής: + Αν έλαβες σύνδεσμο πρόσκλησης για SimpleX Chat, μπορείς να τον ανοίξεις στον περιηγητή σου: + Αγνόησε + Εικόνα + Εικόνα + Η εικόνα αποθηκεύτηκε στη Συλλογή + Η εικόνα στάλθηκε + Η εικόνα θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή της. + Η εικόνα θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, περίμενε ή έλεγξε αργότερα! + Άμεσα + Ανοσοποιημένο στο spam + Εισαγωγή + Εισαγωγή βάσης δεδομένων συνομιλίας; + Εισαγωγή βάσης δεδομένων + Η εισαγωγή απέτυχε + Εισαγωγή αρχείου αρχειοθέτησης + Εισαγωγή θέματος + Σφάλμα εισαγωγής θέματος + Βελτιωμένη πλοήγηση συνομιλίας + Βελτιωμένη παράδοση μηνύματος + Βελτιωμένη παράδοση μηνύματος + Βελτιωμένη ιδιωτικότητα και ασφάλεια + Βελτιωμένη διαμόρφωση διακομιστή + ανενεργό + Ακατάλληλο περιεχόμενο + Ακατάλληλο προφίλ + Ήχοι κατά τη διάρκεια κλήσης + Ανώνυμο + Ανώνυμες ομάδες + Ανώνυμη λειτουργία + Η ανώνυμη λειτουργία προστατεύει το απόρρητό σου χρησιμοποιώντας ένα νέο τυχαίο προφίλ για κάθε επαφή. + ανώνυμα μέσω συνδέσμου διεύθυνσης επαφής + ανώνυμα μέσω συνδέσμου ομάδας + ανώνυμα μέσω συνδέσμου 1-χρήσης + Εισερχόμενη φωνητική κλήση + Εισερχόμενη βιντεοκλήση + Ασύμβατη έκδοση βάσης δεδομένων + Ασύμβατη έκδοση + Λανθασμένος κωδικός πρόσβασης + Λανθασμένος κωδικός ασφάλειας! + Μεγένθυση γραμματοσειράς + έμμεσο (%1$s) + Πληροφορίες + Αρχικός ρόλος + Για να συνεχίσεις, η συνομιλία θα πρέπει να σταματήσει. + Σε απάντηση του + Εγκαταστάθηκε επιτυχημένα + Εγκατέστησε το SimpleX Chat για το τερματικό + Εγκατάσταση αναβάθμισης + Άμεσα + Άμεσες ειδοποιήσεις + Άμεσες ειδοποιήσεις! + Οι άμεσες ειδοποιήσεις είναι απενεργοποιημένες! + ΧΡΩΜΑΤΑ ΔΙΕΠΑΦΗΣ + Εσωτερικό σφάλμα + μη έγκυρη συνομιλία + Μη έγκυρος σύνδεσμος + μη έγκυρα δεδομένα + Μη έγκυρο εμφανιζόμενο όνομα! + Μη έγκυρος σύνδεσμος + Μη έγκυρος σύνδεσμος + Μη έγκυρος σύνδεσμος! + μη έγκυρη διαμόρφωση μηνύματος + Μη έγκυρη επιβεβαίωση μετεγκατάστασης + Μη έγκυρο όνομα! + Μη έγκυρος QR κωδικός + Μη έγκυρος QR κωδικός + Μη έγκυρη διεύθυνση διακομιστή! + Η πρόσκληση έληξε! + πρόσκληση στην ομάδα %1$s + Προσκάλεσε + Προσκάλεσε + προσκαλεσμένος + προσκεκλημένος %1$s + προσκεκλημένος για σύνδεση + προσκεκλημένος μέσω του συνδέσμου της ομάδας σου + Προσκάλεσε φίλους + Προσκάλεσε μέλη + Προσκάλεσε μέλη + Προσκάλεσε για συνομιλία + Προσκάλεσε σε ομάδα + Οριστική διαγραφή μηνύματος + Η οριστική διαγραφή μηνύματος απαγορεύεται. + Η οριστική διαγραφή μηνύματος απαγορεύεται σε αυτή τη συνομιλία. + Διεπαφή στα Ιταλικά + πλάγια γραφή + Σου επιτρέπει να έχεις πολλές ανώνυμες συνδέσεις χωρίς κοινά δεδομένα μεταξύ τους σε ένα μόνο προφίλ συνομιλίας. + Μπορεί να συμβεί όταν:\n1. Τα μηνύματα λήγουν στον αποστολέα μετά από 2 ημέρες ή στον διακομιστή μετά από 30 ημέρες.\n2. Η αποκρυπτογράφηση μηνύματος απέτυχε, επειδή εσύ ή η επαφή σου χρησιμοποιήσατε παλιό αντίγραφο ασφαλείας της βάσης δεδομένων.\n3. Η σύνδεση έχει παραβιαστεί. + Μπορεί να συμβεί όταν εσύ ή η σύνδεσή σου χρησιμοποιήσατε παλιό αντίγραφο ασφαλείας της βάσης δεδομένων. + Προστατεύει τη διεύθυνση IP και τις συνδέσεις σου. + Διεπαφή στα Ιαπωνικά και Πορτογαλικά + Συμμετοχή + Συμμετοχή ως %s + Συμμετοχή στην ομάδα + Συμμετοχή στην ομάδα; + Συμμετοχή στις συνομιλίες της ομάδας + Ανώνυμη συμμετοχή + Συμμετοχή στη ομάδα + Θέλεις να συμμετάσχεις στην ομάδα σου; + χ + Διατήρηση + Διατήρηση συνομιλίας + Διατήρηση αχρησιμοποίητων προσκλήσεων; + Διατήρησε καθαρές τις συνομιλίες σου + Διατήρησε τις συνδέσεις + Σφάλμα κλειδιού + Μεγάλο αρχείο! + Μάθε περισσότερα + Αποχώρησε + Αποχώρησε από τη συνομιλία + Αποχώρηση από τη συνομιλία; + Αποχώρησε από την ομάδα + Αποχώρηση από την ομάδα; + αποχώρησε + αποχώρησε + Λιγότερη κίνηση στα δίκτυα κινητής τηλεφωνίας. + Ας μιλήσουμε στο SimpleX Chat + Ανοιχτόχρωμο + Ανοιχτόχρωμο + Ανοιχτόχρωμη λειτουργία + Σύνδεσε ένα τηλέφωνο + Επιλογές συνδεδεμένου υπολογιστή + Συνδεδεμένοι υπολογιστές + Συνδεδεμένα τηλέφωνα + Σύνδεσε τις εφαρμογές κινητού και υπολογιστή! 🔗 + εικόνα προεπισκόπησης συνδέσμου + Λίστα + Όνομα λίστας... + Το όνομα της λίστας και το emoji πρέπει να είναι διαφορετικά για όλες τις λίστες. + Διεπαφή στα Λιθουανικά + ΖΩΝΤΑΝΑ + Ζωντανό μήνυμα! + Ζωντανά μηνύματα! + Φόρτωση συνομιλιών… + Φόρτωση προφίλ… + Φόρτωση αρχείου + Τοπικό όνομα + Μόνο τοπικά δεδομένα προφίλ + Κλείδωμα μετά + Λειτουργία κλειδώματος + Σύνδεση χρησιμοποιώντας τα στοιχεία σου + Δημιούργησε μία ιδιωτική συνομιλία + Εξαφάνισε ένα μήνυμα + Κάνε το προφίλ ιδιωτικό! + Βεβαιώσου ότι έχεις σωστή διαμόρφωση του διακομιστή μεσολάβησης. + Βεβαιώσου ότι οι διευθύνσεις του διακομιστή SMP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. + Βεβαιώσου ότι το αρχείο έχει σωστή σύνταξη YAML. Κάνε εξαγωγή ενός θέματος για να έχεις παράδειγμα της δομής αρχείου των θεμάτων. + "Βεβαιώσου ότι οι διευθύνσεις των διακομιστών WebRTC ICE έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες." + Βεβαιώσου ότι οι διευθύνσεις των διακομιστών XFTP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. + Κάνε τις συνομιλίες σου να ξεχωρίζουν! + Βοήθεια στη Markdown σύνταξη + Σύνταξη Markdown στα μηνύματα + Επισήμανση ως αναγνωσμένο + Επισήμανση ως μη αναγνωσμένο + Επισήμανση ως επαληθευμένο + Μέχρι 40 δευτερόλεπτα, λαμβάνεται άμεσα. + Διακομιστές πολυμέσων & αρχείων + Μεσαίο + μέλος + ΜΕΛΟΣ + Μέλος %1$s + το μέλος %1$s άλλαξε σε %2$s + Εγγραφή μέλους + το μέλος έχει παλαιότερη έκδοση + Το μέλος είναι ανενεργό + Το μέλος διαγράφηκε – δεν μπορεί να γίνει αποδοχή του αιτήματος + Τα μηνύματα του μέλους θα διαγραφούν – αυτό δεν μπορεί να αναιρεθεί! + Αναφορές μέλους + Τα μέλη μπορούν να προσθέτουν αντιδράσεις στα μηνύματα. + Τα μέλη μπορούν να διαγράψουν οριστικά τα απεσταλμένα μηνύματα. (24 ώρες) + Τα μέλη μπορούν να αναφέρουν μηνύματα στους διαχειριστές. + Τα μέλη μπορούν να στέλνουν απευθείας μηνύματα. + Τα μέλη μπορούν να στέλνουν μηνύματα που εξαφανίζονται. + Τα μέλη μπορούν να στέλνουν αρχεία και πολυμέσα. + Τα μέλη μπορούν να στέλνουν συνδέσμους SimpleX. + Τα μέλη μπορούν να στέλνουν φωνητικά μηνύματα. + Τα μέλη θα αφαιρεθούν από τη συνομιλία – αυτό δεν μπορεί να αναιρεθεί! + Τα μέλη θα αφαιρεθούν από τη ομάδα – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα αφαιρεθεί από τη συνομιλία – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα αφαιρεθεί από την ομάδα – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα συμμετάσχει στην ομάδα, να γίνει αποδοχή του; + Επισήμανση μελών 👋 + Μενού & προειδοποιήσεις + μήνυμα + Μήνυμα + Σφάλμα παράδοσης μηνύματος + Αναφορές παράδοσης μηνύματος! + Προειδοποίηση παράδοσης μηνύματος + Πρόχειρο μήνυμα + Πρόχειρο μήνυμα + Το μήνυμα προωθήθηκε + Στείλε μήνυμα αμέσως μόλις πατήσεις Σύνδεση. + Το μήνυμα είναι πολύ μεγάλο! + Το μήνυμα μπορεί να παραδοθεί αργότερα όταν το μέλος γίνει ενεργό. + Πληροφορίες ουράς μηνυμάτων + Αντιδράσεις μηνυμάτων + Αντιδράσεις μηνυμάτων + Απαγορεύονται οι αντιδράσεις στα μηνύματα. + Απαγορεύονται οι αντιδράσεις στα μηνύματα σε αυτήν τη συνομιλία. + Λήψη μηνυμάτων + Εναλλακτική δρομολόγηση μηνυμάτων + Λειτουργία δρομολόγησης μηνυμάτων + Μηνύματα + ΜΗΝΥΜΑΤΑ ΚΑΙ ΑΡΧΕΙΑ + Διακομιστές μηνυμάτων + Θα εμφανιστούν τα μηνύματα από το %s! + Θα εμφανιστούν τα μηνύματα από αυτά τα μέλη! + Μορφή μηνύματος + Τα μηνύματα σε αυτήν τη συνομιλία δεν θα διαγραφούν ποτέ. + Η πηγή του μηνύματος παραμένει ιδιωτική. + Ληφθέντα μηνύματα + Απεσταλμένα μηνύματα + Κατάσταση μηνύματος + Κατάσταση μηνύματος: %s + Τα μηνύματα διαγράφηκαν αφού τα επιλέξατε. + Τα μηνύματα θα διαγραφούν - αυτό δεν μπορεί να αναιρεθεί! + Τα μηνύματα θα επισημανθούν για διαγραφή. Ο/Οι παραλήπτης/ες θα μπορούν να αποκαλύψουν αυτά τα μηνύματα. + Κείμενο μηνύματος + Το μήνυμα είναι πολύ μεγάλο + Το μήνυμα θα διαγραφεί - αυτό δεν μπορεί να αναιρεθεί! + Το μήνυμα θα επισημανθεί για διαγραφή. Ο/Οι παραλήπτης/ες θα μπορούν να αποκαλύψουν αυτό το μήνυμα. + Μικρόφωνο + Μετεγκατάσταση συσκευής + Μετεγκατάσταση από άλλη συσκευή + Μετεγκατάσταση εδώ + Μετεγκατάσταση σε άλλη συσκευή + Μετεγκατάσταση σε άλλη συσκευή μέσω QR κωδικού. + Μετεγκατάσταση σε εξέλιξη + Η μετεγκατάσταση ολοκληρώθηκε + Μετεγκαταστάσεις: %s + λεπτά + αναπάντητη κλήση + Αναπάντητη κλήση + Διαχειρίσου + διαχειρίζεται + Διαχειρίστηκε στις + Διαχειρίστηκε στις: %s + διαχειριστής + διαχειριστές + μήνες + Περισσότερα + Σύντομα έρχονται περισσότερες βελτιώσεις! + Σύντομα έρχονται περισσότερες βελτιώσεις! + Πιο αξιόπιστη σύνδεση δικτύου. + - πιο σταθερή παράδοση μηνυμάτων.\n- λίγο καλύτερες ομάδες.\n- και πολλά ακόμα! + Πιθανότατα αυτή η επαφή να έχει διαγράψει τη σύνδεση μαζί σου. + Πολλαπλά προφίλ συνομιλίας + Σίγαση + Σίγαση + Σίγαση όλων + Σε σίγαση όταν είναι ανενεργό! + Σύνδεση δικτύου + Αποκέντρωση δικτύου + Προβλήματα δικτύου - το μήνυμα έληξε μετά από πολλές προσπάθειες αποστολής. + Διαχείριση δικτύου + Χειριστής δικτύου + Χειριστές δικτύου + Δίκτυο & διακομιστές + Κατάσταση δικτύου + ποτέ + Ποτέ + Νέα συνομιλία + Νέα εμπειρία συνομιλίας 🎉 + Νέα θέματα συνομιλίας + Νέο αίτημα επαφής + Νέο αρχείο βάσης δεδομένων + Νέα εφαρμογή για υπολογιστές! + Νέο εμφανιζόμενο όνομα: + δευτερόλεπτα + Το βιογραφικό σου: + Πάτα Σύνδεση για να συνομιλήσεις + Πάτα Σύνδεση για αποστολή αιτήματος + Πάτα Σύνδεση για να χρησιμοποιήσεις το μποτ + Πατήστε Δημιουργία διεύθυνσης SimpleX στο μενού, για να τη δημιουργήσετε αργότερα. + Πάτα Συμμετοχή στην ομάδα + Πάτα για να ενεργοποιήσεις το προφίλ. + Πάτα για Σύνδεση + Πάτα για συμμετοχή + Πάτα για ανώνυμη συμμετοχή + Πάτα για επικόλληση συνδέσμου + Πάτα για σάρωση + Πάτα για να ξεκινήσεις μία νέα συνομιλία + Σύνδεση TCP + Χρόνος λήξης σύνδεσης TCP στο παρασκήνιο + Χρόνος λήξης σύνδεσης TCP + Θύρα TCP για ανταλλαγή μηνυμάτων + Σφάλμα προσωρινού αρχείου + Η δοκιμή απέτυχε στο βήμα %s. + Δοκιμή διακομιστή + Δοκιμή διακομιστών + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Σε ευχαριστούμε που εγκατέστησες το SimpleX Chat! + Η διεύθυνση θα είναι σύντομη και το προφίλ σου θα κοινοποιηθεί μέσω αυτής. + Η εφαρμογή λαμβάνει νέα μηνύματα περιοδικά — καταναλώνει ένα μικρό ποσοστό της μπαταρίας ανά ημέρα. Η εφαρμογή δεν χρησιμοποιεί ειδοποιήσεις push — τα δεδομένα από τη συσκευή σου δεν αποστέλλονται στους διακομιστές. + Η εφαρμογή ενδέχεται να κλείσει μετά από 1 λεπτό στο παρασκήνιο. + Η εφαρμογή προστατεύει το απόρρητό σου χρησιμοποιώντας διαφορετικούς χειριστές σε κάθε συνομιλία. + Η εφαρμογή θα ζητήσει επιβεβαίωση για λήψεις από άγνωστους διακομιστές αρχείων (εκτός από .onion ή όταν είναι ενεργοποιημένος ο διακομιστής μεσολάβησης SOCKS). + Η προσπάθεια αλλαγής της φράσης πρόσβασης της βάσης δεδομένων δεν ολοκληρώθηκε. + Ο κωδικός που σάρωσες δεν είναι κωδικός QR ενός συνδέσμου SimpleX. + Η σύνδεση έφτασε στο όριο των μη παραδοθέντων μηνυμάτων, η επαφή σου ενδέχεται να είναι εκτός σύνδεσης. + Η σύνδεση που αποδέχθηκες θα ακυρωθεί! + Η επαφή με την οποία μοιράστηκες αυτόν το σύνδεσμο, ΔΕΝ θα μπορεί να συνδεθεί! + Η βάση δεδομένων δεν λειτουργεί σωστά. Πάτησε για να μάθεις περισσότερα. + Για τις κλήσεις απαιτείται ο προεπιλεγμένος περιηγητής. Ρύθμισε τον προεπιλεγμένο περιηγητή στο σύστημα σου και μοιράσου περισσότερες πληροφορίες με τους προγραμματιστές. + Το όνομα της συσκευής θα κοινοποιηθεί στην εφαρμογή του συνδεδεμένου κινητού. + Η κρυπτογράφηση λειτουργεί και η νέα κρυπτογράφηση δεν είναι απαραίτητη. Μπορεί να προκαλέσει σφάλματα σύνδεσης! + Το μέλλον στην ανταλλαγή μηνυμάτων + Ο κωδικός ελέγχου του προηγούμενου μηνύματος είναι διαφορετικός. + Ο αναγνωριστικός κωδικός του επόμενου μηνύματος είναι λανθασμένος (μικρότερος ή ίσος με τον προηγούμενο).\nΑυτό μπορεί να συμβεί λόγω κάποιου σφάλματος ή όταν η σύνδεση έχει παραβιαστεί. + Η εικόνα δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε μια άλλη εικόνα ή επικοινώνησε με τους προγραμματιστές. + Ο σύνδεσμος θα είναι σύντομος και το προφίλ της ομάδας θα κοινοποιηθεί μέσω αυτού. + Θέμα + ΘΕΜΑΤΑ + Τα μηνύματα θα διαγραφούν για όλα τα μέλη. + Τα μηνύματα θα επισημαίνονται ως ελεγχόμενα για όλα τα μέλη. + Το μήνυμα θα διαγραφεί για όλα τα μέλη. + Το μήνυμα θα επισημανθεί ως υπό έλεγχο για όλα τα μέλη. + Η πλατφόρμα μηνυμάτων και εφαρμογών που προστατεύει το απόρρητο και την ασφάλειά σου. + Η φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο. + Η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις ως απλό κείμενο μετά την αλλαγή της ή την επανεκκίνηση της εφαρμογής. + Το προφίλ κοινοποιείται μόνο στις επαφές σου. + Η αναφορά θα αρχειοθετηθεί για εσένα. + Ο ρόλος θα αλλάξει σε %s. Όλοι οι συμμετέχοντες στη συνομιλία θα ειδοποιηθούν. + Ο ρόλος θα αλλάξει σε %s. Όλα τα μέλη της ομάδας θα ενημερωθούν. + Ο ρόλος θα αλλάξει σε %s. Το μέλος θα λάβει νέα πρόσκληση. + Ο δεύτερος προκαθορισμένος χειριστής στην εφαρμογή! + Το δεύτερο τικ που χάσαμε! ✅ + Ο αποστολέας ΔΕΝ θα ειδοποιηθεί. + Οι διακομιστές για τις νέες συνδέσεις του τρέχοντος προφίλ συνομιλίας σου + Οι διακομιστές για τα νέα αρχεία του τρέχοντος προφίλ συνομιλίας σου + Αυτές οι ρυθμίσεις ισχύουν για το τρέχον προφίλ σου + Το κείμενο που επικόλλησες δεν είναι σύνδεσμος SimpleX. + Το αρχείο της βάσης δεδομένων που μεταφορτώθηκε, θα διαγραφεί οριστικά από τους διακομιστές. + Το βίντεο δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε ένα άλλο βίντεο ή επικοινώνησε με τους προγραμματιστές. + Μπορούν να παρακαμφθούν στις ρυθμίσεις επαφών και ομάδων. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - όλα τα ληφθέντα και απεσταλμένα αρχεία και πολυμέσα θα διαγραφούν. Οι εικόνες χαμηλής ανάλυσης θα παραμείνουν. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - τα μηνύματα που έχουν αποσταλεί και παραληφθεί πριν από την επιλεγμένη ημερομηνία, θα διαγραφούν. Η διαδικασία μπορεί να διαρκέσει αρκετά λεπτά. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - τα μηνύματα που έχουν αποσταλεί και παραληφθεί σε αυτήν τη συνομιλία, πριν από την επιλεγμένη ημερομηνία, θα διαγραφούν. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - το προφίλ, οι επαφές, τα μηνύματα και τα αρχεία σου, θα χαθούν οριστικά και ανεπανόρθωτα. + Αυτή η συνομιλία προστατεύεται με κρυπτογράφηση από άκρη-σε-άκρη. + Αυτή η συνομιλία προστατεύεται με κβαντο-ανθεκτική κρυπτογράφηση από άκρη-σε-άκρη. + Αυτή η συσκευή + Το όνομα αυτής της συσκευής + Το εμφανιζόμενο όνομα δεν είναι έγκυρο. Επέλεξε ένα άλλο όνομα. + Αυτή η λειτουργία δεν υποστηρίζεται ακόμη. Δικίμασε την επόμενη έκδοση. + Αυτή η ομάδα έχει πάνω από %1$d μέλη, δεν αποστέλλονται αναφορές παράδοσης. + Αυτή η ομάδα δεν υπάρχει πλέον. + Αυτός είναι ο δικός σου σύνδεσμος 1-χρήσης! + Αυτή είναι η διεύθυνση σου SimpeX! + Αυτός ο σύνδεσμος δεν είναι έγκυρος! + Αυτός ο σύνδεσμος απαιτεί νεότερη έκδοση της εφαρμογής. Αναβάθμισε την εφαρμογή ή ζήτησε από την επαφή σου να σου στείλει ένα συμβατό σύνδεσμο. + Αυτός ο σύνδεσμος χρησιμοποιήθηκε με άλλη κινητή συσκευή. Δημιούργησε ένα νέο σύνδεσμο στον υπολογιστή σου. + Αυτό το μήνυμα διαγράφηκε ή δεν έχει ληφθεί ακόμα. + Αυτός ο κωδικός QR δεν είναι σύνδεσμος! + Αυτή η ρύθμιση ισχύει για τα μηνύματα στο τρέχον προφίλ συνομιλίας σου. + Αυτή η ρύθμιση αφορά το τρέχον προφίλ σου. + Αυτό το κείμενο δεν είναι σύνδεσμος! + Αυτό το κείμενο είναι διαθέσιμο στις ρυθμίσεις + Εξαντλήθηκε ο χρόνος αναμονής κατά τη σύνδεση με τον υπολογιστή + Ο χρόνος εξαφάνισης ορίζεται μόνο για τις νέες επαφές. + Τίτλος + Για να επιτρέψεις σε μια εφαρμογή κινητού να συνδεθεί στον υπολογιστή, άνοιξε αυτήν τη θύρα στο τείχος προστασίας σου, εάν το έχεις ενεργοποιήσει. + Για να λαμβάνεις ειδοποιήσεις σχετικά με τις νέες εκδόσεις, ενεργοποίησε τον περιοδικό έλεγχο για σταθερές ή δοκιμαστικές εκδόσεις. + Για να συνδεθείς μέσω συνδέσμου + Για να συνδεθείς, η επαφή σου μπορεί να σαρώσει τον κωδικό QR ή να χρησιμοποιήσει τον σύνδεσμο στην εφαρμογή. + Ενεργοποίηση ανώνυμης λειτουργίας κατά τη σύνδεση. + Για απόκρυψη ανεπιθύμητων μηνυμάτων. + Για να πραγματοποιήσεις κλήσεις, επέτρεψε τη χρήση του μικροφώνου σου. Τερμάτισε την κλήση και προσπάθησε να καλέσεις ξανά. + Πάρα πολλές εικόνες! + Πάρα πολλά βίντεο! + Για να προστατευτείς από αντικατάσταση του συνδέσμου σου, μπορείς να συγκρίνεις τους κωδικούς ασφαλείας των επαφών σου. + Για την προστασία της ζώνης ώρας, τα αρχεία εικόνας/φωνής χρησιμοποιούν UTC ώρα. + Για να προστατεύσεις τις πληροφορίες σου, ενεργοποίησε το SimpleX Lock.\nΘα σου ζητηθεί να ολοκληρώσεις την επαλήθευση ταυτότητας πριν ενεργοποιηθεί αυτή η λειτουργία. + Για την προστασία της IP διεύθυνσής σου, η ιδιωτική δρομολόγηση χρησιμοποιεί τους διακομιστές SMP για την παράδοση μηνυμάτων. + Για την προστασία της ιδιωτικότητάς σου, το SimpleX χρησιμοποιεί ξεχωριστά αναγνωριστικά για κάθε μία από τις επαφές σου. + Για λήψη + Για να λαμβάνεις ειδοποιήσεις, παρακαλώ εισήγαγε τη φράση πρόσβασης της βάσης δεδομένων. + Για να αποκαλύψεις το κρυφό προφίλ σου, εισήγαγε έναν πλήρη κωδικό στο πεδίο αναζήτησης στη σελίδα Τα προφίλ συνομιλίας σου. + Για αποστολή + Για αποστολή εντολών, θα πρέπει να είσαι συνδεδεμένος. + (για διαμοιρασμό με την επαφή σου) + Για να ξεκινήσεις μία νέα συνομιλία + Συνολικά + Για να χρησιμοποιήσεις άλλο προφίλ μετά την προσπάθεια σύνδεσης, διέγραψε τη συνομιλία και χρησιμοποίησε ξανά τον σύνδεσμο. + Για να επαληθεύσεις την κρυπτογράφηση από άκρη-σε-άκρη με την επαφή σου, συγκρίνετε (ή σαρώστε) τον κωδικό στις συσκευές σας. + Διαφάνεια + Απομόνωση μεταφοράς + Απομόνωση μεταφοράς + Μεταφορές συνεδριών + Ενεργοποίηση + μη εξουσιοδοτημένη αποστολή + Ξεμπλοκάρισμα + ξεμπλοκαρισμένο %s + Ξεμπλοκάρισμα για όλους + Ξεμπλοκάρισμα μέλους + Ξεμπλοκάρισμα μέλους; + Ξεμπλοκάρισμα μέλους για όλους; + Ξεμπλοκάρισμα μελών για όλους; + Μηνύματα που δεν παραδόθηκαν + Αφαίρεση από τα αγαπημένα + Εμφάνιση + Εμφάνιση προφίλ συνομιλίας + Εμφάνιση προφίλ + άγνωστο + Άγνωστο σφάλμα βάσης δεδομένων: %s + Άγνωστο σφάλμα + άγνωστη μορφή μηνύματος + Άγνωστοι διακομιστές + Άγνωστοι διακομιστές! + άγνωστη κατάσταση + Εκτός αν η επαφή σου διέγραψε τη σύνδεση ή αυτός ο σύνδεσμος είχε ήδη χρησιμοποιηθεί, μπορεί να πρόκειται για σφάλμα - παρακαλούμε να το αναφέρεις.\nΓια να συνδεθείς, ζήτησε από την επαφή σου να δημιουργήσει έναν άλλο σύνδεσμο σύνδεσης και έλεγξε ότι έχεις σταθερή σύνδεση δικτύου. + Αποσύνδεση + Αποσύνδεση υπολογιστή; + Ξεκλείδωμα + Απενεργοποίηση σίγασης + Απενεργοποίηση σίγασης + Απροστάτευτο + αδιάβαστο + Μη αναγνωσμένες αναφορές + Μη υποστηριζόμενος σύνδεσμος σύνδεσης + Αναβάθμιση + Αναβάθμιση + Αναβάθμιση + Διαθέσιμη αναβάθμιση: %s + Ενημέρωση φράσης πρόσβασης της βάσης δεδομένων + Ενημερωμένοι όροι + ενημερωμένο προφίλ ομάδας + Η λήψη της ενημέρωσης ακυρώθηκε + ενημερωμένο προφίλ + Ενημέρωση ρυθμίσεων δικτύου; + Ενημέρωση της λειτουργίας απομόνωσης μεταφοράς; + Ενημέρωσε τη διεύθυνσή σου + Η ενημέρωση των ρυθμίσεων θα επανασυνδέσει την εφαρμογή με όλους τους διακομιστές. + Αναβάθμιση + Αναβάθμιση διεύθυνσης + Αναβάθμιση διεύθυνσης; + Αναβάθμιση και άνοιγμα συνομιλίας + Αυτόματη αναβάθμιση εφαρμογής + Αναβάθμιση συνδέσμου ομάδας + Αναβάθμιση συνδέσμου ομάδας; + Ανέβηκε + Ανεβασμένα αρχεία + Σφάλματα μεταφόρτωσης + Αποτυχία μεταφόρτωσης + Ανέβασμα αρχείου + Ανεβαίνει το αρχείο αρχειοθέτησης + Τα τελευταία 100 μηνύματα αποστέλλονται στα νέα μέλη. + Χρήση συνομιλίας + Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε σύνδεση. + Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε προφίλ. + Χρήση απευθείας σύνδεσης στο Διαδίκτυο; + Χρήση για αρχεία + Χρήση για μηνύματα + Χρήση για νέες συνδέσεις + Χρήση από τον υπολογιστή + Χρήση ανώνυμου προφίλ + Χρήση κεωτρικών διακομιστών .onion + Χρήση ιδιωτικής δρομολόγησης με άγνωστους διακομιστές. + Χρήση ιδιωτικής δρομολόγησης με άγνωστους διακομιστές όταν η διεύθυνση IP δεν προστατεύεται. + Χρήση τυχαίων διαπιστευτηρίων + Χρήση τυχαίας φράσης πρόσβασης + Όνομα χρήστη + Χρήση %s + Χρήση διακομιστή + Χρήση διακομιστών + Χρήση διακομιστών SimpleX Chat; + Χρήση δικομιστή μεσολάβησης SOCKS + Χρήση διακομιστή μεσολάβησης SOCKS; + Χρήση της θύρας TCP %1$s όταν δεν έχει καθοριστεί θύρα. + Χρήση της θύρας TCP 443 μόνο για προκαθορισμένους διακομιστές. + Χρήση της εφαρμογής κατά τη διάρκεια μίας κλήσης. + Χρήση της εφαρμογής με το ένα χέρι + Χρήση θύρας web + Χρήση διακομιστών SimpleX Chat. + Σφάλμα κατά την προσθήκη μέλους/ων + Σφάλμα κατά την προσθήκη διακομιστή + Σφάλμα κατά το μπλοκάρισμα του μέλους, για όλους + Σφάλμα κατά την αλλαγή διεύθυνσης + Σφάλμα κατά την αλλαγή προφίλ + Σφάλμα κατά την αλλαγή ρόλου + Σφάλμα κατά την αλλαγή της ρύθμισης + Σφάλμα κατά τη σύνδεση με το διακομιστή προώθησης %1$s. Παρακαλώ δοκίμασε ξανά αργότερα. + Σφάλμα κατά τη σύνδεση με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτή τη σύνδεση: %1$s. + Σφάλμα κατά τη δημιουργία διεύθυνσης + Σφάλμα κατά τη δημιουργία της λίστας συνομιλιών + Σφάλμα κατά τη δημιουργία συνδέσμου ομάδας + Σφάλμα κατά τη δημιουργία επαφής μέλους + Σφάλμα κατά τη δημιουργία μηνύματος + Σφάλμα κατά τη δημιουργία προφίλ! + Σφάλμα κατά τη δημιουργία της αναφοράς + Σφάλμα κατά τη διαγραφή της συνομιλίας + Σφάλμα κατά τη διαγραφή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά τη διαγραφή της επαφής + Σφάλμα κατά τη διαγραφή του αιτήματος της επαφής + Σφάλμα κατά τη διαγραφή της βάσης δεδομένων + Σφάλμα κατά τη διαγραφή ομάδας + Σφάλμα κατά τη διαγραφή του συνδέσμου ομάδας + Σφάλμα κατά τη διαγραφή εκκρεμούς σύνδεσης επαφής + Σφάλμα κατά τη διαγραφή ιδιωτικών σημειώσεων + Σφάλμα κατά τη διαγραφή του προφίλ χρήστη + Σφάλμα κατά τη λήψη του αρχείου αρχειοθέτησης + Σφάλμα κατά την ενεργοποίηση των αναφορών παράδοσης! + Σφάλμα κατά την κρυπτογράφηση της βάσης δεδομένων + Σφάλμα κατά την εξαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά την εξαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα προώθησης μηνυμάτων + Σφάλμα κατά την εισαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά την αρχικοποίηση του WebView. Βεβαιώσου ότι έχεις εγκαταστήσει το WebView και ότι η υποστηριζόμενη αρχιτεκτονική είναι arm64.\nΣφάλμα: %s + Σφάλμα κατά την αρχικοποίηση του WebView. Ενημέρωσε το σύστημά σου στη νέα έκδοση. Επικοινώνησε με τους προγραμματιστές.\nΣφάλμα: %s + Σφάλμα κατά τη συμμετοχή στην ομάδα + Σφάλμα κατά τη φόρτωση των λιστών συνομιλιών + Σφάλμα κατά τη φόρτωση των λεπτομερειών + Σφάλμα κατά τη φόρτωση των διακομιστών SMP + Σφάλμα κατά τη φόρτωση των διακομιστών XFTP + Σφάλμα επισήμανσης ως αναγνωσμένου + Σφάλμα κατά το άνοιγμα του προγράμματος περιήγησης + Σφάλμα κατά το άνοιγμα της συνομιλίας + Σφάλμα κατά το άνοιγμα της ομάδας + Σφάλμα κατά την ανάγνωση της φράσης πρόσβασης της βάσης δεδομένων + Σφάλμα κατά τη λήψη του αρχείου + Σφάλμα κατά την επανασύνδεση του διακομιστή + Σφάλμα κατά την επανασύνδεση των διακομιστών + Σφάλμα κατά την απόρριψη αιτήματος της επαφής + Σφάλμα κατά την αφαίρεση του μέλους + Σφάλμα επαναφοράς στατιστικών στοιχείων + Σφάλμα: %s + Σφάλματα + Σφάλμα κατά την αποθήκευση της βάσης δεδομένων + Σφάλμα κατά την αποθήκευση του αρχείου + Σφάλμα κατά την αποθήκευση του προφίλ ομάδας + Σφάλμα κατά την αποθήκευση των διακομιστών ICE + Σφάλμα κατά την αποθήκευση του διακομιστή μεσολάβησης + Σφάλμα κατά την αποθήκευση διακομιστών + Σφάλμα κατά την αποθήκευση των ρυθμίσεων + Σφάλμα κατά την αποθήκευση των ρυθμίσεων + Σφάλμα κατά την αποθήκευση των διακομιστών SMP + Σφάλμα κατά την αποθήκευση του κωδικού πρόσβασης χρήστη + Σφάλμα κατά την αποθήκευση διακομιστών XFTP + Σφάλμα κατά την αποστολή της πρόσκλησης + Σφάλμα κατά την αποστολή του μηνύματος + Σφάλμα κατά τη ρύθμιση της διεύθυνσης + σφάλμα κατά την εμφάνιση του περιεχομένου + σφάλμα εμφάνισης μηνύματος + Σφάλμα στην εμφάνιση της ειδοποίησης, επικοινώνησε με τους προγραμματιστές. + Σφάλματα στη διαμόρφωση των διακομιστών. + Σφάλμα κατά την έναρξη της συνομιλίας + Σφάλμα κατά τη διακοπή της συνομιλίας + Σφάλμα κατά την εναλλαγή προφίλ + Σφάλμα κατά την αλλαγή προφίλ! + Σφάλμα κατά τo συγχρονισμό της σύνδεσης + Σφάλμα κατά την ενημέρωση της λίστας συνομιλιών + Σφάλμα κατά την ενημέρωση του συνδέσμου ομάδας + Σφάλμα κατά την ενημέρωση της διαμόρφωσης δικτύου + Σφάλμα κατά την αναβάθμιση του διακομιστή + Σφάλμα κατά την ενημέρωση των ρυθμίσεων απορρήτου χρήστη + Σφάλμα κατά το ανέβασμα του αρχείου αρχειοθέτησης + Σφάλμα κατά την επαλήθευση της φράσης πρόσβασης: + Ακόμα και όταν είναι απενεργοποιημένη στη συνομιλία. + Η εκτέλεση της λειτουργίας διαρκεί πολύ χρόνο: %1$d δευτερόλεπτα: %2$s + Έξοδος χωρίς αποθήκευση + Επέκτεινε + Επέκταση επιλογής ρόλου + ΠΕΙΡΑΜΑΤΙΚΟ + Πειραματικά χαρακτηριστικά + έληξε + Εξαγωγή της βάσης δεδομένων + Το εξαγόμενο αρχείο δεν υπάρχει + Εξαγωγή θέματος + Αποτυχία φόρτωσης συνομιλίας + Αποτυχία φόρτωσης συνομιλιών + Γρήγορα και χωρίς αναμονή μέχρι να συνδεθεί ο αποστολέας! + Ταχύτερη διαγραφή ομάδων. + Ταχύτερη σύνδεση και πιο αξιόπιστα μηνύματα. + Ταχύτερη αποστολή μηνυμάτων. + Αγαπημένο + Αγαπημένα + Αρχείο + Αρχείο + Σφάλμα αρχείου + Το αρχείο έχει αποκλειστεί από το χειριστή του διακομιστή:\n%1$s. + Το αρχείο δεν βρέθηκε + Το αρχείο δεν βρέθηκε - πιθανότατα το αρχείο διαγράφηκε ή ακυρώθηκε. + Αρχείο: %s + Αρχεία + ΑΡΧΕΙΑ + Αρχεία και πολυμέσα + Απαγορεύονται τα αρχεία και τα πολυμέσα. + Τα αρχεία και τα πολυμέσα, απαγορεύονται σε αυτήν τη συνομιλία. + Δεν επιτρέπονται αρχεία και πολυμέσα + Απαγορεύονται αρχεία και πολυμέσα! + Το αρχείο αποθηκεύτηκε + Σφάλμα διακομιστή αρχείων: %1$s + Αρχεία & πολυμέσα + Κατάσταση αρχείου + Κατάσταση αρχείου: %s + Το αρχείο διαγράφηκε ή ο σύνδεσμος δεν είναι έγκυρος. + Το αρχείο θα διαγραφεί από τους διακομιστές. + Το αρχείο θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή του. + Το αρχείο θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Γέμισμα οθόνης + Φίλτραρε τις μη αναγνωσμένες και τις αγαπημένες συνομιλίες. + Ολοκλήρωση της μετεγκατάστασης + Ολοκλήρωσε τη μετεγκατάσταση σε άλλη συσκευή. + Επιτέλους, τα έχουμε! 🚀 + Βρες τις συνομιλίες πιο γρήγορα + Βρες αυτήν την άδεια στις ρυθμίσεις Android και παραχώρησέ την χειροκίνητα. + Το αποτύπωμα στη διεύθυνση του διακομιστή προορισμού δεν ταιριάζει με το πιστοποιητικό: %1$s. + Το αποτύπωμα στη διεύθυνση του διακομιστή προώθησης δεν ταιριάζει με το πιστοποιητικό: %1$s. + Το αποτύπωμα στη διεύθυνση του διακομιστή δεν ταιριάζει με το πιστοποιητικό. + Το αποτύπωμα στη διεύθυνση του διακομιστή δεν ταιριάζει με το πιστοποιητικό: %1$s. + Προσαρμογή στην οθόνη + Επιδιόρθωση + Επιδιόρθωση + Επιδιόρθωση σύνδεσης + Νέος ρόλος ομάδας: Συντονιστής + Νέο στο %s + Επιλογές νέων πολυμέσων + Νέος ρόλος μέλους + Νέο μέλος θέλει να ενταχθεί στην ομάδα. + νέο μήνυμα + Νέο μήνυμα + Νέα συσκευή τηλεφώνου + Νέος κωδικός πρόσβασης + Νέα φράση πρόσβασης + Νέος διακομιστής + Κάθε φορά που εκκινείς την εφαρμογή, θα χρησιμοποιούνται νέα διαπιστευτήρια SOCKS. + Νέα διαπιστευτήρια SOCKS θα χρησιμοποιούνται για κάθε διακομιστή. + όχι + Όχι + Όχι + Όχι + Χωρίς κωδικό πρόσβασης εφαρμογής + Χωρίς κλήσεις στο παρασκήνιο + Χωρίς υπηρεσία παρασκηνίου + Χωρίς συνομιλίες + Δεν βρέθηκαν συνομιλίες + Δεν υπάρχουν συνομιλίες στη λίστα %s. + Δεν υπάρχουν συνομιλίες με μέλη + Δεν υπάρχει συνδεδεμένο κινητό + Δεν έχουν επιλεγεί επαφές + Δεν υπάρχουν επαφές για προσθήκη + Δεν υπάρχουν πληροφορίες παράδοσης + χωρίς λεπτομέρειες + Δεν υπάρχει ακόμη άμεση σύνδεση, το μήνυμα προωθείται από το διαχειριστή. + χωρίς κρυπτογράφηση e2e + Καμία φιλτραρισμένη συνομιλία + Καμία φιλτραρισμένη επαφή + Χωρίς ιστορικό + Δεν υπάρχουν πληροφορίες, δοκίμασε να επαναφορτώσεις + Χωρίς διακομιστές πολυμέσων και αρχείων. + Κανένα μήνυμα + Χωρίς διακομιστές μηνυμάτων. + κανένα + Δεν υπάρχει σύνδεση δικτύου + Καμία συνεδρία ιδιωτικής δρομολόγησης + Δεν υπάρχουν ληφθέντα ή απεσταλμένα αρχεία + Δεν έχει επιλεγεί συνομιλία + Δεν υπάρχουν διακομιστές για τη δρομολόγηση ιδιωτικών μηνυμάτων. + Δεν υπάρχουν διακομιστές για τη λήψη αρχείων. + Δεν υπάρχουν διακομιστές για τη λήψη μηνυμάτων. + Δεν υπάρχουν διακομιστές για την αποστολή αρχείων. + χωρίς συνδρομή + Μη συμβατό! + Σημειώσεις + χωρίς κείμενο + Δεν έχει επιλεγεί τίποτα + Δεν υπάρχει τίποτα να προωθήσεις! + Προεπισκόπηση ειδοποίησης + Ειδοποιήσεις + Ειδοποιήσεις και μπαταρία + Υπηρεσία ειδοποιήσεων + Οι ειδοποιήσεις θα παραδίδονται μόνο μέχρι να σταματήσει η εφαρμογή! + Οι ειδοποιήσεις θα σταματήσουν να λειτουργούν μέχρι να επανεκκινήσεις την εφαρμογή. + μη συγχρονισμένο + Δεν υπάρχουν μη αναγνωσμένες συνομιλίες + Χωρίς αναγνωριστικά χρήστη. + Τώρα οι διαχειριστές μπορούν:\n- να διαγράφουν τα μηνύματα των μελών.\n- να απενεργοποιούν μέλη (ρόλος παρατηρητή) + παρατηρητής + κλειστό` + κλειστό + κλειστό + Κλειστό + Κλειστή + προσφέρεται %s + προσφέρθηκε %s: %2s + ΟΚ + Παλιό αρχείο βάσης δεδομένων + ανοιχτό + Σύνδεσμος πρόσκλησης 1-χρήσης + Σύνδεσμος πρόσκλησης 1-χρήσης + Για τη σύνδεση θα απαιτηθούν διακομιστές Onion.\nΣημείωση: δεν θα μπορείς να συνδεθείς στους διακομιστές χωρίς διεύθυνση .onion. + Οι κεντρικοί υπολογιστές Onion θα χρησιμοποιούνται όταν είναι διαθέσιμοι. + Οι κεντρικοί υπολογιστές Onion δεν θα χρησιμοποιηθούν. + Μπορούν να σταλούν μόνο 10 εικόνες ταυτόχρονα + Μπορούν να σταλούν μόνο 10 βίντεο ταυτόχρονα + Μόνο οι ιδιοκτήτες του chat μπορούν να αλλάξουν τις προτιμήσεις. + Μόνο οι συσκευές αποθηκεύουν προφίλ χρηστών, επαφές, ομάδες και μηνύματα. + Διαγραφή μόνο της συνομιλίας + Μόνο οι ιδιοκτήτες ομάδων μπορούν να αλλάξουν τις προτιμήσεις της ομάδας. + Μόνο οι ιδιοκτήτες ομάδων μπορούν να ενεργοποιήσουν αρχεία και πολυμέσα. + Μόνο οι ιδιοκτήτες ομάδων μπορούν να ενεργοποιήσουν τα φωνητικά μηνύματα. + Μόνο μία συσκευή μπορεί να λειτουργεί ταυτόχρονα + Μόνο ο αποστολέας και οι διαχειριστές μπορούν να το δουν + (αποθηκεύεται μόνο από τα μέλη της ομάδας) + Μόνο εσύ και οι διαχειριστές το βλέπετε + Μόνο εσύ μπορείς να προσθέσεις αντιδράσεις σε μηνύματα. + Μόνο εσύ μπορείς να διαγράψεις οριστικά τα μηνύματα (η επαφή σου μπορεί να τα επισημάνει για διαγραφή). (24 ώρες) + Μόνο εσύ μπορείς να πραγματοποιήσεις κλήσεις. + Μόνο εσύ μπορείς να στέλνεις μηνύματα που εξαφανίζονται. + Μόνο εσύ μπορείς να στέλνεις αρχεία και πολυμέσα. + Μόνο εσύ μπορείς να στέλνεις φωνητικά μηνύματα. + Μόνο η επαφή σου μπορεί να προσθέσει αντιδράσεις σε μηνύματα. + Μόνο η επαφή σου μπορεί να διαγράψει οριστικά τα μηνύματα (μπορείς να τα επισημάνεις για διαγραφή). (24 ώρες) + Μόνο η επαφή σου μπορεί να πραγματοποιεί κλήσεις. + Μόνο η επαφή σου μπορεί να στείλει μηνύματα που εξαφανίζονται. + Μόνο η επαφή σου μπορεί να στείλει αρχεία και πολυμέσα. + Μόνο η επαφή σου μπορεί να στείλει φωνητικά μηνύματα. + άνοιγμα + Άνοιξε + Άνοιγμα + Άνοιξε τις ρυθμίσεις της εφαρμογής + Ανοιχτές αλλαγές + Άνοιγμα συνομιλίας + Άνοιγμα συνομιλίας + Άνοιγμα κονσόλας συνομιλίας + - Άνοιγμα συνομιλίας στο πρώτο μη αναγνωσμένο μήνυμα.\n- Μετάβαση στα αναφερόμενα μηνύματα. + Άνοιγμα καθαρού συνδέσμου + Ανοιχτές προϋποθέσεις + Άνοιγμα φακέλου βάσης δεδομένων + Άνοιγμα θέσης αρχείου + Άνοιγμα πλήρους συνδέσμου + Άνοιγμα ομάδας + Το άνοιγμα του συνδέσμου στον περιηγητή μπορεί να μειώσει την ιδιωτικότητα και την ασφάλεια της σύνδεσης. Οι μη αξιόπιστοι σύνδεσμοι SimpleX θα εμφανίζονται με κόκκινο χρώμα. + Άνοιγμα συνδέσμου + Άνοιγμα συνδέσμων από τη λίστα συνομιλιών + Άνοιξε την οθόνη μετεγκατάστασης + Άνοιξε νέα συνομιλία + Άνοιξε νέα ομάδα + Άνοιγμα θύρας στο τείχος προστασίας + Άνοιξε τις Ρυθμίσεις Safari / Ιστοσελίδες / Μικρόφωνο και στη συνέχεια επέλεξε Να επιτρέπεται για το localhost. + Άνοιγμα ρυθμίσεων διακομιστή + Άνοιγμα ρυθμίσεων + Άνοιξε το SimpleX Chat για να αποδεχθείς την κλήση + Άνοιξε για να αποδεχθείς + Άνοιξε για να συνδεθείς + Άνοιξε για να συμμετάσχεις + Άνοιξε για να χρησιμοποιήσεις το μποτ + Άνοιγμα συνδέσμου ιστού; + Άνοιγμα με %s + Χειριστής + Διακομιστής χειριστή + - προαιρετική ειδοποίηση για διεγραμμένες επαφές.\n- ονόματα προφίλ με κενά.\n- και πολλά άλλα! + Οργάνωσε τις συνομιλίες σε λίστες + Ή εισαγωγή αρχείου αρχειοθέτησης + Ή επικόλλησε το σύνδεσμο του αρχείου αρχειοθέτησης + Ή σάρωσε τον κωδικό QR + Ή μοιράσου με ασφάλεια αυτόν τον σύνδεσμο αρχείου + Ή δείξε αυτόν τον κωδικό + Ή για να μοιραστείς ιδιωτικά + άλλο + Άλλο + άλλα σφάλματα + Άλλοι διακομιστές SMP + Άλλοι διακομιστές XFTP + ιδιοκτήτης + ιδιοκτήτες + Κωδικός πρόσβασης + Ο κωδικός πρόσβασης αλλάχθηκε! + Εισαγωγή κωδικού πρόσβασης + Ο κωδικός πρόσβασης δεν έχει αλλάξει! + Ο κωδικός πρόσβασης έχει οριστεί! + Η φράση πρόσβασης στο Keystore δεν μπορεί να διαβαστεί, παρακαλώ εισήγαγέ τη χειροκίνητα. Αυτό μπορεί να συνέβη μετά από ενημέρωση του συστήματος που δεν είναι συμβατή με την εφαρμογή. Εάν δεν είναι αυτή η περίπτωση, παρακαλώ επικοινώνησε με τους προγραμματιστές. + Η φράση πρόσβασης στο Keystore δεν μπορεί να διαβαστεί. Αυτό μπορεί να συνέβη μετά από ενημέρωση του συστήματος που δεν είναι συμβατή με την εφαρμογή. Εάν δεν είναι αυτή η περίπτωση, επικοινώνησε με τους προγραμματιστές. + Απαιτείται φράση πρόσβασης + Ο φράση πρόσβασης δεν βρέθηκε στο Keystore, παρακαλώ εισήγαγέ τη χειροκίνητα. Αυτό μπορεί να συνέβη αν επανέφερες τα δεδομένα της εφαρμογής χρησιμοποιώντας ένα εργαλείο δημιουργίας αντιγράφων ασφαλείας. Αν δεν είναι αυτή η περίπτωση, παρακαλώ επικοινώνησε με τους προγραμματιστές. + Κωδικός + Κωδικός για εμφάνιση + Επικόλληση + Επικόλληση συνδέσμου αρχείου αρχειοθέτησης + Επικόλληση διεύθυνσης υπολογιστή + Επικόλληση συνδέσμου + Επικόλλησε το σύνδεσμο για να συνδεθείς! + Επικόλλησε το σύνδεσμο που έλαβες + Επικόλλησε το σύνδεσμο που έλαβες για να συνδεθείς με την επαφή σου… + από άκρη-σε-άκρη + εκκρεμής + Εκκρεμής + Εκκρεμής + σε αναμονή έγκρισης + Εκκρεμής κλήση + σε αναμονή για έλεγχο + Περιοδικά + Περιοδικές ειδοποιήσεις + Οι περιοδικές ειδοποιήσεις είναι απενεργοποιημένες! + Η άδεια απορρίφθηκε! + Διεπαφή στα Περσικά + Κλήσεις σε λειτουργία εικόνα-μέσα-στην-εικόνα + Μέτρηση PING + εσωτερικό PING + Αναπαραγωγή από τη λίστα συνομιλιών. + Παρακαλώ ζήτησε από την επαφή σου να ενεργοποιήσει τις κλήσεις. + Παρακαλώ ζήτησε από την επαφή σου να ενεργοποιήσει τα φωνητικά μηνύματα. + Έλεγξε ότι το κινητό και ο υπολογιστής είναι συνδεδεμένοι στο ίδιο τοπικό δίκτυο και ότι το τείχος προστασίας του υπολογιστή επιτρέπει τη σύνδεση.\nΕνημέρωσε τους προγραμματιστές για τυχόν άλλα προβλήματα. + Έλεγξε ότι ο σύνδεσμος SimpleX είναι σωστός. + Έλεγξε ότι χρησιμοποιείς το σωστό σύνδεσμο ή ζήτησε από την επαφή σου να σου στείλει έναν άλλο. + Έλεγξε τη σύνδεσή σου στο δίκτυο με %1$s και δοκίμασε ξανά. + Επιβεβαίωσε ότι οι ρυθμίσεις δικτύου είναι σωστές για αυτήν τη συσκευή. + Παρακαλώ επικοινώνησε με το διαχειριστή της ομάδας. + Εισήγαγε τη σωστή τρέχουσα φράση πρόσβασης. + Εισήγαγε τον προηγούμενο κωδικό μετά την επαναφορά του αντιγράφου ασφαλείας της βάσης δεδομένων. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. + Μείωσε το μέγεθος του μηνύματος και απέστειλέ το ξανά. + Μείωσε το μέγεθος του μηνύματος ή αφαίρεσε τα αρχεία πολυμέσων και απέστειλέ το ξανά. + Παρακαλώ θυμήσου ή αποθήκευσε το με ασφάλεια - δεν υπάρχει τρόπος να ανακτήσεις έναν χαμένο κωδικό! + Παρακαλώ ανάφερέ το στους προγραμματιστές. + Παρακαλώ ανάφερέ το στους προγραμματιστές: \n%s + Παρακαλώ ανάφερέ το στους προγραμματιστές: \n%s\n\nΠροτείνεται η επανεκκίνηση της εφαρμογής. + Παρακαλώ επανεκκίνησε την εφαρμογή. + Αποθήκευσε τη φράση πρόσβασης σε ασφαλές μέρος, καθώς ΔΕΝ θα μπορείς να έχεις πρόσβαση στη συνομιλία αν τη χάσεις. + Αποθήκευσε τη φράση πρόσβασης σε ασφαλές μέρος, καθώς ΔΕΝ θα μπορείς να την αλλάξεις σε περίπτωση απώλειας. + Παρακαλώ δοκίμασε αργότερα. + Ενημέρωσε την εφαρμογή και επικοινώνησε με τους προγραμματιστές. + Παρακαλώ περίμενε μέχρι οι διαχειριστές της ομάδας να εξετάσουν το αίτημά σου για συμμετοχή στην ομάδα. + Παρακαλώ, περίμενε ενώ το αρχείο φορτώνεται από το συνδεδεμένο κινητό + Διεπαφή στα Πολωνικά + Μετέφερε + θύρα%d + Προετοιμασία λήψης + Προετοιμασία μεταφόρτωσης + Διατήρηση του τελευταίου πρόχειρου μηνύματος, με τα συνημμένα. + Προκαθορισμένος διακομιστής + Διεύθυνση προκαθορισμένου διακομιστή + Προκαθορισμένοι διακομιστές + Προκαθορισμένοι διακομιστές + Προεπισκόπηση + Προηγούμενοι συνδεδεμένοι διακομιστές + Προστασία της ιδιωτικότητας των πελατών σου. + Πολιτική απορρήτου και όροι χρήσης. + Επαναπροσδιορισμός της ιδιωτικότητας + Απόρρητο & ασφάλεια + Οι ιδιωτικές συνομιλίες, οι ομάδες και οι επαφές σου δεν είναι προσβάσιμες στους χειριστές του διακομιστή. + Ιδιωτικά ονόματα αρχείων + Ιδιωτικά ονόματα αρχείων πολυμέσων. + Δρομολόγηση ιδιωτικών μηνυμάτων 🚀 + ΔΡΟΜΟΛΟΓΗΣΗ ΙΔΙΩΤΙΚΩΝ ΜΗΝΥΜΑΤΩΝ + Ιδιωτικές σημειώσεις + Ιδιωτικές σημειώσεις + Ιδιωτικές ειδοποιήσεις + Ιδιωτική δρομολόγηση + Σφάλμα ιδιωτικής δρομολόγησης + Λήξη χρονικού ορίου ιδιωτικής δρομολόγησης + Προφίλ και συνδέσεις διακομιστή + εικόνα προφίλ + θέση για εικόνα προφίλ + Εικόνες προφίλ + Όνομα προφίλ: + Κωδικός προφίλ + Θέμα προφίλ + Η ενημέρωση του προφίλ θα σταλεί στις επαφές σου. + Απαγόρευση κλήσεων ήχου/βίντεο. + Απαγόρευση της μη αναστρέψιμης διαγραφής μηνυμάτων. + Απαγόρευση αντιδράσεων σε μήνυμα. + Απαγόρευση αντιδράσεων σε μηνύματα. + Απαγόρευση αναφοράς μηνυμάτων στους διαχειριστές. + Απαγόρευση αποστολής άμεσων μηνυμάτων στα μέλη. + Απαγόρευση αποστολής μηνυμάτων που εξαφανίζονται. + Απαγόρευση αποστολής μηνυμάτων που εξαφανίζονται. + Απαγόρευση αποστολής αρχείων και πολυμέσων. + Απαγόρευση αποστολής αρχείων και πολυμέσων. + Απαγόρευση αποστολής συνδέσμων SimpleX + Απαγόρευση αποστολής φωνητικών μηνυμάτων. + Απαγόρευση αποστολής φωνητικών μηνυμάτων. + Προστασία οθόνης εφαρμογής + Προστασία διεύθυνσης IP + Προστάτεψε τα προφίλ συνομιλίας σου με έναν κωδικό! + Προστάτεψε τη διεύθυνση IP σου από τα κέντρα διαβίβασης μηνυμάτων που επιλέγουν οι επαφές σου.\nΕνεργοποίησε την επιλογή στις ρυθμίσεις *Δίκτυο και διακομιστές*. + Χρονικό όριο πρωτοκόλλου + Χρονικό όριο πρωτοκόλλου + Χρονικό όριο πρωτοκόλλου ανά KB + Μέσω διακομιστή μεσολάβησης + Διακομιστές μέσω proxy + Πιστοποίηση διακομιστή μεσολάβησης + Κωδικός QR + κβαντο-ανθεκτική κρυπτογράφηση e2e + Κβαντο-ανθεκτική κρυπτογράφηση + Τυχαία + Η τυχαία φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο.\nΜπορείς να την αλλάξεις αργότερα. + Αξιολόγησε την εφαρμογή + Προσβάσιμες γραμμές εργαλείων εφαρμογής + Προσβάσιμη γραμμή εργαλείων συνομιλίας + Προσβάσιμη γραμμή εργαλείων συνομιλίας + Διάβασε περισσότερα + Οι αναφορές παράδοσης είναι απενεργοποιημένες + απάντηση που παραλήφθηκε… + Παραλήφθηκε στις + Παραλήφθηκε στις: %s + επιβεβαίωση που παραλήφθηκε… + Μήνυμα που παραλήφθηκε + Μήνυμα που παραλήφθηκε + Μηνύματα που παραλήφθηκαν + παραλήφθηκε, απαγορεύεται + Παραλήφθηκε απάντηση + Σύνολο που παραλήφθηκε + Σφάλματα παραλαβής + Η διεύθυνση παραλαβής θα αλλάξει σε διαφορετικό διακομιστή. Η αλλαγή διεύθυνσης θα ολοκληρωθεί μετά την σύνδεση του αποστολέα. + Λήψη ταυτόχρονης πρόσβασης + η λήψη αρχείων δεν υποστηρίζεται ακόμη + Η λήψη αρχείων θα διακοπεί. + Λήψη μηνυμάτων… + Λήψη μέσω + Πρόσφατο ιστορικό και βελτιωμένο μποτ καταλόγου. + Ο/Οι παραλήπτης/ες δεν μπορούν να δουν από ποιον προέρχεται αυτό το μήνυμα. + Οι παραλήπτες βλέπουν τις ενημερώσεις καθώς τις πληκτρολογείς. + Επανασύνδεση + Επανασύνδεσε όλους τους συνδεδεμένους διακομιστές για να επιβάλεις την παράδοση μηνυμάτων. Χρησιμοποιεί επιπλέον κίνηση. + Επανασύνδεση όλων των διακομιστών + Επανασύνδεση διακομιστή; + Επανασύνδεση διακομιστών; + Επανασύνδεση διακομιστή για να επιβληθεί η παράδοση μηνυμάτων. Χρησιμοποιεί επιπλέον κίνηση. + Η εγγραφή ενημερώθηκε στις + Η εγγραφή ενημερώθηκε στις: %s + Εγγραφή φωνητικού μηνύματος + Μειωμένη χρήση μπαταρίας + Ανανέωση + Απόρριψη + Απόρριψη + Απόρριψη + Απόρριψη αιτήματος επαφής + απορρίφθηκε + απορρίφθηκε + απορριφθείσα κλήση + Απορριφθείσα κλήση + Απόρριψη μέλους; + Ο διακομιστής αναμετάδοσης χρησιμοποιείται μόνο αν είναι απαραίτητο. Οι άλλοι μπορούν να δουν τη διεύθυνση IP σου. + Ο διακομιστής αναμετάδοσης προστατεύει τη διεύθυνση IP σου, αλλά μπορεί να παρακολουθεί τη διάρκεια της κλήσης. + Υπενθύμιση αργότερα + Απομακρυσμένα κινητά τηλέφωνα + Κατάργηση + Κατάργηση + Κατάργηση και διαγραφή μηνυμάτων + Κατάργηση αρχείου αρχειοθέτησης; + καταργήθηκε + καταργήθηκε %1$s + διεγραμμένη διεύθυνση επαφής + αφαιρέθηκε από την ομάδα + αφαιρέθηκε η φωτογραφία προφίλ + σε αφαίρεσε + Κατάργηση εικόνας + Κατάργηση παρακολούθησης συνδέσμων + Κατάργηση μέλους + Κατάργηση μέλους + Κατάργηση μέλους; + Κατάργηση μελών; + Κατάργηση φράσης πρόσβασης από το Keystore; + Κατάργηση φράσης πρόσβασης από τις ρυθμίσεις; + Κατάργηση μηνυμάτων και μπλοκάρισμα μελών. + Επαναδιαπραγμάτευση + Επαναδιαπραγμάτευση κρυπτογράφησης + Επαναδιαπραγμάτευση κρυπτογράφησης; + Επανάληψη στην οθόνη + Επανάληψη αιτήματος σύνδεσης; + Επανάληψη λήψης + Επανάληψη εισαγωγής + Επανάληψη αιτήματος συμμετοχής; + Επανάληψη μεταφόρτωσης + Απάντησε + Ανέφερε + Αναφορά περιεχομένου: μόνο οι διαχειριστές της ομάδας θα το δουν. + Η αναφορά μηνυμάτων απαγορεύεται σε αυτήν την ομάδα. + Αναφορά προφίλ μέλους: μόνο οι διαχειριστές της ομάδας θα το δουν. + Άλλη αναφορά: μόνο οι διαχειριστές της ομάδας θα το δουν. + Αιτία αναφοράς; + Αναφορά: %s + Αναφορές + Η αναφορά εστάλη στους διαχειριστές + Επανεκκίνηση + Αναφορά spam: μόνο οι διαχειριστές της ομάδας θα το δουν. + Αναφορά παραβίασης κανόνων: μόνο οι διαχειριστές της ομάδας θα τη δουν. + αιτήσου σύνδεση από την ομάδα %1$s + αιτήσου να συνδεθείς + το αίτημα αποστέλλεται + το αίτημα συμμετοχής απορρίφθηκε + Απαιτείται + Επανέφερε + Επαναφορά + Επαναφορά όλων των υποδείξεων + Επαναφορά όλων των στατιστικών + Επαναφορά όλων των στατιστικών; + Επαναφορά χρώματος + Επαναφορά χρωμάτων + Επαναφορά στο θέμα της εφαρμογής + Επαναφορά στις προεπιλογές + Επαναφορά στο θέμα χρήστη + Επανεκκίνηση συνομιλίας + Επανεκκίνησε την εφαρμογή για να δημιουργήσεις ένα νέο προφίλ συνομιλίας. + Επανεκκίνησε την εφαρμογή για να χρησιμοποιήσεις την εισαγώμενη βάση δεδομένων. + Επαναφορά + Επαναφορά αντιγράφου ασφαλείας βάσης δεδομένων + Επαναφορά αντιγράφου ασφαλείας βάσης δεδομένων; + Σφάλμα επαναφοράς βάσης δεδομένων + Επανέλαβε + Αποκάλυψε + ανασκόπηση + Προϋποθέσεις ελέγχου + ελέγχθηκε από τους διαχειριστές + Έλεγχος μελών ομάδας + Έλεγχος αργότερα + Έλεγχος μελών + Έλεγχος μελών πριν την αποδοχή τους (knocking). + Ανάκληση + Ανάκληση αρχείου + Ανάκληση αρχείου; + Ρόλος + ΕΚΚΙΝΗΣΗ ΣΥΝΟΜΙΛΙΑΣ + Εκτελείται όταν η εφαρμογή είναι ανοιχτή + Ασφαλής λήψη αρχείων + Ασφαλέστερες ομάδες + %s και %s + %s και %s συνδέθηκαν + %s στις %s + Αποθήκευσε + Αποθήκευση + Αποθήκευση + Αποθήκευση ρυθμίσεων εισόδου; + Αποθήκευση και ειδοποίηση επαφής + Αποθήκευση και ειδοποίηση επαφών + Αποθήκευση και ειδοποίηση μελών ομάδας + Αποθήκευση και επανασύνδεση + Αποθήκευση και ενημέρωση προφίλ ομάδας + αποθηκευμένο + Αποθηκευμένο + Αποθηκευμένο από + αποθηκευμένο από %s + Αποθηκευμένο μήνυμα + Οι αποθηκευμένοι διακομιστές WebRTC ICE θα αφαιρεθούν. + Αποθήκευση προφίλ ομάδας + Αποθήκευση λίστας + Αποθήκευση φράσης πρόσβασης και άνοιγμα συνομιλίας + Αποθήκευση φράσης πρόσβασης στο Keystore + Αποθήκευση φράσης πρόσβασης στις ρυθμίσεις + Αποθήκευση προτιμήσεων; + Αποθήκευση κωδικού προφίλ + Αποθήκευση διακομιστών + Αποθήκευση διακομιστών; + Αποθήκευση ρυθμίσεων; + Αποθήκευση ρυθμίσεων διεύθυνσης SimpleX + Αποθήκευση μηνύματος καλωσορίσματος; + Αποθήκευση %1$s μηνυμάτων + Κλιμάκωση στην οθόνη + Σάρωση κωδικού + Σάρωση από κινητό + (σάρωσε ή επικόλλησε από το πρόχειρο) + Σάρωση / Επικόλληση συνδέσμου + Σάρωσε τον κωδικό QR από τον υπολογιστή + %s συνδέθηκε + %s (τρέχον) + %s κατέβηκαν + αναζήτηση + Η γραμμή αναζήτησης δέχεται συνδέσμους πρόσκλησης. + Αναζήτηση ή επικόλληση συνδέσμου SimpleX + Δευτερεύων + Ασφαλής + ο κωδικός ασφαλείας άλλαξε + Επιλογή + Επέλεξε + Επέλεξε προφίλ συνομιλίας + Επέλεξε επαφές + Οι επιλεγμένες προτιμήσεις συνομιλίας απαγορεύουν αυτό το μήνυμα. + Επιλέχθηκαν %d + Επέλεξε τους χειριστές δικτύου που θέλεις να χρησιμοποιήσεις. + Αυτοκαταστροφή + Κωδικός αυτοκαταστροφής + Κωδικός αυτοκαταστροφής + Ο κωδικός αυτοκαταστροφής άλλαξε! + Ο κωδικός αυτοκαταστροφής ενεργοποιήθηκε! + Απέστειλε + Απέστειλε + Στείλε ένα ζωντανό μήνυμα - θα ενημερώνεται για τον παραλήπτη ή τους παραλήπτες καθώς το πληκτρολογείς. + Αποστολή αιτήματος επαφής; + ΑΠΟΣΤΟΛΗ ΑΝΑΦΟΡΩΝ ΠΑΡΑΔΟΣΗΣ ΣΕ + Αποστολή άμεσου μηνύματος + Στείλε άμεσο μήνυμα για να συνδεθείς + Αποστολή μηνύματος που εξαφανίζεται + Ο αποστολέας ακύρωσε τη μεταφορά αρχείων. + Ο αποστολέας ενδέχεται να έχει διαγράψει το αίτημα σύνδεσης. + Σφάλματα αποστολής + αποτυχία αποστολής + Η αποστολή αναφορών παράδοσης θα είναι ενεργοποιημένη για όλες τις επαφές. + Η αποστολή αναφορών παράδοσης θα είναι ενεργοποιημένη για όλες τις επαφές σε όλα τα ορατά προφίλ συνομιλίας. + η αποστολή αρχείων δεν υποστηρίζεται ακόμη + Η αποστολή του αρχείου θα διακοπεί. + Η αποστολή αναφορών είναι απενεργοποιημένη για %d επαφές + Η αποστολή αναφορών είναι απενεργοποιημένη για %d ομάδες + Η αποστολή αναφορών είναι ενεργοποιημένη για %d επαφές + Η αποστολή αναφορών είναι ενεργοποιημένη για %d ομάδες + Αποστέλλεται μέσω + Αποστολή προεπισκόπησης συνδέσμων + Αποστολή ζωντανού μηνύματος + Αποστολή Μηνύματος + Στείλε μηνύματα απευθείας όταν η διεύθυνση IP είναι προστατευμένη και ο διακομιστής σου ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Στείλε μηνύματα απευθείας όταν ο διακομιστής σου ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Στείλε μήνυμα για να ενεργοποιήσεις τις κλήσεις. + Αποστολή ιδιωτικών αναφορών + Στείλε ερωτήσεις και ιδέες + Αποστολή αναφορών + Αποστολή αιτήματος + Αποστολή αιτήματος χωρίς μήνυμα + αποστολή για σύνδεση + Αποστολή εώς και 100 τελευταίων μηνυμάτων σε νέα μέλη. + Στείλε μας ένα mail + Στείλε τα προσωπικά σου σχόλια στις ομάδες. + στάλθηκε + Στάλθηκε στις + Στάλθηκε στις: %s + Στάλθηκε απευθείας + Απεσταλμένο μήνυμα + Απεσταλμένο μήνυμα + Απεσταλμένα μηνύματα + Τα αποσταλμένα μηνύματα θα διαγραφούν μετά από καθορισμένο χρονικό διάστημα. + Απεσταλμένη απάντηση + Σύνολο απεσταλμένων + Αποστέλλεται στην επαφή σου μετά τη σύνδεση. + Αποστολή μέσω διακομιστή μεσολάβησης + Διακομιστής + Ο διακομιστής προστέθηκε στο χειριστή %s. + Διεύθυνση διακομιστή + Η διεύθυνση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου. + Η διεύθυνση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου: %1$s. + Ο χειριστής του διακομιστή άλλαξε. + Χειριστές διακομιστή + Αλλαγή πρωτοκόλλου διακομιστή. + πληροφορίες ουράς διακομιστή: %1$s\n\nτελευταίο ληφθέν μήνυμα: %2$s + Ο διακομιστής απαιτεί εξουσιοδότηση για τη δημιουργία ουρών, έλεγξε τον κωδικό. + Ο διακομιστής απαιτεί εξουσιοδότηση για ανέβασμα αρχείων, έλεγξε τον κωδικό. + ΔΙΑΚΟΜΙΣΤΕΣ + Πληροφορίες διακομιστών + Θα γίνει επαναφορά στα στατιστικά στοιχεία των διακομιστών - αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η δοκιμή του διακομιστή απέτυχε! + Η έκδοση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου. + Η έκδοση του διακομιστή δεν είναι συμβατή με την εφαρμογή σου: %1$s. + Κωδικός συνεδρίας + Όρισε σε 1 ημέρα + Όρισε το όνομα συνομιλίας… + Όρισε το όνομα επαφής + Όρισε το όνομα επαφής… + Όρισε τη φράση πρόσβασης της βάσης δεδομένων + Όρισε το προεπιλεγμένο θέμα + Όρισε τις προτιμήσεις ομάδας + Όρισέ τον αντί για την πιστοποίηση συστήματος. + Όρισε την εισαγωγή μέλους + Όρισε τη λήξη των μηνυμάτων στις συνομιλίες. + ορίστε νέα διεύθυνση επαφής + όρισε νέα εικόνα προφίλ + Όρισε κωδικό πρόσβασης + Όρισε φράση πρόσβασης + Όρισε φράση πρόσβασης για εξαγωγή + Όρισε το βιογραφικό του προφίλ και το μήνυμα καλωσορίσματος. + Όρισε το εμφανιζόμενο μήνυμα για τα νέα μέλη! + Ρυθμίσεις + Ρυθμίσεις + ΡΥΘΜΙΣΕΙΣ + Όρισε τη φράση πρόσβασης της βάσης δεδομένων + Διαμόρφωση εικόνων προφίλ + Διαμοίρασε + Διαμοίρασε το σύνδεσμο 1-χρήσης + Διαμοίρασε το σύνδεσμο 1-χρήσης με ένα φίλο + Διαμοιρασμός διεύθυνσης + Δημόσιος διαμοιρασμός διεύθυνσης + Διαμοιρασμός διεύθυνσης με τις επαφές; + Διαμοιρασμός αρχείου… + Διαμοιρασμός συνδέσμου + Διαμοιρασμός πολυμέσων… + Διαμοιρασμός μηνύματος… + Διαμοιρασμός παλιάς διεύθυνσης + Διαμοιρασμός παλιού συνδέσμου + Διαμοιρασμός προφίλ + Διαμοιρασμός διεύθυνσης SimpleX σε εφαρμογές κοινωνικής δικτύωσης. + Διαμοιρασμός αυτού του συνδέσμου 1-χρήσης + Διαμοιρασμός με τις επαφές + Διαμοιρασμός της διεύθυνσής σου + Σύντομη περιγραφή: + Σύντομος σύνδεσμος + Σύντομη διεύθυνση SimpleX + Εμφάνιση + Εμφάνιση: + Εμφάνιση λίστας μηνυμάτων σε νέο παράθυρο + Εμφάνιση κονσόλας τερματικού σε νέο παράθυρο + Εμφάνιση επαφής και μηνύματος + Εμφάνιση επιλογών για προγραμματιστές + Εμφάνιση πληροφοριών για + Εμφάνιση εσωτερικών σφαλμάτων + Εμφάνιση τελευταίων μηνυμάτων + Εμφάνιση κατάστασης μηνύματος + Εμφάνιση μόνο της επαφής + Εμφάνιση ποσοστού + Εμφάνιση προεπισκόπησης + Εμφάνιση κωδικού QR + Εμφάνιση αργών κλήσεων API + Απενεργοποίηση + Απενεργοποίηση; + SImpleX + SimpleX διεύθυνση + Διεύθυνση SimpleX + Η διεύθυνση SimpleX και οι σύνδεσμοι 1-χρήσης είναι ασφαλές να διαμοιράζονται μέσω οποιασδήποτε εφαρμογής ανταλλαγής μηνυμάτων. + Διεύθυνση SimpleX ή σύνδεσμος 1-χρήσης; + Το SimpleX δεν μπορεί να λειτουργήσει στο παρασκήνιο. Θα λαμβάνεις τις ειδοποιήσεις μόνο όταν η εφαρμογή είναι σε λειτουργία. + Σύνδεσμος καναλιού SimpleX + Η SimpleX Chat και η Flux σύναψαν συμφωνία για την ενσωμάτωση των διακομιστών που λειτουργεί η Flux, στην εφαρμογή. + Κλήσεις SimpleX Chat + Μηνύματα SimpleX Chat + Η ασφάλεια του SimpleX Chat ελέγχθηκε από την Trail of Bits. + Υπηρεσία SimpleX Chat + Διεύθυνση επικοινωνίας SimpleX + Σύνδεσμος ομάδας SimpleX + Σύνδεσμοι SimpleX + Σύνδεσμοι SimpleX + Οι σύνδεσμοι SimpleX απαγορεύονται. + Οι σύνδεσμοι SimpleX δεν επιτρέπονται + SimpleX Lock + SimpleX Lock + Λειτουργία SimpleX Lock + Το SimpleX Lock δεν είναι ενεργοποιημένο! + Το SimpleX Lock είναι ενεργοποιημένο + SimpleX Logo + simplexmq: v%s (%2s) + Πρόσκληση 1-χρήσης SimpleX + Πρωτόκολλα SimpleX που έχουν ελεγχθεί από την Trail of Bits. + Σύνδεσμος αναμεταδότη SimpleX + SimpleX Team + Απλοποιημένη ανώνυμη λειτουργία + %s δεν έχει επαληθευτεί + %s έχει επαληθευτεί + Μέγεθος + Παράλειψη πρόσκλησης μελών + Παραλειπόμενα μηνύματα + Παράλειψη αυτής της έκδοσης + Αργή λειτουργία + Μικρές ομάδες (μέγιστο 20 άτομα) + Διακομιστής SMP + Διακομιστές SMP + Διακομιστής μεσολάβησης SOCKS + ΔΙΑΚΟΜΙΣΤΗΣ ΜΕΣΟΛΑΒΗΣΗΣ SOCKS + Ρυθμίσεις διακομιστή μεσολάβησης SOCKS + Απαλό + Κάποιο/α αρχείο/α δεν εξήχθησαν + Κατά την εισαγωγή προέκυψαν ορισμένα μη κρίσιμα σφάλματα: + Ορισμένοι διακομιστές απέτυχαν στη δοκιμή: + Ήχος σε σίγαση + Spam + Spam + Ηχείο + Απενεργοποίηση ηχείου + Εεργοποίηση ηχείου + Τετράγωνο, κύκλος ή οτιδήποτε μεταξύ τους. + %s: %s + %s, %s και %d μέλη + %s, %s και %d άλλα μέλη συνδεδεμένα + %s, %s και %s συνδεδεμένα + %s δευτερόλεπτο/α + %s διακομιστές + Σταθερή + τυποποιημένη κρυπτογράφηση από άκρη-σε-άκρη + Αστέρι στο GitHub + Εκκίνηση συνομιλίας + Εκκίνηση συνομιλίας; + εκκινεί… + Εκκινεί από %s. + Εκκινεί από %s.\nΌλα τα δεδομένα παραμένουν ιδιωτικά στη συσκευή σου. + Εκκίνηση νέας συνομιλίας + Εκκινεί περιοδικά + Στατιστικά + Διακοπή + Διακοπή + Διακοπή συνομιλίας + Διακοπή συνομιλίας; + Διέκοψε τη συνομιλία για να εξάγεις, να εισάγεις ή να διαγράψεις τη βάση δεδομένων συνομιλιών. Δεν θα μπορείς να λαμβάνεις και να στέλνεις μηνύματα ενώ η συνομιλία έχει διακοπεί. + Διακοπή αρχείου + Διακοπή συνομιλίας + Διακοπή λήψης αρχείου; + Διακοπή αποστολής αρχείου; + Διακοπή διαμοιρασμού + Διακοπή διαμοιρασμού διεύθυνσης; + διαγράμμιση + Έντονο + Υποβολή + Εγγεγραμμένος + Σφάλματα εγγραφής + Η εγγραφή αγνοήθηκε + %s ανεβασμένα + Υποστήριξη bluetooth και άλλων βελτιώσεων. + ΥΠΟΣΤΗΡΙΞΗ SIMPLEX CHAT + Ενάλλαξε + Εναλλαγή ήχου και βίντεο κατά τη διάρκεια της κλήσης. + Αλλαγή προφίλ συνομιλίας για προσκλήσεις 1-χρήσης. + Σύστημα + Σύστημα + Σύστημα + Σύστημα + Αυθεντικοποίηση συστήματος + Λειτουργία συστήματος + Ουρά + Πάτα το κουμπί + Επαλήθευση κωδικού στο κινητό + Επαλήθευση κωδικού με υπολογιστή + Επαλήθευση σύνδεσης + Επαλήθευση συνδέσεων + Επαλήθευση ασφάλειας σύνδεσης + Επαλήθευση φράσης πρόσβασης της βάσης δεδομένων + Επαλήθευση φράσης πρόσβασης + Επαλήθευση κωδικού ασφαλείας + μέσω %1$s + Μέσω περιηγητή + μέσω του συνδέσμου διεύθυνσης επαφής + μέσω συνδέσμου ομάδας + μέσω συνδέσμου 1-χρήσης + μέσω αναμεταδότη + Μέσω ασφαλούς κβαντο-ανθεκτικού πρωτοκόλλου + βίντεο + Βίντεο + Βίντεο + βιντεοκλήση + Βιντεοκλήση + βιντεοκλήση (χωρίς κρυπτογράφηση e2e) + Βίντεο απενεργοποιημένο + Βίντεο ενεργοποιημένο + Βίντεο και αρχεία εώς 1gb + Βίντεο απεστάλη + Το βίντεο θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή του. + Το βίντεο θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Δες τους όρους + Προβολή κωδικού ασφαλείας + Προβολή ενημερωμένων συνθηκών + Ορατό ιστορικό + Φωνητικό μήνυμα + Φωνητικό μήνυμα… + Φωνητικό μήνυμα (%1$s) + Φωνητικά μηνύματα + Φωνητικά μηνύματα + Τα φωνητικά μηνύματα απαγορεύονται. + Τα φωνητικά μηνύματα απαγορεύονται σε αυτήν τη συνομιλία. + Τα φωνητικά μηνύματα δεν επιτρέπονται + Τα φωνητικά μηνύματα απαγορεύονται! + - φωνητικά μηνύματα εώς 5 λεπτά.\n- προσαρμοσμένος χρόνος εξαφάνισης.\n- ιστορικό επεξεργασίας. + αναμονή για απάντηση… + αναμονή για επιβεβαίωση… + Αναμονή για τον υπολογιστή… + Αναμονή για το αρχείο + Αναμονή για την εικόνα + Αναμονή για την εικόνα + Αναμονή σύνδεσης κινητού: + Αναμονή για το βίντεο + Αναμονή για το βίντεο + Χρωματική έμφαση ταπετσαρίας + Φόντο ταπετσαρίας + θέλει να συνδεθεί μαζί σου! + Προειδοποίηση: η έναρξη συνομιλίας σε πολλαπλές συσκευές δεν υποστηρίζεται και θα προκαλέσει σφάλματα στην παράδοση των μηνυμάτων. + Προειδοποίηση: ενδέχεται να χάσεις ορισμένα δεδομένα! + Διακομιστές WebRTC ICE + Ιστοσελίδα + Δεν αποθηκεύουμε καμία από τις επαφές ή τα μηνύματά σου (αφού παραδοθούν) στους διακομιστές. + εβδομάδες + Καλωσόρισες! + Καλωσόρισες %1$s! + Μήνυμα καλωσορίσματος + Μήνυμα καλωσορίσματος + Μήνυμα καλωσορίσματος + Το μήνυμα καλωσορίσματος είναι πολύ μεγάλο + Καλωσόρισε τις επαφές σου 👋 + Τι νέο υπάρχει + Όταν η εφαρμογή είναι σε λειτουργία + Όταν είναι διαθέσιμο + Κατά τη σύνδεση κλήσεων ήχου και βίντεο. + Όταν η IP είναι κρυφή + Όταν είναι ενεργοποιημένοι περισσότεροι από ένας χειριστές, κανένας από αυτούς δεν διαθέτει μεταδεδομένα για να μάθει ποιος επικοινωνεί με ποιον. + Όταν κάποιος ζητήσει να συνδεθεί, μπορείς να αποδεχτείς ή να απορρίψεις το αίτημα. + Όταν μοιράζεσε ένα ανώνυμο προφίλ με κάποιον, αυτό το προφίλ θα χρησιμοποιείται για τις ομάδες στις οποίες σε προσκαλούν. + WiFi + Θα ενεργοποιηθεί στις άμεσες συνομιλίες! + Ενσύρματο ethernet + Με κρυπτογραφημένα αρχεία και μέσα. + Με προαιρετικό μήνυμα καλωσορίσματος. + Χωρίς Tor ή VPN, η διεύθυνση IP σου θα είναι ορατή στους διακομιστές αρχείων. + Χωρίς Tor ή VPN, η διεύθυνση IP σου θα είναι ορατή σε αυτούς τους XFTP αναμεταδότες:\n%1$s. + Με μειωμένη χρήση της μπαταρίας. + Με μειωμένη χρήση της μπαταρίας. + Λανθασμένη φράση πρόσβασης της βάσης δεδομένων + Λανθασμεο κλειδί ή άγνωστη σύνδεση - πιθανότατα αυτή η σύνδεση έχει διαγραφεί. + Λανθασμένο κλειδί ή άγνωστη διεύθυνση τμήματος αρχείου - πιθανότατα το αρχείο έχει διαγραφεί. + Λανθασμένη φράση πρόσβασης! + Διακομιστής XFTP + Διακομιστές XFTP + ναι + Ναι + Ναι + εσύ + ΕΣΥ + εσύ: %1$s + Αποδέχθηκες τη σύνδεση + αποδέχθηκες αυτό το μέλος + Επιτρέπεις + Έχεις ήδη ένα προφίλ συνομιλίας με το ίδιο όνομα εμφάνισης. Παρακαλώ επέλεξε ένα άλλο όνομα. + Είσαι ήδη συνδεδεμένος στο %1$s. + Ήδη συνδέεσαι μέσω αυτού του μοναδικού συνδέσμου! + Έχεις ήδη ενταχθεί στην ομάδα μέσω αυτού του συνδέσμου. + Είσαι προσκεκλημένος στην ομάδα + Είσαι προσκεκλημένος στην ομάδα + Είσαι προσκεκλημένος στην ομάδα. Αποδέξου την πρόσκληση για να συνδεθείς με τα μέλη της ομάδας. + Δεν είσαι συνδεδεμένος στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτή τη σύνδεση (δεν υπάρχει συνδρομή). + Δεν είσαι συνδεδεμένος σε αυτούς τους διακομιστές. Για την παράδοση μηνυμάτων σε αυτούς, χρησιμοποιείται ιδιωτική δρομολόγηση. + είσαι παρατηρητής + είσαι παρατηρητής + μπλόκαρες %s + Μπορείς να το αλλάξεις στις ρυθμίσεις Εμφάνισης. + Μπορείς να διαμορφώσεις τους χειριστές στις ρυθμίσεις Δικτύου & διακομιστών. + Μπορείς να διαμορφώσεις τους διακομιστές μέσω των ρυθμίσεων. + Μπορείς να αντιγράψεις και να μειώσεις το μέγεθος του μηνύματος για να το στείλεις. + Μπορείς να το δημιουργήσεις αργότερα + Μπορείς να το ενεργοποιήσεις αργότερα μέσω των Ρυθμίσεων. + Μπορείς να τις ενεργοποιήσεις αργότερα μέσω των ρυθμίσεων απορρήτου και ασφάλειας της εφαρμογής. + Μπορείς να δοκιμάσεις ξανά. + Μπορείς να δοκιμάσεις ξανά. + Μπορείς να αποκρύψεις ή να σιγάσεις ένα προφίλ χρήστη - κράτησέ το πατημένο για να εμφανιστεί το μενού. + Μπορείς να το κάνεις ορατό στις επαφές σου στο SimpleX μέσω των Ρυθμίσεων. + Μπορείς να αναφέρεις εώς και %1$s μέλη ανά μήνυμα! + Μπορείς να στείλεις μηνύματα στην επαφή %1$s από τις αρχειοθετημένες επαφές. + Μπορείς να ορίσεις το όνομα της σύνδεσης για να θυμάσε με ποιον μοιράστηκες το σύνδεσμο. + Μπορείς να μοιραστείς ένα σύνδεσμο ή έναν κωδικό QR - οποιοσδήποτε θα μπορεί να συμμετάσχει στην ομάδα. Δεν θα χάσεις μέλη της ομάδας αν τον διαγράψεις αργότερα. + Μπορείς να μοιραστείς αυτήν τη διεύθυνση με τις επαφές σου για να τους επιτρέψεις να συνδεθούν με την επαφή %s. + Μπορείς να διαμοιραστείς τη διεύθυνσή σου ως σύνδεσμο ή κωδικό QR - οποιοσδήποτε θα μπορεί να συνδεθεί μαζί σου. + Μπορείς να ξεκινήσεις τη συνομιλία μέσω της εφαρμογής Ρυθμίσεις / Βάση δεδομένων ή επανεκκινώντας την εφαρμογή. + Μπορείς ακόμα να δεις τη συνομιλία με την επαφή %1$s, στη λίστα των συνομιλιών. + Δεν μπορείς να στείλεις μηνύματα! + Μπορείς να ενεργοποιήσεις το SimpleX Lock μέσω των Ρυθμίσεων. + Μπορείς να χρησιμοποιήσεις σύνταξη markdown για να μορφοποιήσεις τα μηνύματα: + Μπορείς να δεις ξανά το σύνδεσμο πρόσκλησης στις λεπτομέρειες σύνδεσης. + Μπορείς να δείς τις αναφορές σου στη Συνομιλία με τους διαχειριστές. + άλλαξες διεύθυνση + άλλαξες διεύθυνση για %s + άλλαξες ρόλο για τον εαυτό σου σε %s + άλλαξες το ρόλο του μέλους %s σε %s + Έχεις τον έλεγχο της συνομιλίας σου! + Δεν ήταν δυνατή η επαλήθευση. Παρακαλώ, δοκίμασε ξανά. + Εσύ αποφασίζεις ποιος μπορεί να συνδεθεί. + Έχεις ήδη ζητήσει σύνδεση μέσω αυτής της διεύθυνσης! + Δεν έχεις συνομιλίες + Πρέπει να εισάγεις τη φράση πρόσβασης κάθε φορά που ξεκινά η εφαρμογή - δεν αποθηκεύεται στη συσκευή. + Προσκάλεσες μία επαφή + Εντάχθηκες σε αυτήν την ομάδα + Έχεις ενταχθεί σε αυτή την ομάδα. Σύνδεση με το μέλος που σε προσκάλεσε. + αποχώρησες + αποχώρησες + Μπορείς να μεταφέρεις την εξαγώμενη βάση δεδομένων. + Μπορείς να αποθηκεύσεις το εξαγώμενο αρχείο. + Πρέπει να χρησιμοποιήσεις την πιο πρόσφατη έκδοση της βάσης δεδομένων συνομιλιών σου σε ΜΟΝΟ μία συσκευή, διαφορετικά ενδέχεται να σταματήσεις να λαμβάνεις μηνύματα από ορισμένες επαφές. + Πρέπει να επιτρέψεις στην επαφή σου να σε καλέσει για να μπορείς να την καλέσεις πίσω. + Για να μπορείς να στέλνεις φωνητικά μηνύματα, πρέπει να επιτρέψεις στην επαφή σου να στέλνει φωνητικά μηνύματα. + Η επαγγελματική σου επαφή + Η κλήσεις σου + Η βάση δεδομένων συνομιλιών σου + Η βάση δεδομένων συνομιλιών σου δεν είναι κρυπτογραφημένη - όρισε μία φράση πρόσβασης για να την προστατεύσεις. + Τα προφίλ συνομιλιών σου + Το προφίλ συνομιλίας σου θα σταλεί στα μέλη της συνομιλίας. + Το προφίλ συνομιλίας σου θα σταλεί στα μέλη της ομάδας. + Η σύνδεσή σου μεταφέρθηκε στο προφίλ %s, αλλά προέκυψε σφάλμα κατά την εναλλαγή του. + Η επαφή σου + Η επαφή σου πρέπει να είναι συνδεδεμένη στο διαδίκτυο για να ολοκληρωθεί η σύνδεση.\nΜπορείς να ακυρώσεις αυτήν τη σύνδεση και να καταργήσεις την επαφή (και να δοκιμάσεις αργότερα με έναν νέο σύνδεσμο). + Οι επαφές σου + Οι επαφές σου μπορούν να επιτρέψουν την πλήρη διαγραφή μηνυμάτων. + Τα διαπιστευτήριά σου ενδέχεται να αποσταλούν χωρίς κρυπτογράφηση. + Η τρέχουσα βάση δεδομένων συνομιλιών σου θα ΔΙΑΓΡΑΦΕΙ και θα ΑΝΤΙΚΑΤΑΣΤΑΘΕΙ με την εισαγώμενη.\nΑυτή η ενέργεια δεν μπορεί να αναιρεθεί - το προφίλ, οι επαφές, τα μηνύματα και τα αρχεία σου θα χαθούν οριστικά. + Προσπαθείς να προσκαλέσεις μία επαφή με την οποία έχεις μοιραστεί ένα ανώνυμο προφίλ στην ομάδα στην οποία χρησιμοποιείς το κύριο προφίλ σου. + Χρησιμοποιείς ένα ανώνυμο προφίλ για αυτήν την ομάδα - για να αποφύγεις την κοινή χρήση του κύριου προφίλ σου, δεν επιτρέπεται η πρόσκληση επαφών. + Η ομάδα σου + Το προφίλ σου + Το προφίλ σου αποθηκεύεται στη συσκευή σου και κοινοποιείται μόνο στις επαφές σου. Οι διακομιστές της SimpleX δεν μπορούν να δουν το προφίλ σου. + Οι διακομιστές σου + διαμοιράστηκες ένα σύνδεσμο 1-χρήσης + διαμοιράστηκες ένα σύνδεσμο 1-χρήσης ανώνυμα + ξεμπλόκαρες %s + Θα συνδεθείς στην ομάδα όταν η συσκευή του διαχειριστή της ομάδας είναι συνδεδεμένη στο διαδίκτυο. Παρακαλώ περίμενε ή έλεγξε αργότερα! + Θα συνδεθείς όταν γίνει αποδεκτό το αίτημά σου για σύνδεση. Παρακαλώ περίμενε ή έλεγξε αργότερα! + Θα σου ζητηθεί να πραγματοποιήσεις έλεγχο ταυτότητας όταν ξεκινήσεις ή συνεχίσεις την εφαρμογή μετά από 30 δευτερόλεπτα στο παρασκήνιο. + Θα συνεχίσεις να λαμβάνεις κλήσεις και ειδοποιήσεις από τα προφίλ που έχεις σε σίγαση όταν αυτά θα είναι ενεργά. + Δεν θα λαμβάνεις πλέον μηνύματα από αυτήν τη συνομιλία. Το ιστορικό συνομιλιών θα διατηρηθεί. + Δεν θα λαμβάνεις πλέον μηνύματα από αυτήν την ομάδα. Το ιστορικό συνομιλιών θα διατηρηθεί. + Δεν θα χάσεις τις επαφές σου αν διαγράψεις αργότερα τη διεύθυνσή σου. + Μεγέθυνση + Όλα τα μηνύματα + Αρχεία + ΦΙλτράρισμα + Εικόνες + Σύνδεσμοι + Αναζήτηση αρχείων + Αναζήτηση εικόνων + Αναζήτηση συνδέσμων + Αναζήτηση βίντεο + Αναζήτηση φωνητικών μηνυμάτων + Βίντεο + Φωνητικά μηνύματα + Η σύνδεση απέτυχε + απέτυχε + Αν έχετε συμμετάσχει ή δημιουργήσει κανάλια, θα σταματήσουν να λειτουργούν μόνιμα. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index fa356715db..7088c54d9b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -329,7 +329,7 @@ Archivo: %s ¡Error al cambiar perfil! Añadir manualmente - Cómo usar los servidores + Cómo usar tus servidores Error al parar SimpleX Introduce la contraseña correcta. Introduce la contraseña… @@ -358,7 +358,7 @@ Los miembros pueden enviar mensajes de voz. en modo incógnito mediante dirección de contacto ¡Error al crear perfil! - No se pudo cargar el chat + Fallo en la carga del chat Fallo en la carga de chats Enlace completo Error al eliminar contacto @@ -381,11 +381,10 @@ Servidores ICE (uno por línea) Nombre completo: Tu decides quién se conecta. - Cómo funciona SimpleX Colgar Archivos y multimedia ¡Grupo no encontrado! - perfil de grupo actualizado + perfil del grupo actualizado Error al crear enlace de grupo Error al eliminar enlace de grupo activado para el contacto @@ -571,7 +570,7 @@ Establecer una conexión privada Comprueba tu conexión de red con %1$s e inténtalo de nuevo. El remitente puede haber eliminado la solicitud de conexión. - Posiblemente la huella del certificado en la dirección del servidor es incorrecta + La huella en la dirección del servidor no coincide con el certificado. Responder Guardar contraseña en Keystore Error al restaurar base de datos @@ -748,11 +747,11 @@ Para proteger tu información, activa el Bloqueo SimpleX. \nSe te pedirá que completes la autenticación antes de activar esta función. Para actualizar la configuración el cliente se reconectará a todos los servidores. - ¿Usar servidores SimpleX Chat\? + ¿Usar servidores de SimpleX Chat? Enlace de grupo SimpleX Invitación SimpleX de un uso Enlaces SimpleX - El servidor requiere autorización para crear colas, comprueba la contraseña + El servidor requiere autorización para crear colas, comprueba la contraseña. Para recibir notificaciones, introduce la contraseña de la base de datos Llamadas de chat SimpleX Cíclico @@ -804,9 +803,9 @@ El rol cambiará a %s y el miembro recibirá una invitación nueva. Actualizar ¿Actualizar la configuración de red\? - Intentando conectar con el servidor para recibir mensajes de este contacto. + Intentando conectar con el servidor usado para recibir mensajes de esta conexión. formato de mensaje desconocido - Intentando conectar con el servidor para recibir mensajes de este contacto (error: %1$s). + Error al conectar con el servidor usado para recibir mensajes de esta conexión: (error: %1$s). Prueba no superada en el paso %s. Pulsa para iniciar chat nuevo Compartir mensaje… @@ -819,7 +818,7 @@ Cambiar servidor de recepción Totalmente descentralizado. Visible sólo para los miembros. Para conectarte mediante enlace - ¡Prueba no superada! + ¡Prueba del servidor no superada! Algunos servidores no han superado la prueba: Usar servidor Para conexiones nuevas @@ -880,7 +879,7 @@ Has sido invitado a un grupo. Únete para conectar con sus miembros. has expulsado a %1$s Tú: %1$s - Puedes compartir el enlace o el código QR para que cualquiera pueda unirse al grupo. Si más tarde lo eliminas, no afectará a los miembros del grupo. + Puedes compartir el enlace o código QR. Cualquiera podrá unirse al grupo. Si más tarde lo eliminas, no afectará a los miembros del grupo. Cuando compartes un perfil incógnito con alguien, este perfil también se usará para los grupos a los que te inviten. Mis preferencias Con mensaje de bienvenida opcional. @@ -916,7 +915,7 @@ Tu perfil se enviará al contacto del que has recibido este enlace. Conectarás con todos los miembros del grupo. tu - Estás conectado al servidor usado para recibir mensajes de este contacto. + Estás conectado al servidor usado para recibir mensajes de esta conexión. mediante dirección de contacto mediante enlace de grupo enlace de un solo uso @@ -937,7 +936,7 @@ Tu servidor Dirección de tu servidor Tu perfil actual - Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. + Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil. Sistema Añadir mensaje de bienvenida Llamadas y videollamadas @@ -1016,7 +1015,7 @@ Error al cargar servidores XFTP Error al cargar servidores SMP Asegúrate de que las direcciones del servidor XFTP tienen el formato correcto, están separadas por líneas y no están duplicadas. - El servidor requiere autorización para subir, comprueba la contraseña + El servidor requiere autorización para subir, comprueba la contraseña. Comparar archivo Crear archivo Eliminar archivo @@ -1117,7 +1116,7 @@ Fondo Exportar tema Menús y alertas - Añade la dirección a tu perfil para que tus contactos puedan compartirla con otros. La actualización del perfil se enviará a tus contactos. + Añade la dirección a tu perfil para que tus contactos SimpleX puedan compartirla con otros. La actualización del perfil se enviará a tus contactos SimpleX. Acerca de la dirección SimpleX Dirección Todos tus contactos permanecerán conectados. La actualización del perfil se enviará a tus contactos. @@ -1134,7 +1133,7 @@ Invitar amigos Hablemos en SimpleX Chat Asegúrate de que el archivo tiene la sintaxis YAML correcta. Exporta el tema para tener un ejemplo de la estructura del archivo de tema. - La actualización del perfil se enviará a tus contactos. + La actualización del perfil se enviará a tus contactos SimpleX. Mensaje recibido Guardar configuración de dirección SimpleX Puedes compartir tu dirección como enlace o código QR para que cualquiera pueda conectarse contigo. @@ -1145,8 +1144,8 @@ ¿Dejar de compartir la dirección\? COLORES DE LA INTERFAZ Puedes crearla más tarde - ¿Compartir la dirección con los contactos\? - Compartir con contactos + ¿Compartir la dirección con los contactos SimpleX? + Compartir con contactos SimpleX Título Puedes compartir esta dirección con tus contactos para que puedan conectar con %s. Tus contactos permanecerán conectados. @@ -1364,7 +1363,7 @@ Abrir Cifra archivos almacenados y multimedia Error al establecer contacto con el miembro - Recuerda: los servidores están conectados mediante proxy SOCKS, pero las llamadas y las previsualizaciones de enlaces usan conexión directa.]]> + Recuerda: los servidores están conectados mediante proxy SOCKS. Las llamadas usan conexión directa.]]> Cifrar archivos locales Nueva aplicación para ordenador! 6 nuevos idiomas para la interfaz @@ -1430,7 +1429,7 @@ Conectar con ordenador Desconectar ¿Bloquear miembro? - %d evento(s) de grupo + %d evento(s) del grupo ¡Nombre no válido! Conectado a móvil Dirección ordenador incorrecta @@ -1732,7 +1731,7 @@ Descargar Reenviar Reenviado - Mensaje reenviado… + Reenviando mensaje… Los destinatarios no ven de quién procede este mensaje. Bluetooth Concurrencia en la recepción @@ -1906,10 +1905,10 @@ Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse! Descargado Servidor SMP - Aún no hay conexión directa, el mensaje es reenviado por el administrador. + Aún no hay conexión directa, los mensajes son reenviados por el administrador. Otros servidores SMP Otros servidores XFTP - Escanear / Pegar enlace + Pegar enlace / Escanear Mostrar porcentaje Desactivar Desactivado @@ -2041,7 +2040,6 @@ Ningún contacto filtrado Difumina para mayor privacidad Crear - Alternar lista de chats: Ajusta el tamaño de la fuente. Reproduce desde la lista de chats. Actualizar la aplicación automáticamente @@ -2132,7 +2130,7 @@ Operador Servidores predefinidos Revisar condiciones - %s servidores + Servidores %s Las condiciones serán aceptadas el: %s. Condiciones de uso Para enrutamiento privado @@ -2166,7 +2164,7 @@ Sin servidores para recibir mensajes. Servidor del operador O para compartir en privado - Selecciona los operadores de red a utilizar + Selecciona operadores a usar. Compartir dirección públicamente Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio. Actualizar @@ -2223,7 +2221,7 @@ Sólo los propietarios del chat pueden cambiar las preferencias. El miembro será eliminado del chat. ¡No puede deshacerse! El rol cambiará a %s. Se notificará en el chat. - Dejarás de recibir mensajes del chat. El historial del chat se conserva. + Dejarás de recibir mensajes del chat. El historial del chat se conservará. Cómo ayuda a la privacidad Cuando está habilitado más de un operador, ninguno dispone de los metadatos para conocer quién se comunica con quién. Tu perfil de chat será enviado a los miembros de chat @@ -2374,9 +2372,8 @@ La frase de contraseña no se ha podido leer en Keystore. Por favor, introdúcela manualmente. Puede deberse a alguna actualización del sistema incompatible con la aplicación. Si no es así, por favor, ponte en contacto con los desarrolladores. Aceptar Política de privacidad y condiciones de uso. - Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores. - Al usar SimpleX Chat, aceptas:\n- enviar únicamente contenido legal en los grupos públicos.\n- respetar a los demás usuarios - spam prohibido. - Configurar operadores de servidores + Los operadores se comprometen a:\n- Ser independientes\n- Minimizar el tratamiento de metadatos\n- Ejecutar código open-source verificado + Te comprometes a:\n- Sólo contenido legal en grupos públicos \n- Respetar a los demás usuarios — no hacer spam Enlace de canal SimpleX Enlace completo Enlace corto @@ -2391,7 +2388,7 @@ Error al aceptar el miembro ¿Guardar configuración? Por favor, espera a que tu solicitud sea revisada por los moderadores del grupo. - has aceptado al miembro + has admitido al miembro pendiente de revisión por revisar Chat con administradores @@ -2402,7 +2399,7 @@ Admisión de miembros el miembro usa una versión antigua Sin chats - Error al eliminar el chat con el miembro + Error al eliminar el chat %d chats con miembros %d mensajes un chat con miembro @@ -2419,7 +2416,7 @@ ¡No puedes enviar mensajes! Puedes ver tus informes en Chat con administradores has salido - te ha aceptado + te ha admitido Un miembro nuevo desea unirse al grupo. todos Chat con miembros @@ -2530,6 +2527,275 @@ Abrir enlace limpio Limpiar enlaces de seguimiento Abrir enlace completo - Enlace de servidor SimpleX - Error al marcar el chat con miembro como leído + Dirección de servidor SimpleX + Error al marcar como leído + La huella en la dirección del servidor no coincide con el certificado: %1$s. + La huella en la dirección del servidor de destino no coincide con el certificado: %1$s. + La huella en la dirección del servidor de reenvío no coincide con el certificado: %1$s. + Sin suscripciones + No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito). + Eliminar mensajes del miembro + ¿Eliminar mensajes del miembro? + Eliminar mensajes + Los mensajes del miembro serán eliminados. ¡No puede deshacerse! + Eliminar miembro y sus mensajes + Todos los mensajes + Archivos + Filtro + Imágenes + Enlaces + Buscar archivos + Buscar imágenes + Buscar enlaces + Buscar vídeos + Buscar mensajes de voz + Vídeos + Mensajes de voz + %1$d/%2$d servidores activos + %1$d/%2$d servidores activos, %3$d con fallo + %1$d/%2$d servidores conectados + %1$d/%2$d servidores conectados, %3$d errores + aceptado + activo + Test del servidor para recibir su nombre.]]> + El perfil del canal se almacena en los dispositivos de los suscriptores y en los servidores de chat. + El canal comenzará a funcionar con %1$d de %2$d servidores. ¿Continuar? + Servidores de chat + Servidores de chat + Servidores de chat + Servidores de chat + Los servidores de chat reenvían los mensajes en los canales que has creado. + Los servidores de chat reenvían los mensajes a los suscriptores del canal. + Comprueba la dirección del servidor y prueba de nuevo. + Comprueba el nombre del servidor y prueba de nuevo. + Configurar servidores + Conectar + conectado + conectando + Decodificar enlace + eliminado + Eliminar servidor + Activa al menos un servidor de chat para crear un canal. + Introduce el nombre del servidor… + Error al añadir el servidor + La conexión con el servidor ha fallado + ¡El test del servidor ha fallado! + Prueba no superada en el paso %s. + %1$d suscriptor + %1$d suscriptores + ¿Bloquear al suscriptor para todos? + Retransmisión + ¿Cancelar la creación del canal? + %1$s!]]> + canal + Canal + Canal + Título completo: + Enlace del canal + Miembros canal + Título del canal + perfil del canal actualizado + El canal será eliminado para todos los suscriptores. ¡No puede deshacerse! + El canal será eliminado para tí. ¡No puede deshacerse! + CONEXIÓN FALLIDA + Crear canal público + Crear canal público + Crear canal público (BETA) + Creando canal + %d eventos del canal + Eliminar canal + ¿Eliminar el canal? + canal eliminado + caído (%1$d intentos) + Editar perfil del canal + Error al crear el canal + Error al abrir el canal + error:%s + Error al guardar el perfil del canal + fallo + fallo + Recibir el enlace + Si te has unido o has creado canales, dejarán de funcionar permanentemente. + ¡Dirección de servidor no válido! + ¡Nombre de servidor no válido! + Invitado + Unirme al canal + Salir del canal + ¿Salir del canal? + Enlace + Mensaje de error + nuevo + Nuevo servidor de chat + Sin servidores de chat + Ningún servidor de chat activado. + Hay servidores no conectados + Abrir canal + Abrir canal nuevo + PROPIETARIO + Propietarios + Direcciones predefinidas + Nombres predefinidos + servidor + SERVIDOR + Dirección servidor + Dirección del servidor + Enlace servidor + Eliminar suscriptor + ¿Eliminar suscriptor? + Guardar y notificar suscriptores + Guardar perfil del canal + El servidor requiere autorización para conectar con el servidor, comprueba la contraseña. + Alerta del servidor + Compartir dirección del servidor + SUSCRIPTOR + Suscriptores + Los suscriptores usan el enlace del servidor para conectarse a los canales.\nLa dirección del servidor se usó para establecer el servidor para el canal. + El suscriptor será eliminado del canal. ¡No puede deshacerse! + Pulsa Unirme al canal + Test servidor + La app ha eliminado el mensaje tras %1$d intentos de recibirlo. + Esto es una dirección de servidor, no puede usarse para conectar. + ¿Desbloquear al suscriptor para todos? + perfil del canal actualizado + Usar para canales nuevos + Usar servidor + Verificar + mediante %1$s + La grabación de voz no es compatible con tu plataforma + Espera + Espera respuesta + tu + eres suscriptor + Puedes compartir un enlace o código QR. Cualquiera podrá unirse al canal. + Te conectaste al canal mediante este enlace de servidor. + Tu canal + Tu canal + El perfil %1$s será compartido con los servidores de canal y los suscriptores.\nLos servidores tienen acceso a los mensajes del canal. + Tu dirección de servidor + Tu nombre del servidor + Dejarás de recibir mensajes de este canal. El historial del chat se conservará. + fallo + Naciste sin una cuenta. + Nadie monitorizaba tus conversaciones. Nadie registraba tus ubicaciones. La privacidad nunca fue un lujo, era la manera de vivir. + Después pasamos a internet y cada plataforma pedía una parte de tí: tu nombre, tu número, tus amistades. Aceptamos que el precio de hablar con los demás es informar a alguien de quién es interlocutor. Cada generación, personas y tecnología, ha funcionado así: teléfono, email, mensajería, redes sociales. Parecía el único camino. + Existe otro camino. Una red sin números de teléfono. Sin nombres de usuario. Sin cuentas. Sin identificadores de ningún tipo. Una red que conecta las personas y entrega mensajes cifrados sin saber quien está conectado. + No un candado mejorado en la puerta de otro. No un terrateniente que respeta tu privacidad pero sigue guardando un registro de tus visitantes. Tu no eres el invitado. Estás en tu casa y ningún rey podrá entrar. Tu eres el soberano. + Tus conversaciones te pertenecen, tal como ha sido siempre antes de la llegada de internet. Tu red no es un lugar que visitas. Es un lugar que has creado, te pertenece y nadie te la podrá quitar, ya sea pública o privada. + La libertad más antigua del ser humano, la de hablar con otra persona sin ser observado, materializada sobre una infraestructura que no puede traicionarla. + Porque hemos destruido el poder de saber quien eres. De manera que tu poder nunca se pueda arrebatar. + Se libre en tu red. + + Informes de suscriptores + Se permiten mensajes directos entre suscriptores. + No se permiten mensajes directos entre suscriptores. + Se envían hasta 100 mensajes más recientes a los suscriptores nuevos. + No se envía el historial a los suscriptores nuevos. + Los suscriptores del canal pueden enviar mensajes temporales. + Los suscriptores del canal pueden enviar mensajes directos. + Los mensajes directos entre suscriptores del canal no están permitidos. + Los suscriptores del canal pueden eliminar mensajes de forma irreversible. (24 horas) + Los suscriptores pueden añadir reacciones a los mensajes. + Los suscriptores del canal pueden enviar mensajes de voz. + Los suscriptores del canal pueden enviar archivos y multimedia. + Los suscriptores del canal pueden enviar enlaces SimpleX. + Los suscriptores pueden informar de mensajes a los moderadores. + Hasta 100 últimos mensajes son enviados a los suscriptores nuevos. + El historial no se envía a suscriptores nuevos. + %1$d/%2$d servidores activos, %3$d errores + %1$d/%2$d servidores activos, %3$d servidores eliminados + %1$d/%2$d servidores conectados, %3$d con fallo + %1$d/%2$d servidores conectados, %3$d eliminados + %1$d servidores han fallado + %1$d servidores inactivos + %1$d servidores eliminados + Añadir servidores estará disponible en una versión posterior. + Enlace para un solo contacto + Permitir que los miembros chateen con administradores. + Permitir que los suscriptores chateen con administradores. + Todos los servidores han fallado + Todos los servidores eliminados + Se libre\nen tu red + Dirección empresarial + no puedes retransmitir + El canal no tiene servidores activos. Por favor, intenta unirte más tarde. + Enlace del canal + Preferencias del canal + Canales + Canales no disponibles temporalmente + Chat con administradores no permitido + El chat con administradores en el canal público no dispone de cifrado E2E. Úsalo sólo con servidores de confianza. + Chats con miembros desactivado + Chat con administradores + Conecta vía enlace o QR + Crea tu enlace + Dirección de contacto + Crea tu dirección pública + Desactivar + Invitar a tus amigos es más fácil 👋 + Activar + Activar + ¿Activar chat con administradores? + ¿Activar previsualización de enlaces? + Introduce el nombre del perfil… + Error + Error al compartir el canal + Para que cualquiera acceda a ti + (del propietario) + Empezar + Enlace de grupo + inactivo + Invitación privada + Conecta con alguien + Firma del enlace verificada + Los miembros pueden chatear con los administradores + Migrar + Error de red + Los routers de la red no pueden saber\nquién se comunica con quién + Nuevo enlace de 1 solo uso + Sin cuenta. Sin teléfono. Sin email. Sin ID.\nEl cifrado más seguro. + Sin servidores activos + Enlace de un solo uso + Sólo los propietarios pueden modificar las preferencias de los canales. + En tu teléfono, no en el servidor. + ¿Abrir enlace externo? + O muestra el código QR en persona o por videollamada. + O usa el QR, imprímelo o muestralo en línea. + En propiedad: puedes poner en marcha tus propios servidores. + Privacidad: para propietarios y suscriptores. + Mensajería segura y privada. + El chat con los administradores no está permitido. + Canales públicos - habla con libertad 🚀 + Resultados del servidor: + Fiabilidad: muchos servidores por canal. + eliminado por el operador + Enlaces web seguros + Seguridad: los propietarios tienen la llave del canal. + Enviar una previsualización del enlace puede revelar tu dirección IP al sitio web. Puedes cambiarlo más tarde en los ajustes de privacidad. + Envía el enlace con cualquier mensajero, es seguro. El contacto debe pegarlo en SimpleX. + Configurar notificaciones + Configurar routers + Compartir canal… + Compartir mediante chat + ⚠️ Verificación de firma fallida: %s. + (firmado) + Los suscriptores pueden chatear con los administradores. + Para comunicarte + Pulsa para abrir + La conexión ha alcanzado al límite de mensajes no entregados + La primera red donde los grupos\ny los contactos son tuyos. + Para que la Red SimpleX perdure. + Usa esta dirección en el perfil de tus redes sociales, página web o firma email. + Esperando a que el propietario del canal añada servidores. + Hemos simplificado la conexión para los usuarios nuevos. + Por qué fue creado SimpleX. + Tu red + Tu perfil + Tu dirección pública + no están cifrados de extremo a extremo. Los servidores pueden ver estos mensajes.]]> + Compromisos en la red + Gobernanza no lucrativa + - aceptar el envío de vistas previas de los enlaces. \n- usar proxy SOCKS si está hablilitado.\n- prevenir el phishing mediante hipervínculos. \n- eliminar el seguimiento de los enlaces. + Menú inferior + Las previsualizaciones de enlaces se solicitan a través del proxy SOCKS. Las peticiónes DNS aún pueden usar el DNS local del sistema. + Menú superior diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index cbb4849067..3f7d4ff025 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -35,7 +35,7 @@ متصل شدن مسیر نامعتبر فایل برنامه از کار افتاد - در حال تلاش برای اتصال به سرور مورد استفاده برای دریافت پیام‌ها از این مخاطب (خطا: %1$s). + در حال تلاش برای اتصال به سرور مورد استفاده برای دریافت پیام‌ها از این مخاطب (خطا: %1$s). حذف شد علامت گذاشته شده به عنوان حذف شده توسط %s حذف شد @@ -119,8 +119,8 @@ خطا در لغو تغییر نشانی خطا در انطباق زمانی اتصال آزمایش در گام %s ناموفق بود. - سرور برای بارگذازی به اجازه نیاز دارد، گذرواژه را بررسی کنید - احتمال دارد اثر انگشت گواهینامه در نشانی سرور نادرست باشد + سرور برای بارگذاری نیاز به مجوز دارد، گذرواژه را بررسی کنید. + اثر انگشت در نشانی سرور با گواهی مطابقت ندارد. ایجاد صف بارگذاری فایل بارگیری فایل @@ -140,7 +140,7 @@ صف امن حذف صف ایجاد فایل - سرور برای ایجاد صف داده‌ها به اجازه نیاز دارد، گذرواژه را بررسی کنید + سرور برای ایجاد صف‌ها نیاز به مجوز دارد، گذرواژه را بررسی کنید. مگر اینکه مخاطبتان اتصال را حذف کرده یا این لینک قبلا استفاده شده باشد، ممکن است این یک اشکال باشد - لطفا آن را گزارش دهید. \nبرای متصل شدن، لطفا از مخاطبتان بخواهید لینک اتصال دیگری ایجاد کند و بررسی کنید که اتصال شبکه باثباتی دارید. عملکرد کند @@ -730,7 +730,6 @@ تماس پذیرفته نامتمرکز پروفایل خود را ایجاد کنید - SimpleX چگونه کار می‌کند مخزن GitHub ما.]]> استفاده از چت بهترین گزینه برای باتری. شما اعلان‌ها را فقط وقتی دریافت می‌کنید که برنامه در حال اجراست (بدون سرویس پس‌زمینه).]]> @@ -998,7 +997,7 @@ شما را حذف کرد رمزنگاری مورد توافق قرار گرفت ناظر - عضو پیشین %1$s + اعضا %1$s بسط دادن انتخاب نقش عبارت عبور رمزنگاری پایگاه داده به‌روز خواهد شد. درخواست اتصال کرد @@ -1938,7 +1937,7 @@ خطا در فوروارد کردن پیام‌ها خطا در ایجاد گزارش خطا در پذیرفتن عضو - خطا در پاک کردن چت با عضو + خطا در حذف کردن چت پیوند اتصال پشتیبانی‌نشده این پیوند به نسخه جدیدتری از برنامه نیاز دارد. لطفاً برنامه را به‌روزرسانی کنید یا از مخاطب خود بخواهید پیوند سازگاری بفرستد. اتصال مسدود شد @@ -2183,7 +2182,6 @@ شرایط به‌طور خودکار برای اپراتورهای فعال در: %s پذیرفته خواهد شد. سرورهای SMP پیکربندی‌شده سرورهای XFTP پیکربندی‌شده - پیکربندی اپراتورهای سرور سرورهای متصل شده اتصال نیاز به تجدید مذاکره رمزنگاری دارد. اتصالات @@ -2426,7 +2424,6 @@ این پیام حذف شده یا هنوز دریافت نشده است. زمان ناپدید شدن فقط برای مخاطبان جدید تنظیم شده است. برای مطلع شدن از نسخه‌های جدید، بررسی دوره‌ای برای نسخه‌های Stable یا Beta را فعال کنید. - تغییر لیست چت: برای برقراری تماس، اجازه دهید از میکروفن شما استفاده شود. تماس را پایان دهید و دوباره تلاش کنید. برای جلوگیری از جایگزینی لینک شما، می‌توانید کدهای امنیتی مخاطب را مقایسه کنید. برای دریافت @@ -2528,4 +2525,9 @@ فقط مخاطب شما می‌تواند فایل‌ها و رسانه‌ها را ارسال کند. ارسال فایل‌ها و رسانه‌ها در این چت ممنوع است. پل ارتباطی سیمپلکس + خطا در علامت گذاری به عنوان خوانده شده + اثر انگشت در نشانی سرور مقصد با گواهی مطابقت ندارد: ‎%1$s. + اثر انگشت در نشانی سرور انتقال با گواهی مطابقت ندارد: ‎%1$s. + اثر انگشت در نشانی سرور با گواهی مطابقت ندارد: ‎%1$s. + پاک کردن پیام کاربر diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 1dd6598ef3..24634192ec 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -388,7 +388,6 @@ Miten Koko nimi: Miten markdownia käytetään - Miten SimpleX toimii Saapuva äänipuhelu Saapuva videopuhelu Sivuuta @@ -822,7 +821,7 @@ Arvioi sovellus Skannaa palvelimen QR-koodi Profiilipäivitys lähetetään kontakteillesi. - Jaa osoite kontakteille\? + Jaa osoite kontakteille? Lopeta jakaminen Lopeta osoitteen jakaminen\? Tallenna automaattisen hyväksynnän asetukset @@ -1036,7 +1035,7 @@ Profiilisi lähetetään kontaktille, jolta sait tämän linkin. Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin. Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. - Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta (virhe: %1$s). + Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta (virhe: %1$s). sinä kertalinkillä %1$s:n kautta diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 46b6e6e2fd..d95f8ad500 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -52,7 +52,7 @@ Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre. Erreur de connexion Erreur lors de l\'ajout de membre·s - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %1$s). + Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %1$s). format de message invalide Lien entier Erreur lors de la sauvegarde des serveurs SMP @@ -85,7 +85,7 @@ Les notifications périodiques sont désactivées ! Une phrase secrète est nécessaire Autoriser le dans la boîte de dialogue suivante pour recevoir des notifications instantanément.]]> - Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe + Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe. L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs. SimpleX fonctionne en arrière-plan au lieu d\'utiliser les notifications push.]]> Cacher @@ -447,7 +447,6 @@ Créez votre profil Établir une connexion privée Comment ça fonctionne - Comment SimpleX fonctionne Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages. GitHub repository.]]> Batterie peu utilisée. L\'app vérifie les messages toutes les 10 minutes. Vous risquez de manquer des appels ou des messages urgents.]]> @@ -1017,7 +1016,7 @@ Supprimer le fichier Erreur lors de la sauvegarde des serveurs XFTP Assurez-vous que les adresses des serveurs XFTP sont au bon format, séparées par des lignes et qu\'elles ne sont pas dupliquées. - Le serveur requiert une autorisation pour téléverser, vérifiez le mot de passe + Le serveur requiert une autorisation pour téléverser, vérifiez le mot de passe. Téléverser le fichier Serveurs XFTP Vos serveurs XFTP @@ -1056,7 +1055,7 @@ Système Authentification annulée ID du message incorrect - Le hash du message précédent est différent.\" + Le hash du message précédent est différent. L\'ID du message suivant est incorrect (inférieur ou égal au précédent). \nCela peut se produire en raison d\'un bug ou lorsque la connexion est compromise. Erreur de déchiffrement @@ -1105,7 +1104,7 @@ Vous pouvez accepter ou refuser les demandes de contacts. COULEURS DE L\'INTERFACE Vos contacts resteront connectés. - Partager l\'adresse avec vos contacts \? + Partager l\'adresse avec vos contacts ? Partager avec vos contacts Entrez un message de bienvenue… (facultatif) Cesser le partage @@ -1149,7 +1148,7 @@ Assurez-vous que le fichier a une syntaxe YAML correcte. Exporter le thème pour avoir un exemple de la structure du fichier du thème. La mise à jour du profil sera envoyée à vos contacts. Guide de l\'utilisateur.]]> - Enregistrer les paramètres de validation automatique + Enregistrer les paramètres de l\'adresse SimpleX Pour se connecter, votre contact peut scanner un code QR ou utiliser un lien dans l\'app. Le code d\'accès de l\'application est remplacé par un code d\'autodestruction. Activer l\'autodestruction @@ -1950,7 +1949,7 @@ Infos serveurs Afficher les informations pour À partir de %s. - À partir de %s. \nToutes les données restent confinées dans votre appareil. + À partir de %s.\nToutes les données restent confinées dans votre appareil. Statistiques Total Serveur XFTP @@ -2042,7 +2041,6 @@ %d sélectionné(s) Aperçu depuis la liste de conversation. Les messages seront marqués comme modérés pour tous les membres. - Afficher la liste des conversations : Vous pouvez choisir de le modifier dans les paramètres d\'apparence. Rétablir tous les conseils Mise à jour automatique de l\'app @@ -2361,4 +2359,90 @@ Quatres nouvelles langues d\'interface Partagez votre adresse Actualisez votre adresse + 1 discussion avec un membre + vous a accepté + Accepter le membre + actif + tous + Tous les messages + Permettre des fichiers et des médias seulement si votre contact les permet. + Permettre à vos contacts d\'envoyer des fichiers et des médias. + Tous les serveurs + seulement après que votre requête soit acceptée.]]> + Vérifiez l\'adresse de relais et essayez à nouveau. + Vérifiez le nom du relais et essayez à nouveau. + Se connecter + Se connecter + connecté + CONNEXION ÉCHOUÉE + contact supprimé + contact désactivé + le contact devrait accepter… + Créez votre adresse + supprimé + Supprimer les messages + Supprimer le relais + %d messages + Entrez le nom de relais.. + échoué + échoué + Fichiers + Les membres seront retirés du groupe - impossible de revenir en arrière! + Le membre va rejoindre le groupe, accepter le membre? + Les messages de ces membres seront affichés! + modérateurs + nouveau + Nouveau rôle de groupe: Modérateur + non synchronisé + Désactivé + Ouvrir pour se connecter + Ouvrez pour rejoindre + en attente + en attente d\'approbation + Veuillez attendre que les modérateurs de groupe examinent votre demande pour rejoindre le groupe. + Adresse de relais prédéfinie + Nom de relais prédéfini + Serveurs prédéfinis + Politique de confidentialité et conditions d\'utilisation. + Rejeter + Rejeter la demande de contact + rejeté + rejeté + Rejeter le membre? + relais + RELAIS + Adresse de relais + Adresse de relais + Échec de la connexion au relais + Lien du relais + Retirer et supprimer les messages + retiré du groupe + Retirer les membres? + Retire les messages et bloque les membres. + Rapport envoyé aux modérateurs + requête envoyée + examiné par les administrateurs + Examiner les membres du groupe + Examiner les membres + Envoyez une demande de contact? + Envoyer la demande + Envoyer la demande sans message + Envoyez vos commentaires privés aux groupes. + Envoyé à votre contact après la connexion. + Le serveur requiert une autorisation pour se connecter au relais, vérifiez le mot de passe. + Avertissement du serveur + Partager l\'ancienne adresse + Partager l\'ancien lien + Partager l\'adresse de relais + Description courte: + Lien court + Adresse SimpleX courte + Adresse du relais SimpleX + Port TCP pour la messagerie + Échec du test à l\'étape %s. + Tester le relais + L\'adresse sera courte et votre profil sera partagé via l\'adresse. + Le lien sera court, et le profil de groupe sera partagé via le lien. + L\'expéditeur n\'en sera PAS informé. + Ce réglage est pour votre profil actuel diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 3084b8569b..84e806dda0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -90,7 +90,6 @@ Domaćin Kako utiče na bateriju Kako pomaže privatnosti - Kako SimpleX radi Grupni profil je uskladnjen na uredjajima korisnika, ne na serverima. O operatorima Skrivena šifra profila diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index a3c6a65e32..2df64ae590 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -21,7 +21,7 @@ A SimpleXről Kiemelőszín fogadott hívás - Hozzáférés a kiszolgálókhoz SOCKS proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. + Hozzáférés a kiszolgálókhoz SOCKS proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt a beállítást. Elfogadás Elfogadás gombra fent, majd: @@ -29,7 +29,7 @@ Elfogadja a kapcsolódási kérést? Elfogadás Elfogadás - Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára. + Cím hozzáadása a profilhoz, hogy a SimpleX partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve a SimpleX partnerei számára. További kiemelőszín híváshiba Csoporttagok letiltása @@ -39,16 +39,16 @@ Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva. Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> - hivatkozás előnézetének visszavonása + hivatkozáselőnézet visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. Hibás az üzenet kivonata Háttér - Megjegyzés: az üzenet- és fájltovábbító kiszolgálók SOCKS proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének küldése közvetlen kapcsolatot használ.]]> + Megjegyzés: az üzenet- és a fájlátjátszók SOCKS proxyn keresztül kapcsolódnak. A hívások pedig közvetlen kapcsolatot használnak.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen - A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. + Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). Egy új, véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. @@ -59,7 +59,7 @@ Hang- és videóhívások Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével). Hívás fogadása - Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. + Az eltűnő üzenetek küldése engedélyezve van a partnerei számára. Kapcsolódás folyamatban! Nem lehet fogadni a fájlt Hitelesítés elérhetetlen @@ -70,7 +70,7 @@ Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) Továbbfejlesztett csoportok Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. - Hívás befejeződött + A hívás véget ért HÍVÁSOK és további %d esemény Cím @@ -86,18 +86,18 @@ Vissza Kikapcsolható a beállításokban – az értesítések továbbra is meg lesznek jelenítve amíg az alkalmazás fut.]]> Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. - Hívások a zárolási képernyőn: + Hívások a zárolási képernyőn titkosítás elfogadása… Nem lehet meghívni a partnert! hibás az üzenet azonosítója Partneri kapcsolatkérések automatikus elfogadása - Megjegyzés: NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti.]]> + Megjegyzés: NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti.]]> hívás… További másodlagos szín Hozzáadás egy másik eszközhöz A reakciók hozzáadása az üzenetekhez engedélyezve van. Fájlelőnézet visszavonása - Az összes csoporttag kapcsolatban marad. + Az összes csoporttag továbbra is kapcsolatban marad. Több akkumulátort használ! Az alkalmazás mindig fut a háttérben – az értesítések azonnal megjelennek.]]> Letiltás adminisztrátor @@ -114,11 +114,11 @@ Az alkalmazásjelkód helyettesítve lesz egy önmegsemmisítő jelkóddal. Arab, bolgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. Engedélyezi a hangüzeneteket? - Mindig használjon továbbítókiszolgálót + Mindig legyen használva átjátszó mindig - A hívás már befejeződött! + A hívás már véget ért! Engedélyezés - Az összes partnerével kapcsolatban marad. + Az összes partnerével továbbra is kapcsolatban marad. Élő csevegési üzenet visszavonása Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) Hang- és videóhívások @@ -130,7 +130,7 @@ Megjelenés Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek időszakos lekérdezését. Ezt a beállításokban újraengedélyezheti. Letiltja a tagot? - %1$s hívása befejeződött + %1$s hívása véget ért Jó akkumulátoridő. Az alkalmazás 10 percenként ellenőrzi az új üzeneteket. Előfordulhat, hogy hívásokról, vagy a sürgős üzenetekről marad le.]]> szerző Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra) @@ -165,7 +165,7 @@ A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva - hanghívás (nem e2e titkosított) + hanghívás (végpontok között NEM titkosított) letiltva Módosítja az adatbázis jelmondatát? kapcsolódva @@ -195,11 +195,11 @@ Kapcsolódás partneri kapcsolatot kért kapcsolat %1$d - a partner e2e titkosítással rendelkezik + a partner végpontok közötti titkosítással rendelkezik Csoport létrehozása véletlenszerű profillal. A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. - Kapcsolódik az egyszer használható meghívóval? + Kapcsolódik az egyszer használható meghívón keresztül? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül Kapcsolódási hiba (AUTH) Csak név @@ -214,7 +214,7 @@ Kapcsolódik saját magához? Vágólapra másolva Kapcsolódási kérés elküldve! - Kapcsolódás a számítógéphez + Társítás számítógéppel Kapcsolat Helyesbíti a nevet a következőre: %s? Időtúllépés kapcsolódáskor @@ -224,7 +224,7 @@ Kapcsolat Kapcsolat megszakítva kapcsolat létrehozva - a partner nem rendelkezik e2e titkosítással + a partner nem rendelkezik végpontok közötti titkosítással Partner engedélyezi Rejtett név: Társítás számítógéppel @@ -233,7 +233,7 @@ Partnerek Kapcsolódási hiba A partnere még nem kapcsolódott! - - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. + - kapcsolódás a könyvtárszolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. Közreműködés kapcsolódás (bemutatkozó meghívó) SimpleX-cím létrehozása @@ -266,25 +266,25 @@ kapcsolódás… Csevegési profil törlése egyéni - kapcsolódási hívás… + hívás kapcsolása… Téma személyre szabása - Jelenleg támogatott legnagyobb fájl méret: %1$s. + Jelenleg támogatott legnagyobb fájlméret: %1$s. Fájl törlése Hamarosan! cím módosítása %s számára… Csevegési adatbázis importálva Üzenetek törlése - Kiürítés + Ürítés Bezárás gomb A csevegés megállt (jelenlegi) Témák személyre szabása és megosztása. Törli a csevegési profilt? Titkos csoport létrehozása - Kapcsolódva a számítógéphez + Társítva a számítógéppel ICE-kiszolgálók beállítása Csoport törlése - Hitelesítés törlése + Ellenőrzés törlése készítő Megerősítés Csak nálam @@ -299,7 +299,7 @@ kapcsolódás… Hívás kapcsolása Törli a fájlokat és a médiatartalmakat? - befejezett + kész CSEVEGÉSI ADATBÁZIS Önmegsemmisítő jelkód módosítása Várólista létrehozása @@ -314,7 +314,7 @@ Egyéni időköz Kapcsolódás inkognitóban CSEVEGÉSEK - Új profil létrehozása a számítógép alkalmazásban. 💻 + Új profil létrehozása a számítógépes alkalmazásban. 💻 kapcsolódás (bejelentve) kapcsolódás… Csevegési adatbázis törölve @@ -333,7 +333,7 @@ Titkos csoport létrehozása Elvetés Törli a partnert? - Kiürítés + Ürítés Cím létrehozása, hogy az emberek kapcsolatba léphessenek Önnel. Biztonsági kódok összehasonlítása a partnerekével. Fájl-összehasonlítás @@ -341,9 +341,9 @@ Törli az üzenetet? Törli a függőben lévő kapcsolatot? Adatbázis titkosítva! - Kiüríti a csevegést? + Üríti a csevegés üzeneteit? Adatbázis visszafejlesztése - Üzenetek kiürítése + Csevegés üzeneteinek ürítése Az adatbázis titkosítási jelmondata frissítve lesz. Kapcsolódás automatikusan Adatbázishiba @@ -390,15 +390,15 @@ %2$s %1$d üzenetet moderált Eltűnő üzenet Ne hozzon létre címet - Ne mutasd újra + Ne jelenjen meg újra SimpleX-zár kikapcsolása - e2e titkosított + végpontok között titkosított ESZKÖZ - e2e titkosított videóhívás + végpontok között titkosított videóhívás közvetlen Számítógép %d perc - %d partner kijelölve + %d partner kiválasztva Engedélyezés %dhónap A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. @@ -419,7 +419,7 @@ Törlés, és a partner értesítése letiltva %d mp - Az összes fájl törlése + Összes fájl törlése Az adatbázis titkosítva lesz. Adatbázis-jelmondat és -exportálás Az adatbázis titkosítva lesz, a jelmondat pedig a Keystore-ban lesz tárolva. @@ -429,13 +429,13 @@ Leírás %d óra %dp - Szétkapcsolás + Kapcsolat bontása Szerkesztés Letiltás (csoport egyéni beállításainak megtartása) %d csoportesemény %d hónap Csoportprofil szerkesztése - e2e titkosított hanghívás + végpontok között titkosított hanghívás %d mp Decentralizált Dekódolási hiba @@ -443,12 +443,12 @@ Értesítések letiltása Eszközök Látható a helyi hálózaton - Ne engedélyezze + Nem engedélyezem Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben. alapértelmezett (%s) duplikált üzenet Leválasztja a számítógépet? - A számítógép-alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. + A számítógépes alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. Kézbesítés %d fájl, %s összméretben A csevegés megnyitásához adja meg az adatbázis jelmondatát. @@ -495,7 +495,7 @@ A csoportprofil a tagok eszközein tárolódik, nem a kiszolgálókon. Adja meg a jelmondatot… Hiba történt a felhasználói adatvédelem frissítésekor - Titkosít + Titkosítás Csoport nem található! Hiba történt az SMP-kiszolgálók mentésekor Visszafejlesztés és a csevegés megnyitása @@ -532,7 +532,7 @@ Fájlok és médiatartalmak KONZOLHOZ Nem sikerült a titkosítást újraegyeztetni. - Hiba történt a felhasználóprofil törlésekor + Hiba történt a felhasználói profil törlésekor Csoporttag általi javítás nem támogatott Adja meg az üdvözlőüzenetet… Titkosított adatbázis @@ -543,7 +543,7 @@ Kiszolgáló megadása kézzel A fájl akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! Hiba történt a csoporthivatkozás létrehozásakor - A galériából + Galéria Engedélyezés (csoport egyéni beállításainak megtartása) Hiba történt a partner törlésekor A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra) @@ -566,7 +566,7 @@ Hiba történt az XFTP-kiszolgálók mentésekor A tagok küldhetnek egymásnak közvetlen üzeneteket. Hiba történt a tag eltávolításakor - befejeződött + hívás vége A csoport üdvözlőüzenete Adja meg a csoport nevét: Hiba történt a meghívó elküldésekor @@ -618,23 +618,22 @@ Azonnal A fájlok és a médiatartalmak küldése le van tiltva! Profil elrejtése - Hogyan használja a saját kiszolgálóit + Útmutató a saját kiszolgálók használatához Csevegési üzenetek gyorsabb megtalálása Téma importálása Hiba történt a téma importálásakor Partner nevének és az üzenet tartalmának elrejtése Nem kompatibilis adatbázis-verzió - Hogyan működik a SimpleX Nem kompatibilis verzió Elrejtés Bejövő videóhívás Téves jelkód Azonnali Inkognitócsoportok - Hogyan + Útmutató Összecsukás Kép - Fejlesztett adatvédelem és biztonság + Továbbfejlesztett adatvédelem és biztonság Mellőzés Kép elküldve Se név, se üzenet @@ -645,7 +644,7 @@ Inkognitó Használati útmutató Alkalmazás képernyőjének elrejtése a gyakran használt alkalmazások között. - Javított kiszolgáló konfiguráció + Továbbfejlesztett kiszolgálókonfiguráció Előzmények Rejtett profiljelszó Adatbázis importálása @@ -674,7 +673,7 @@ Számítógépek A markdown használata Csevegési profil létrehozása - Védett a kéretlen tartalommal szemben + Védett a kéretlen tartalmakkal szemben Hordozható eszközök leválasztása Különböző nevek, profilképek és átvitelelkülönítés. Elutasítás esetén a kérés küldője NEM kap értesítést. @@ -695,7 +694,7 @@ moderált A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. - Nincs partner kijelölve + Nincs partner kiválasztva Nincsenek fogadott vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben @@ -711,13 +710,13 @@ Helyi név Hálózat és kiszolgálók Értesítésekben megjelenő információk - Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 + Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗 közvetett (%1$s) Hamarosan további fejlesztések érkeznek! A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben. Helytelen biztonsági kód! Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta. - Új számítógép-alkalmazás! + Új számítógépes alkalmazás! Most már az adminisztrátorok is:\n- törölhetik a tagok üzeneteit.\n- letilthatnak tagokat (megfigyelő szerepkör) meghívta őt: %1$s A reakciók hozzáadása az üzenetekhez le van tiltva. @@ -751,16 +750,16 @@ bekapcsolva Japán és portugál kezelőfelület Az üzenetek végleges törlése le van tiltva. - %s nevű hordozható eszközzel]]> + %s nevű hordozható eszköz le lett választva]]> hónap - Üzenetvázlat + Piszkozatok Egy üzenet eltüntetése Végleges üzenettörlés Egyszerre csak 10 videó küldhető el Csak Ön adhat hozzá reakciókat az üzenetekhez. elhagyta a csoportot Az üzenetek végleges törlése le van tiltva ebben a csevegésben. - Max 40 másodperc, azonnal fogadható. + Legfeljebb 40 másodperc, azonnal megérkezik. inkognitó a kapcsolattartási címhivatkozáson keresztül Onion kiszolgálók szükségesek a kapcsolódáshoz.\nMegjegyzés: .onion cím nélkül nem fog tudni kapcsolódni a kiszolgálókhoz. Olasz kezelőfelület @@ -777,7 +776,7 @@ Csak a csoport tulajdonosai engedélyezhetik a fájlok és a médiatartalmak küldését. Fájl betöltése… Nincs hozzáadandó partner - Üzenetvázlat + Piszkozatok függőben lévő kapcsolat Egyszer használható meghívó Értesítések @@ -817,8 +816,8 @@ Barátok meghívása Menük és figyelmeztetések Tagok meghívása - Csatlakozás mint %s - Nincs csevegés kijelölve + Csatlakozás mint: %s + Nincs csevegés kiválasztva Csak helyi profiladatok inkognitó egy egyszer használható meghívón keresztül Moderálva: %s @@ -827,7 +826,7 @@ Beszélgessünk a SimpleX Chatben Moderálva Élő üzenetek - Hitelesítés + Megjelölés ellenőrzöttként Üzenetkézbesítési jelentések! hivatkozás előnézeti képe Elhagyja a csoportot? @@ -838,7 +837,7 @@ Új megjelenítendő név: Új jelmondat… nem fogadott hívás - Átköltöztetés: %s + Átköltöztetések: %s Válaszul erre Név és üzenet Az értesítések csak az alkalmazás bezárásáig érkeznek! @@ -851,7 +850,7 @@ dőlt Érvénytelen a fájl elérési útvonala Csatlakozik a csoporthoz? - nincs e2e titkosítás + nincs végpontok közötti titkosítás Új adatbázis-archívum Élő üzenet! Meghívás a csoportba @@ -872,7 +871,7 @@ Időszakos fogadott, tiltott Megismétli a kapcsolódási kérést? - Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) + Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra) Szerepkör SimpleX kapcsolattartási cím Megállítás @@ -895,17 +894,17 @@ Jelentse a fejlesztőknek. Ön dönti el, hogy kivel beszélget. Az eltűnő üzenetek küldése le van tiltva. - Csak Ön tud hangüzeneteket küldeni. + Csak Ön küldhet hangüzeneteket. Frissítés Videó elküldve - Az adatbázis jelmondatának módosítása + Adatbázis jelmondatának módosítása Alkalmazásbeállítások megnyitása A jelkód nem módosult! Frissítés - Kijelölés - Csak Ön tud hívásokat indítani. + Kiválasztás + Csak Ön kezdeményezhet hívásokat. Biztonságos várólista - Értékelje az alkalmazást + Alkalmazás értékelése Egyszer használható meghívó megosztása Hiba történt az adatbázis visszaállításakor %s és %s @@ -918,7 +917,7 @@ Fogadott üzenet Üdvözlőüzenet %s, %s és további %d tag kapcsolódott - Csak a partnere tud hívást indítani. + Csak a partnere kezdeményezhet hívásokat. TÉMÁK Túl sok videó! Üdvözöljük! @@ -937,14 +936,14 @@ Hangszóró bekapcsolva Importált csevegési adatbázis használatához indítsa újra az alkalmazást. jogosulatlan küldés - Csak a partnere tud hangüzeneteket küldeni. + Csak a partnere küldhet hangüzeneteket. Beállítások A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. - visszaigazolás fogadása… + visszaigazolás érkezett… Biztonsági kód beolvasása a partnere alkalmazásából. Lépjen kapcsolatba a csoport adminisztrátorával. Videó bekapcsolva - Profilnév: + Profil neve: Beillesztés Köszönjük, hogy telepítette a SimpleX Chatet! Csillagozás a GitHubon @@ -952,7 +951,7 @@ Keresés Újraegyezteti a titkosítást? Az önmegsemmisítő jelkód engedélyezve! - Biztonsági kiértékelés + Biztonsági felmérés Cím Üzenet elküldése Adatbázismentés visszaállítása @@ -979,7 +978,7 @@ %s (jelenlegi) Saját SMP-kiszolgáló Véletlen - Megosztás a partnerekkel + Megosztás a SimpleX partnerekkel Ön Nincsenek csevegései Küldés @@ -995,7 +994,7 @@ Elküldve: %s Jelenlegi profil használata Ez az eszköz - Megosztja a címet a partnereivel? + Megosztja a címet a SimpleX partnereivel? Profiljelszó Téma Eltávolítja a jelmondatot a beállításokból? @@ -1027,21 +1026,21 @@ SIMPLEX CHAT TÁMOGATÁSA SimpleX Chat szolgáltatás Ön megfigyelő - %s hitelesítve + %s ellenőrizve Jelszó a megjelenítéshez Adatvédelem és biztonság Eltávolítás A jelkód beállítva! Elküldött üzenet - Partnerek kijelölése + Partnerek kiválasztása ismeretlen üzenetformátum Kiszolgálók mentése Üdvözlőüzenet mp - A profilfrissítés el lesz küldve a partnerei számára. + A profilfrissítés el lesz küldve a SimpleX partnerei számára. Egyszerűsített inkognitómód Menti az üdvözlőüzenetet? - Új csevegési fiók létrehozásához indítsa újra az alkalmazást. + Új csevegési profil létrehozásához indítsa újra az alkalmazást. Engedély megtagadva! Függőben lévő hívás Adatbázis megnyitása… @@ -1049,7 +1048,7 @@ Jelmondat szükséges Privát értesítések Ön meghívta egy partnerét - %s nincs hitelesítve + %s nincs ellenőrizve Koppintson ide a kapcsolódáshoz Ennek az eszköznek a neve Jelenlegi profil @@ -1081,7 +1080,7 @@ Újraindítás SMP-kiszolgálók Videó - SimpleX-cím beállításainak mentése + SimpleX-címbeállítások mentése Újraegyeztetés Várakozás a videóra Saját XFTP-kiszolgálók @@ -1090,7 +1089,7 @@ Menti a beállításokat? Jelkód Ismeretlen hiba - Saját SMP-kiszolgálójának címe + Saját SMP-kiszolgáló címe Csevegési konzol megnyitása Eltávolítás Adatbázis-jelmondat beállítása @@ -1119,11 +1118,11 @@ Várakozás a képre Hangüzenetek Eltávolítja a tagot? - Biztonsági kód hitelesítése + Biztonsági kód ellenőrzése eltávolította Önt SimpleX-cím Megjelenítve: - válasz fogadása… + válasz érkezett… Visszaállítja az adatbázismentést? Üzenetek fogadása… %s és %s kapcsolódott @@ -1135,14 +1134,14 @@ Elküldve A hangüzenetek küldése le van tiltva. Legutóbbi üzenetek előnézetének megjelenítése - Az előre beállított kiszolgáló címe + Előre beállított kiszolgáló címe Időszakos értesítések letiltva! A jelkód módosult! Akkor fut, amikor az alkalmazás meg van nyitva Ez a QR-kód nem egy hivatkozás! Várakozás a fájlra simplexmq: v%s (%2s) - Szétkapcsolás + Leválasztás Véletlenszerű profil Érvénytelen jelmondat! A reakciók hozzáadása az üzenetekhez le van tiltva. @@ -1160,7 +1159,7 @@ Kihagyott üzenetek A hangüzenetek küldése le van tiltva. Partner nevének beállítása - Csak Ön tud eltűnő üzeneteket küldeni. + Csak Ön küldhet eltűnő üzeneteket. Médiatartalom megosztása… Ön: %1$s Beállítások @@ -1170,7 +1169,7 @@ A kapott hivatkozás beillesztése a partnerhez való kapcsolódáshoz… Beolvasás Port nyitása a tűzfalban - indítás… + hívás indítása… Leállítás elküldve SOCKS proxy használata @@ -1198,7 +1197,7 @@ A fájlok és a médiatartalmak küldése le van tiltva. Fájl megosztása… Mentés - továbbítókiszolgálón keresztül + átjátszón keresztül Megosztás megállítása Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása @@ -1217,7 +1216,7 @@ Rendszer-hitelesítés Böngészőn keresztül Védje meg a csevegési profiljait egy jelszóval! - Csak a partnere tud eltűnő üzeneteket küldeni. + Csak a partnere küldhet eltűnő üzeneteket. Saját ICE-kiszolgálók QR-kód beolvasása a számítógépről SimpleX logó @@ -1239,7 +1238,7 @@ SimpleX-zár bekapcsolva elküldés a partnernek Beolvasás hordozható eszközről - Kapcsolatok hitelesítése + Kapcsolatok ellenőrzése Üzenet megosztása… másodperc A SimpleX-zár nincs bekapcsolva! @@ -1248,7 +1247,7 @@ Csevegési adatbázis eltávolította őt: %1$s Sikertelen kiszolgáló teszt! - Kapcsolat hitelesítése + Kapcsolat ellenőrzése Tudjon meg többet A fájl küldője visszavonta az átvitelt. Megállítja a csevegést? @@ -1256,7 +1255,7 @@ Beállítva 1 nap Felfedés Fogadott üzenetbuborék színe - Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra) Az önmegsemmisítő jelkód módosult! SimpleX Chat kiszolgálók használatban. SimpleX Chat kiszolgálók használata? @@ -1269,31 +1268,31 @@ Ötletek és javaslatok Figyelmeztetés: néhány adat elveszhet! Koppintson ide az új csevegés indításához - Várakozás a számítógépre… + Várakozás a számítógép-alkalmazásra… Az üzenetváltás jövője Módosítja a hálózati beállításokat? Várakozás a hordozható eszköz társítására: - Biztonságos kapcsolat hitelesítése + Biztonságos kapcsolat ellenőrzése fájlok küldése egyelőre még nem támogatott Ön módosította a címet %s számára fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése Visszaállítás alapértelmezettre Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. - videóhívás (nem e2e titkosított) + videóhívás (végpontok között NEM titkosított) Használat új kapcsolatokhoz - Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. + Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ leküldéses értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése a kapcsolattartási címhivatkozáson keresztül - a SimpleX a háttérben fut a push értesítések használata helyett.]]> + a SimpleX a háttérben fut a leküldéses értesítések használata helyett.]]> A partnereinek online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt a kapcsolatot és eltávolíthatja a partnert (ezt később ismét megpróbálhatja egy új hivatkozással). A jelmondat nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. - A partnerei továbbra is kapcsolódva maradnak. - A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát + A partnereivel továbbra is kapcsolatban marad. + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. Az adatbázis nem működik megfelelően. Koppintson ide a további információkért A fájl küldése le fog állni. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - Nem sikerült hitelesíteni; próbálja meg újra. + Nem sikerült ellenőrizni; próbálja meg újra. Az üzenet az összes tag számára moderáltként lesz megjelölve. Értesítések fogadásához adja meg az adatbázis jelmondatát A teszt a(z) %s lépésnél sikertelen volt. @@ -1305,9 +1304,9 @@ Az alkalmazás 1 perc után bezárható a háttérben. Ön meghívást kapott a csoportba Engedélyezze a következő párbeszédpanelen az azonnali értesítések fogadásához.]]> - A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát + A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát. Kapcsolódni fog a csoport összes tagjához. - Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal. A biztonsága érdekében kapcsolja be a SimpleX-zár funkciót.\nA funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beállítására az eszközén. A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! Ellenőrizze a hálózati kapcsolatát vele: %1$s, és próbálja újra. @@ -1317,26 +1316,26 @@ A kép nem dekódolható. Próbálja meg egy másik képpel, vagy lépjen kapcsolatba a fejlesztőkkel. Érvénytelen fájlelérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %1$s). + Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %1$s. A fájl fogadása le fog állni. Ne felejtse el, vagy tárolja biztonságosan – az elveszett jelszót nem lehet visszaállítani! A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését. Ön egy egyszer használható meghívót osztott meg inkognitóban - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. + Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Később engedélyezheti a beállításokban Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! különböző átköltöztetés az alkalmazásban/adatbázisban: %s / %s %1$s.]]> Profil felfedése Ez nem egy érvényes kapcsolattartási hivatkozás! - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. + A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. Ez a beállítás csak az Ön jelenlegi csevegési profiljában lévő üzenetekre vonatkozik Ön meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. Ez a csoport már nem létezik. A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. Ön meghívást kapott a csoportba - A partnere a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. + A partnere a jelenleg támogatott legnagyobb (%1$s) fájlméretnél nagyobbat küldött. A partnerei és az üzenetek (kézbesítés után) nem a SimpleX kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> @@ -1348,14 +1347,14 @@ Átvitelelkülönítés Akkor lesz kapcsolódva, ha a kapcsolódási kérését elfogadják, várjon, vagy ellenőrizze később! A hangüzenetek küldése le van tiltva. - Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> + Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> Biztonságos kvantumbiztos protokollon keresztül. - legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni időkorlát beállítása az üzenetek eltűnéséhez.\n- előzmények szerkesztése. Társítás számítógéppel menüt a hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]> %s ekkor: %s Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. - Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> + Onion kiszolgálók használata beállítást „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. Létrehozás később A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX kiszolgálók nem láthatják a profilját. @@ -1366,7 +1365,7 @@ Csoportmeghívó elküldve Frissíti az átvitelelkülönítési módot? Átvitelelkülönítés - Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. + Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak. A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. Közvetlen internetkapcsolat használata? Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak. @@ -1389,10 +1388,10 @@ A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. kapcsolatba akar lépni Önnel! Ön a következőre módosította a saját szerepkörét: „%s” - A csevegési szolgáltatás elindítható a „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával. - Kód hitelesítése a hordozható eszközön + A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával. + Kód ellenőrzése a hordozható eszközön Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. - a SimpleX Chat fejlesztőivel, ahol bármiről kérdezhet és értesülhet a friss hírekről.]]> + a SimpleX Chat fejlesztőivel, akiktől bármit kérdezhet és értesülhet a friss hírekről.]]> Nem kötelező üdvözlőüzenettel. Ismeretlen adatbázishiba: %s Elrejtheti vagy lenémíthatja a felhasználóprofiljait – koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. @@ -1402,7 +1401,7 @@ %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. Ön irányítja csevegését! - Kód hitelesítése a számítógépen + Kód ellenőrzése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. A csatlakozási kérése el lesz küldve ennek a csoporttagnak. Ha egy inkognitóprofilt oszt meg valamelyik partnerével, a rendszer ezt az inkognitóprofilt fogja használni azokban a csoportokban, ahová az adott partnere meghívja Önt. @@ -1414,12 +1413,12 @@ A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. Protokoll időtúllépése kB-onként Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. - Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. A profilja csak a partnereivel van megosztva. Néhány kiszolgáló megbukott a teszten: Koppintson ide a csatlakozáshoz Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. - A kézbesítési jelentések engedélyezve vannak %d partnernél + A kézbesítési jelentések engedélyezve vannak %d partner számára Küldés a következőn keresztül: Köszönet a felhasználóknak a Weblate-en való közreműködésért! A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. @@ -1435,16 +1434,16 @@ A kézbesítési jelentések le vannak tiltva %d csoportban Néhány nem végzetes hiba történt az importáláskor: Köszönet a felhasználóknak a Weblate-en való közreműködésért! - A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. + Az átjátszó kiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. Beállítás a rendszer-hitelesítés helyett. Az üzenetfogadási cím egy másik kiszolgálóra fog módosulni. A cím módosítása akkor fejeződik be, amikor az üzenetküldési kiszolgáló online lesz. A csevegés megállítása a csevegési adatbázis exportálásához, importálásához vagy törléséhez. A csevegés megállításakor nem tud üzeneteket fogadni és küldeni. Jelmondat mentése a Keystore-ba Köszönet a felhasználóknak a Weblate-en való közreműködésért! - Jelmondat mentése a beállításokban + Jelmondat mentése a beállításokba Ennek a csoportnak több mint %1$d tagja van, a kézbesítési jelentések nem lesznek elküldve. - A második jelölés, amit kihagytunk! ✅ - A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. + A második pipa, ami már nagyon hiányzott! ✅ + Az átjátszó kiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt. A mentett WebRTC ICE-kiszolgálók el lesznek távolítva. A kézbesítési jelentések engedélyezve vannak %d csoportban @@ -1452,10 +1451,10 @@ Profil és kiszolgálókapcsolatok Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. Koppintson ide a profil aktiválásához. - A kézbesítési jelentések le vannak tiltva %d partnernél - Munkamenet kód + A kézbesítési jelentések le vannak tiltva %d partner számára + Munkamenet kódja Köszönet a felhasználóknak a Weblate-en való közreműködésért! - Kis csoportok (max. 20 tag) + Kis csoportok (legfeljebb 20 tag) Az Ön által elfogadott kapcsolat vissza lesz vonva! Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet A KÉZBESÍTÉSI JELENTÉSEKET A KÖVETKEZŐ CÍMRE KELL KÜLDENI @@ -1510,7 +1509,7 @@ Vagy QR-kód beolvasása Érvénytelen QR-kód Megtartás - Keresés vagy SimpleX-hivatkozás beillesztése + Keressen vagy adjon meg egy SimpleX-hivatkozást Belső hibák megjelenítése Kritikus hiba Belső hiba @@ -1519,8 +1518,8 @@ Számítógép elfoglalt Számítógép inaktív Csevegés újraindítása - Időtúllépés a számítógéphez való csatlakozáskor - Kapcsolat bontva a számítógéppel + Időtúllépés a számítógéphez való társításkor + A számítógép le lett választva A kapcsolat megszakadt A kapcsolat megszakadt A kapcsolat a számítógéppel rossz állapotban van @@ -1528,10 +1527,10 @@ Jelentse a fejlesztőknek: \n%s %s hordozható eszköz által használt alkalmazás verziója nem támogatott. Győződjön meg arról, hogy mindkét eszközön ugyanazt a verziót használja]]> - %s nevű hordozható eszközzel]]> + %s nevű hordozható eszköz le lett választva]]> Érvénytelen megjelenítendő név! Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet. - %s nevű hordozható eszközzel, a következő okból: %s]]> + %s nevű hordozható eszköz le lett választva, a következő okból: %s]]> Kapcsolat bontva a következő okból: %s %s hordozható eszköz nem található]]> %s hordozható eszközzel rossz állapotban van]]> @@ -1555,7 +1554,7 @@ Privát jegyzetek Hiba történt a privát jegyzetek törlésekor Hiba történt az üzenet létrehozásakor - Kiüríti a privát jegyzeteket? + Üríti a privát jegyzetek tartalmát? Létrehozva Mentett üzenet Létrehozva: %s @@ -1586,7 +1585,7 @@ Az üdvözlőüzenet túl hosszú Az adatbázis átköltöztetése folyamatban van.\nEz eltarthat néhány percig. Hanghívás - A hívás befejeződött + Hívás vége Videóhívás Hiba történt a böngésző megnyitásakor A hívásokhoz egy alapértelmezett webböngésző szükséges. Állítson be egy alapértelmezett webböngészőt az eszközön, és osszon meg további információkat a SimpleX Chat fejlesztőivel. @@ -1597,7 +1596,7 @@ Feltöltés megerősítése Hiba történt az adatbázis törlésekor Az adminisztrátorok egy tagot a csoport összes tagja számára letilthatnak. - Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra. + Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-átjátszókra. Alkalmazásadatok átköltöztetése Adatbázis archiválása Átköltöztetés visszavonása @@ -1613,14 +1612,14 @@ Hiba történt a beállítások mentésekor Hiba történt az archívum letöltésekor Hiba történt az archívum feltöltésekor - Hiba történt a jelmondat hitelesítésekor: + Hiba történt a jelmondat ellenőrzésekor: Az exportált fájl nem létezik A fájl törölve lett, vagy érvénytelen a hivatkozás %s letöltve Archívum importálása Feltöltés előkészítése - Az adatbázis jelmondatának hitelesítése - Jelmondat hitelesítése + Adatbázis jelmondatának ellenőrzése + Jelmondat ellenőrzése Jelmondat beállítása Kép a képben hívások Biztonságosabb csoportok @@ -1633,10 +1632,10 @@ A folytatáshoz a csevegést meg kell szakítani. Csevegés megállítása folyamatban Vagy ossza meg biztonságosan ezt a fájlhivatkozást - Csevegés indítása + Csevegés elindítása Nem szabad ugyanazt az adatbázist használni egyszerre két eszközön.]]> Az átköltöztetéshez erősítse meg, hogy emlékszik az adatbázis jelmondatára. - Átköltöztetés egy másik eszközről opciót az új eszközén és olvassa be a QR-kódot.]]> + Átköltöztetés egy másik eszközről beállítást az új eszközén és olvassa be a QR-kódot.]]> Átköltöztetés véglegesítése Átköltöztetés véglegesítése egy másik eszközön. Letöltés előkészítése @@ -1654,7 +1653,7 @@ Átköltöztetés egy másik eszközről Kvantumbiztos titkosítás Megpróbálhatja még egyszer. - Átköltöztetés befejezve + Átköltöztetés kész Átköltöztetés egy másik eszközre QR-kód használatával. Átköltöztetés Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését.]]> @@ -1739,10 +1738,10 @@ Nem Nem védett Igen - NE használjon privát útválasztást. + NE legyen használva privát útválasztás. Privát útválasztás - Privát útválasztás használata az ismeretlen kiszolgálókkal. - Mindig használjon privát útválasztást. + Privát útválasztás használata az ismeretlen kiszolgálókhoz. + Mindig legyen használva privát útválasztás. Üzenet-útválasztási mód Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. @@ -1756,7 +1755,7 @@ IP-cím védelme Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról történő letöltések megerősítését (kivéve, ha az .onion vagy a SOCKS proxy engedélyezve van). Ismeretlen kiszolgálók! - Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára:\n%1$s. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-átjátszók számára:\n%1$s. Összes színmód Fekete Színmód @@ -1788,9 +1787,9 @@ További kiemelőszín 2 Alkalmazás témája Perzsa kezelőfelület - Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. + Védje az IP-címét a partnerei által kiválasztott üzenetváltási átjátszókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. Ismeretlen kiszolgálókról származó fájlok megerősítése. - Javított üzenetkézbesítés + Továbbfejlesztett üzenetkézbesítés Alkalmazás témájának visszaállítása Tegye egyedivé a csevegéseit! Új csevegési témák @@ -1812,18 +1811,18 @@ Fájlkiszolgáló-hiba: %1$s Fájl állapota Fájl állapota: %s - Másolási hiba + Hiba másolása Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel. Nem lehet üzenetet küldeni - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. Próbálja meg később. A kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. Inaktív tag Továbbított üzenet Az üzenet később is kézbesíthető, ha a tag aktívvá válik. Még nincs közvetlen kapcsolat, az üzenetet az adminisztrátor továbbítja. - Hivatkozás beolvasása / beillesztése + Hivatkozás megadása vagy QR-kód beolvasása Konfigurált SMP-kiszolgálók Egyéb SMP-kiszolgálók Egyéb XFTP-kiszolgálók @@ -1843,7 +1842,7 @@ Újrakapcsolódás az összes kiszolgálóhoz Hiba történt a statisztikák visszaállításakor Visszaállítás - Az összes statisztika visszaállítása + Összes statisztika visszaállítása Visszaállítja az összes statisztikát? A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! Részletes statisztikák @@ -1932,17 +1931,17 @@ Letiltás Letiltva Stabil - Hiba történt a(z) %1$s továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. - A(z) %1$s célkiszolgáló verziója nem kompatibilis a(z) %2$s továbbítókiszolgálóval. - A(z) %1$s továbbítókiszolgáló nem tudott kapcsolódni a(z) %2$s célkiszolgálóhoz. Próbálja meg később. - A(z) %1$s célkiszolgáló címe nem kompatibilis a(z) %2$s továbbítókiszolgáló beállításaival. + Hiba történt a(z) %1$s továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később. + A(z) %1$s célkiszolgáló verziója nem kompatibilis a(z) %2$s továbbító kiszolgálóval. + A(z) %1$s továbbító kiszolgáló nem tudott kapcsolódni a(z) %2$s célkiszolgálóhoz. Próbálja meg később. + A(z) %1$s célkiszolgáló címe nem kompatibilis a(z) %2$s továbbító kiszolgáló beállításaival. Médiatartalom elhomályosítása Közepes Kikapcsolva Enyhe Erős - A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. - A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %1$s. + A továbbító kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. + A továbbító kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %1$s. hívás A partner törölve lesz – ez a művelet nem vonható vissza! Csak a beszélgetés törlése @@ -1976,12 +1975,12 @@ Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást. A(z) %1$s nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. Üzenet… - Kijelölés + Kiválasztás Az üzenetek az összes tag számára moderáltként lesznek megjelölve. - Nincs semmi kijelölve + Nincs semmi kiválasztva Az üzenetek törlésre lesznek jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Törli a tagok %d üzenetét? - %d kijelölve + %d kiválasztva Az üzenetek az összes tag számára törölve lesznek. Csevegési adatbázis exportálva Kapcsolatok- és kiszolgálók állapotának megjelenítése. @@ -2002,7 +2001,6 @@ TCP-kapcsolat Mentheti az exportált archívumot. Tippek visszaállítása - Csevegési lista ki/be: Ezt a „Megjelenés” menüben módosíthatja. Új médiabeállítások Lejátszás a csevegési listából. @@ -2024,7 +2022,7 @@ CSEVEGÉSI ADATBÁZIS Profil megosztása Rendszerbeállítások használata - Csevegési profil kijelölése + Csevegési profil kiválasztása Ne használja a hitelesítési adatokat proxyval. Különböző proxy-hitelesítési adatok használata az összes profilhoz. Különböző proxy-hitelesítési adatok használata az összes kapcsolathoz. @@ -2048,7 +2046,7 @@ %1$s üzenet nem lett továbbítva Továbbít %1$s üzenetet? Továbbítja az üzeneteket fájlok nélkül? - Az üzeneteket törölték miután kijelölte őket. + Az üzeneteket törölték miután kiválasztotta őket. %1$s üzenet mentése Hiba történt az üzenetek továbbításakor Hang elnémítva @@ -2057,11 +2055,11 @@ Üzenetbuborék alakja Farok Kiszolgáló - Minden alkalommal, amikor elindítja az alkalmazást, új SOCKS-hitelesítési adatokat fog használni. + Minden alkalommal, amikor elindítja az alkalmazást, új SOCKS-hitelesítési adatok lesznek használva. Alkalmazás munkamenete Az összes kiszolgálóhoz új, SOCKS-hitelesítési adatok lesznek használva. - Kattintson a címmező melletti info gombra a mikrofon használatának engedélyezéséhez. - Nyissa meg a Safari Beállítások / Weboldalak / Mikrofon menüt, majd válassza a helyi kiszolgálók engedélyezése lehetőséget. + Kattintson a címmező melletti információ gombra a mikrofon használatának engedélyezéséhez. + Nyissa meg a Safari / Beállítások / Weboldalak / Mikrofon menüt, majd válassza a helyi kiszolgálók engedélyezése beállítást. Hívások kezdeményezéséhez engedélyezze a mikrofon használatát. Fejezze be a hívást, és próbálja meg a hívást újra. Továbbfejlesztett hívásélmény Továbbfejlesztett üzenetdátumok. @@ -2097,10 +2095,10 @@ Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. Egyszer használható meghívó megosztása egy baráttal - csak egyetlen partnerrel használható – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható.]]> + csak egyetlen partnerrel használható – személyesen vagy bármilyen üzenetváltó alkalmazáson keresztül megosztható.]]> Beállíthatja a partner nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. Kapcsolatbiztonság - A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül. + A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó alkalmazáson keresztül. A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével. A közösségi médiához Vagy a privát megosztáshoz @@ -2127,7 +2125,7 @@ Hálózatüzemeltető Weboldal Feltételek elfogadásának ideje: %s. - A feltételek el lesznek elfogadva a következő időpontban: %s. + A feltételek el lesznek fogadva a következő időpontban: %s. Kiszolgálók használata %s használata A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: @@ -2162,7 +2160,7 @@ A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében. Eszköztárak a metaadatok jobb védelme érdekében. - Javított csevegési navigáció + Továbbfejlesztett csevegési navigáció - Csevegés megnyitása az első olvasatlan üzenetnél.\n- Ugrás az idézett üzenetekre. Frissített feltételek megtekintése A jelenlegi csevegési profiljához tartozó új fájlok kiszolgálói @@ -2178,7 +2176,7 @@ Értesítések és akkumulátor Az alkalmazás mindig fut a háttérben Elhagyja a csevegést? - Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. + Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. Csevegés törlése Meghívás a csevegésbe Barátok hozzáadása @@ -2236,7 +2234,7 @@ Nincsenek olvasatlan csevegések Lista létrehozása Lista mentése - Az összes csevegés el lesz távolítva a következő listáról, és a lista is törlődik: %s + Az összes csevegés el lesz távolítva a(z) %s nevű listáról, és a lista is törölve lesz Törlés Törli a listát? Szerkesztés @@ -2293,7 +2291,7 @@ alapértelmezett (%s) Csevegési üzenetek törlése az eszközről. Módosítja az automatikus üzenettörlést? - Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. A következő TCP-port használata, amikor nincs port megadva: %1$s. TCP-port az üzenetváltáshoz Webport használata @@ -2301,7 +2299,7 @@ Összes némítása Legfeljebb %1$s tagot említhet meg egy üzenetben! Az üzenetek jelentése a moderátorok felé engedélyezve van. - Az üzenetek a moderátorok felé történő jelentésének megtiltása. + Az üzenetek jelentése a moderátorok felé le van tiltva. Archiválja az összes jelentést? Archivál %d jelentést? Csak magamnak @@ -2340,10 +2338,9 @@ A tagok összes üzenete meg fog jelenni! moderátorok Elfogadás - A SimpleX Chat használatával Ön elfogadja, hogy:\n- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban.\n- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek. + Ön kijelenti, hogy:\n- nyilvános csoportokban kizárólag megengedett tartalmakat oszt meg\n- tiszteletben tartja a többi felhasználót – nem küld senkinek kéretlen tartalmat Adatvédelmi szabályzat és felhasználási feltételek. - A privát csevegések, a csoportok és a partnerek nem érhetők el a kiszolgálók üzemeltetői számára. - Kiszolgálóüzemeltetők beállítása + Az üzemeltetők kijelentik, hogy:\n- függetlenek maradnak\n- minimálisra csökkentik a metaadatok használatát\n- ellenőrzött, nyílt forráskódú szoftvereket futtatnak Nem támogatott kapcsolattartási hivatkozás Rövid hivatkozás Teljes hivatkozás @@ -2352,7 +2349,7 @@ Összes kiszolgáló Kikapcsolva Előre beállított kiszolgálók - A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz. + A 443-as TCP-port használata kizárólag az előre beállított kiszolgálókhoz. Hiba a tag befogadásakor %d csevegés a tagokkal %d üzenet @@ -2393,12 +2390,12 @@ csatlakozási kérés elutasítva Ön elhagyta a csoportot a tag régi verziót használ - Hiba a csevegés törlésekor + Hiba történt a csevegés törlésekor Ön nem tud üzeneteket küldeni! a partner nem áll készen nincs szinkronizálva Törli a taggal való csevegést? - a partnere elhagyta a csevegést + partner törölve Csevegés törlése Elutasítás Elutasítja a tagot? @@ -2408,8 +2405,8 @@ Új csoport megnyitása végpontok közötti titkosítással vannak védve.]]> Hiba történt a partneri kapcsolatkérés elutasításakor - Hiba a csevegés megnyitásakor - Hiba a csoport megnyitásakor + Hiba történt a csevegés megnyitásakor + Hiba történt a csoport megnyitásakor Hiba a profil módosításakor Megnyitás a csatlakozáshoz Megnyitás a kapcsolódáshoz @@ -2450,9 +2447,9 @@ TCP-kapcsolat időtúllépése a háttérben Profil betöltése… Rövid leírás: - Saját névjegy: - Névjegy: - A névjegy túl hosszú + Saját életrajz: + Életrajz: + Az életrajz túl hosszú A leírás túl hosszú Partneri kapcsolatkérés elfogadása Üzleti kapcsolat @@ -2468,17 +2465,17 @@ Saját cím létrehozása Eltűnő üzenetek engedélyezése alapértelmezetten. Tartsa tisztán a csevegéseit - Névjegy és üdvözlőüzenet beállítása a profilokhoz. + Életrajz és üdvözlőüzenet beállítása a profilokhoz. Saját cím megosztása Rövid SimpleX-cím Cím frissítése Üdvözölje a partnereit 👋 4 új kezelőfelületi nyelv - Katalán, indonéz, román és vietnami – köszönjük felhasználóinknak! + Katalán, indonéz, román és vietnámi – köszönjük a felhasználóinknak! Csoporthivatkozás frissítése A hivatkozás rövid lesz és a csoportprofil meg lesz osztva a hivatkozáson keresztül. Régi cím megosztása - Régi hivatkozás megosztása + Régi (hosszú) hivatkozás megosztása PARTNERI KAPCSOLATKÉRÉSEK A CSOPORTOKBÓL A tag törölve lett – nem lehet elfogadni a kérést a(z) %1$s nevű csoportból partneri kapcsolatot kért @@ -2498,8 +2495,275 @@ Tiszta hivatkozás megnyitása Teljes hivatkozás megnyitása Nyomonkövetési paraméterek eltávolítása a hivatkozásokból - SimpleX továbbítókiszolgáló-hivatkozás + SimpleX-átjátszó címe Hiba a csevegés olvasottként való megjelölésekor A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. - A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. + A továbbító kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. + nincs feliratkozás + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás). + Tag üzeneteinek törlése + Törli a tag üzeneteit? + Üzenetek törlése + A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza! + Eltávolítás és az üzeneteinek törlése + Összes üzenet + Fájlok + Szűrő + Képek + Hivatkozások + Fájlok keresése + Képek keresése + Hivatkozások keresése + Videók keresése + Hangüzenetek keresése + Videók + Hangüzenetek + NEM SIKERÜLT LÉTREHOZNI A KAPCSOLATOT + sikertelen + Ha csatornákat hozott létre vagy csak csatlakozott hozzájuk, akkor azok véglegesen le fognak állni. + aktív + Közvetítés… + csatorna + Csatorna + Csatorna + Csatornahivatkozás + Csatornatagok + Csatorna neve + Kapcsolódás + kapcsolódott + kapcsolódás + Nyilvános csatorna létrehozása + Nyilvános csatorna létrehozása + Nyilvános csatorna létrehozása (BÉTA) + Csatorna létrehozása + törölve + sikertelen + sikertelen + Hivatkozás + új + meghíva + Csatorna megnyitása + Új csatorna megnyitása + TULAJDONOS + Tulajdonosok + Csatorna elhagyása + Elhagyja a csatornát? + Ellenőrzés + Várakozás + Várakozás a válaszra + Ön + Saját csatorna + Saját csatorna + FELIRATKOZÓ + Feliratkozók + %1$d feliratkozó + %1$d feliratkozó + elfogadva + Csatorna törlése + Törli a csatornát? + Csatlakozás a csatornához + Koppintson a „Csatlakozás a csatornához” gombra + A hangrögzítés nem támogatott az Ön által használt eszközön + Nincsenek engedélyezve csevegési átjátszók. + Kiszolgáló-figyelmeztetés + Ön feliratkozó + Ön nem fog több üzenetet kapni ebből a csatornából. A csevegési előzmények megmaradnak. + átjátszó + A csatorna az összes feliratkozó számára törölve lesz – ez a művelet nem vonható vissza! + A csatorna törölve lesz az Ön számára – ez a művelet nem vonható vissza! + Csatornaprofil szerkesztése + Megoszthat egy hivatkozást vagy egy QR-kódot – bárki képes lesz csatlakozni a csatornához. + Csevegési átjátszók + Eltávolítja a feliratkozót? + A feliratkozó el lesz távolítva a csatornából – ez a művelet nem vonható vissza! + Csevegési átjátszó + Új csevegési átjátszó + Előre beállított átjátszó neve + Előre beállított átjátszó címe + Saját átjátszó neve + Saját átjátszó címe + Adja meg az átjátszó nevét… + Átjátszó használata + Átjátszó tesztelése + Használat új csatornákhoz + Átjátszó törlése + Nem sikerült tesztelni az átjátszót! + Hivatkozás megtekintése + Hivatkozás dekódolása + A teszt a(z) %s. lépésnél sikertelen volt. + A kiszolgáló hitelesítést igényel az átjátszóhoz való kapcsolódáshoz, ellenőrizze a jelszavát. + Érvénytelen az átjátszó neve! + Ellenőrizze az átjátszó nevét, és próbálja újra. + Érvénytelen az átjátszó címe! + Ellenőrizze az átjátszó címét, és próbálja újra. + Hiba az átjátszó hozzáadásakor + Csevegési átjátszók + A csevegési átjátszók továbbítják az üzeneteket az Ön által létrehozott csatornákban. + Csevegési átjátszók + Nincsenek csevegési átjátszók + A csevegési átjátszók továbbítják az üzeneteket a csatorna feliratkozóinak. + %1$d/%2$d átjátszó aktív, %3$d sikertelen + %1$d/%2$d átjátszó aktív + %1$d/%2$d átjátszó kapcsolódva, %3$d hiba + %1$d/%2$d átjátszó kapcsolódva + ÁTJÁTSZÓ + Átjátszóhivatkozás + Átjátszó címe + a következőn keresztül: %1$s + Átjátszó címének megosztása + A feliratkozók az átjátszó hivatkozását használják a csatornához való kapcsolódáshoz.\nAz átjátszó címe ennek az átjátszónak a beállítására szolgált a csatornához. + Ön ezen az átjátszóhivatkozáson keresztül kapcsolódott a csatornához. + Feliratkozó eltávolítása + Az összes feliratkozó számára letiltja a feliratkozót? + Hiba a csatorna létrehozásakor + Visszavonja a csatorna létrehozását? + Engedélyezzen legalább egy csevegési átjátszót a csatorna létrehozásához. + A(z) %1$s nevű profilja meg lesz osztva a csatorna átjátszóival és feliratkozóival.\nAz átjátszók hozzáférhetnek a csatornaüzenetekhez. + Átjátszók konfigurálása + Nem sikerült kapcsolódni az átjátszóhoz + Nem minden átjátszó kapcsolódott + A csatorna %2$d átjátszóból %1$d használatával kezd el működni. Folytatja? + Átjátszó címe + Ez egy csevegési átjátszó címe, nem használható kapcsolódásra. + %1$s nevű csatornához!]]> + Hiba a csatorna megnyitásakor + Az összes feliratkozó számára feloldja a feliratkozó letiltását? + Átjátszó tesztelése a nevének lekéréséhez.]]> + Csatorna teljes neve: + A csatornaprofil a feliratkozók eszközén és a csevegési átjátszókon van tárolva. + csatornaprofil frissítve + %d csatornaesemény + törölt csatorna + hiba: %s + Hiba a csatornaprofil mentésekor + Üzenethiba + Mentés és a csatorna feliratkozóinak értesítése + Csatornaprofil mentése + frissített csatornaprofil + Az alkalmazás %1$d sikertelen letöltési kísérlet után eltávolította ezt az üzenetet. + eltávolítva (%1$d kísérlet) + A csatorna ideiglenesen nem érhető el + A csatornának nincsenek aktív átjátszói. Próbáljon meg később csatlakozni. + nem lehet közvetíteni + az üzemeltető eltávolította + inaktív + Az összes átjátszó el lett távolítva + Nem sikerült kapcsolódni egyetlen átjátszóhoz sem + Nincsenek aktív átjátszók + %1$d átjátszó eltávolítva + %1$d átjátszóhoz nem sikerült kapcsolódni + %1$d átjátszó inaktív + %1$d/%2$d átjátszó aktív, %3$d eltávolítva + %1$d/%2$d átjátszó aktív, %3$d hiba + %1$d/%2$d átjátszó kapcsolódott, %3$d átjátszóhoz nem sikerült kapcsolódni + %1$d/%2$d átjátszó kapcsolódott, %3$d eltávolítva + Az átjátszók hozzáadása később lesz támogatott. + Várakozás a csatorna tulajdonosára az átjátszók hozzáadásához. + Üzleti cím + Csatornahivatkozás + Kapcsolattartási cím + Hiba a csatorna megosztásakor + (a tulajdonostól) + Csoporthivatkozás + Hivatkozás aláírása ellenőrizve. + Egyszer használható meghívó + Csatorna megosztása… + Megosztás egy csevegésen keresztül + ⚠️ Nem sikerült ellenőrizni az aláírást: %s. + (aláírva) + Koppintson ide a megnyitáshoz + Letiltás + Engedélyezés + Engedélyezi a hivatkozások előnézetét? + Hiba + Hálózati hiba + A hivatkozáselőnézet küldése felfedheti az Ön IP-címét a weboldal számára. Ezt később módosíthatja az adatvédelmi beállításokban. + A kapcsolat elérte a kézbesítetlen üzenetek korlátját + Átjátszóeredmények: + Csatornabeállítások + Csak a csatorna tulajdonosai módosíthatják a csatornabeállításokat. + Saját nyilvános cím + Új egyszer használható meghívó + Csatornák + Saját nyilvános cím létrehozása + Hivatkozás vagy QR-kód használata + Egy hivatkozás, ami egyetlen partnerrel való kapcsolat létrehozására szolgál + Saját hivatkozás létrehozása + Bárki számára, aki el szeretné érni Önt + Partner meghívása privátban + Hagyja, hogy valaki elérje Önt + Vagy mutassa meg a QR-kódot személyesen vagy videóhíváson keresztül. + Vagy használja ezt a QR-kódot – nyomtassa ki vagy mutassa meg online. + Küldje el a hivatkozást bármilyen üzenetváltó alkalmazáson keresztül – ez egy biztonságos módszer – és kérje meg a partnerét, hogy illessze be a SimpleX alkalmazásba. + Beszélgessen valakivel + Használja ezt a címet a közösségi oldalakon használt profiljaiban, weboldalakon vagy az e-mail aláírásában. + Könnyebben hívhatja meg a barátait 👋 + Nonprofit irányítás + - Hivatkozások előnézetének küldése.\n- SOCKS proxy használata, ha engedélyezve van.\n- Hiperhivatkozásokon keresztüli adathalászat megakadályozása.\n- Hivatkozások nyomonkövetési paramétereinek eltávolítása. + Tulajdonjog: saját átjátszókat üzemeltethet. + Adatvédelem: tulajdonosok és előfizetők számára. + Nyilvános csatornák – mondja el szabadon a véleményét 🚀 + Megbízhatóság: több átjátszó is használható csatornánként. + Biztonságos webhivatkozások + Biztonság: a csatornák kulcsait a tulajdonosok őrzik. + A SimpleX hálózat hosszú távú működésének biztosítása érdekében. + Az új felhasználók számára egyszerűbbé tettük a kapcsolatok létrehozását. + Fiók nélkül születtünk. + Senki sem követte nyomon a beszélgetéseinket. Senki sem készített térképet arról, hogy merre jártunk. A magánéletünk nem csak egy funkció volt, hanem az életmódunk. + Aztán felléptünk az internetre, és minden platform kért belőlünk egy darabot - nevet, telefonszámot, baráti kapcsolatokat. Elfogadtuk, hogy a kommunikáció ára az, hogy mások megtudják, hogy kivel beszélünk. Minden generáció, az emberek és a technológia is eddig így működött - telefon, e-mail, üzenetküldő programok, közösségi média. Úgy tűnt, ez az egyetlen lehetséges mód. + De van egy másik lehetőség is. Egy hálózat, amelyben nincsenek telefonszámok. Nincsenek felhasználónevek. Nincsenek fiókok. Nincsenek semmiféle felhasználói azonosítók. Egy hálózat, amely összeköti az embereket és titkosított üzeneteket továbbít, anélkül, hogy tudná, ki csatlakozik hozzá. + Nem egy jobb zár mások ajtaján. Nem egy kedvesebb házmester, aki tiszteletben tartja az Ön magánéletét, de mégis nyilvántartást vezet minden látogatójáról. Ön itt nem csak egy vendég. Ön itt otthon van. Nincs az a hatalom, amely beléphetne ide - Ön itt szuverén. + A beszélgetései Önhöz tartoznak, ahogy az internet megjelenése előtt is mindig így volt. A hálózat nem egy hely, amelyet meglátogat. Ez egy olyan hely, amelyet Ön hoz létre saját magának. És senki sem veheti el Öntől, függetlenül attól, hogy privát vagy nyilvános. + A legrégebbi emberi szabadság - beszélgetni az emberekkel, anélkül, hogy mások megfigyelnének - olyan infrastruktúrán alapul, amely nem tudja elárulni. + Mert felszámoltuk a lehetőségét is annak, hogy megtudjuk, Ön kicsoda. Így az önrendelkezése soha nem kerülhet idegen kezekbe. + Legyen szabad a saját hálózatában. + + Feliratkozók jelentései + A közvetlen üzenetek küldése a feliratkozók között engedélyezve van. + A közvetlen üzenetek küldése a feliratkozók között le van tiltva. + Legfeljebb az utolsó 100 üzenet elküldése az új feliratkozók számára. + Az előzmények ne legyenek elküldve az új feliratkozók számára. + A feliratkozók küldhetnek eltűnő üzeneteket. + A feliratkozók küldhetnek egymásnak közvetlen üzeneteket. + A feliratkozók közötti közvetlen üzenetek le vannak tiltva. + A feliratkozók véglegesen törölhetik az elküldött üzeneteiket. (24 óra) + A feliratkozók reakciókat adhatnak hozzá az üzenetekhez. + A feliratkozók küldhetnek hangüzeneteket. + A feliratkozók küldhetnek fájlokat és médiatartalmakat. + A feliratkozók küldhetnek SimpleX-hivatkozásokat. + A feliratkozók jelenthetik az üzeneteket a moderátorok felé. + Legfeljebb az utolsó 100 üzenet lesz elküldve az új feliratkozók számára. + Az előzmények nem lesznek elküldve az új feliratkozók számára. + A csevegés az adminisztrátorokkal engedélyezve van a tagok számára. + A csevegés az adminisztrátorokkal engedélyezve van a feliratkozók számára. + Váljon szabaddá\na saját hálózatában. + A csevegés az adminisztrátorokkal le van tiltva. + A nyilvános csatornákban az adminisztrátorokkal való csevegések nem rendelkeznek végpontok közötti titkosítással – csak megbízható csevegési átjátszókkal használja őket. + A csevegés a tagokkal le van tiltva + Csevegés az adminisztrátorokkal + Engedélyezés + Engedélyezi a csevegést az adminisztrátorokkal? + Profil nevének megadása… + Vágjunk bele + A tagok cseveghetnek az adminisztrátorokkal + nem rendelkeznek végpontok közötti titkosítással. A csevegési átjátszók láthatják ezeket az üzeneteket.]]> + Átköltöztetés + Hálózati kötelezettségvállalások + A hálózati útválasztók nem tudhatják,\nhogy ki kivel beszélget + Nincs fiók. Nincs telefonszám. Nincs e-mail-cím. Nincs személyazonosító.\nA legbiztonságosabb titkosítás. + Az eszközön, nem pedig kiszolgálókon. + Megnyitja a külső hivatkozást? + Privát és biztonságos üzenetváltás. + A csevegés az adminisztrátorokkal le van tiltva. + Értesítések beállítása + Útválasztók beállítása + A feliratkozók cseveghetnek az adminisztrátorokkal + Miért jött létre a SimpleX? + Saját hálózat + Profil létrehozása + Az első hálózat, ahol Ön birtokolja\na saját kapcsolatait és csoportjait. + Alsó sáv + A hivatkozások előnézetét SOCKS proxyn keresztül kéri le a kliens. A DNS-lekérdezés viszont továbbra is történhet helyi szinten, a saját DNS-kiszolgálón keresztül. + Felső sáv diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg new file mode 100644 index 0000000000..fc1e09a3cb --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg new file mode 100644 index 0000000000..c88692fc12 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg @@ -0,0 +1 @@ + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg new file mode 100644 index 0000000000..045484d0a1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg new file mode 100644 index 0000000000..e731314fcc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg new file mode 100644 index 0000000000..4ed6a064bf --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg @@ -0,0 +1 @@ + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg new file mode 100644 index 0000000000..091b0d4692 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg new file mode 100644 index 0000000000..72692b2e17 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg new file mode 100644 index 0000000000..6d012c8956 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg new file mode 100644 index 0000000000..7e77b31444 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg new file mode 100644 index 0000000000..ea9417f047 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg new file mode 100644 index 0000000000..5e8c346fdd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 55b108cfb9..60ed7db384 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -103,8 +103,8 @@ Menghubungkan Tautan tidak valid Periksa apakah tautan SimpleX sudah benar. - Anda terhubung ke server yang digunakan untuk menerima pesan dari kontak ini. - Mencoba menyambung ke server yang digunakan untuk menerima pesan dari kontak ini (error: %1$s). + Anda terhubung ke server yang digunakan untuk menerima pesan dari koneksi ini. + Mencoba menyambung ke server yang digunakan untuk menerima pesan dari kontak ini (error: %1$s). Migrasi basis data sedang berlangsung, \nmemerlukan waktu beberapa menit. menghubungkan @@ -112,7 +112,7 @@ Tampilan macet Anda membagikan lokasi file yang tidak valid. Laporkan masalah ini ke pengembang aplikasi. galat - Tautan sekali + Tautan 1-kali %1$d pesan yang terlewati %1$d pesan yang dilewati %s tidak didukung. Harap pastikan kamu menggunakan versi yang sama pada kedua perangkat.]]> @@ -307,7 +307,7 @@ Kontak %d pesan dihapus dihapus - Mencoba terhubung ke server untuk menerima pesan dari kontak ini. + Mencoba terhubung ke server untuk menerima pesan dari koneksi ini. disimpan diundang untuk terhubung Deskripsi @@ -619,7 +619,6 @@ Kesalahan saat menginisialisasi WebView. Pastikan Anda telah menginstal WebView dan arsitektur yang didukung adalah arm64.\nKesalahan: %s Gunakan obrolan Bagaimana caranya - Cara kerja SimpleX Berkala Panggilan suara masuk panggilan suara terenkripsi e2e @@ -768,19 +767,19 @@ koneksi %1$d koneksi terjalin menghubungkan… - Anda bagikan tautan sekali - Anda bagikan tautan sekali samaran + Anda bagikan tautan 1-kali + Anda bagikan tautan samaran 1-kali via tautan grup samaran via tautan grup via tautan alamat kontak samaran via tautan alamat kontak - via tautan sekali + via tautan 1-kali Alamat kontak SimpleX - samaran via tautan sekali + samaran via tautan 1-kali Tautan lengkap Tautan grup SimpleX Tautan SimpleX - Undangan sekali SimpleX + Undangan 1-kali SimpleX via %1$s Gagal simpan server XFTP Nama tampilan tidak valid! @@ -1151,7 +1150,7 @@ Harap periksa apakah tautan yang digunakan benar atau minta kontak Anda untuk kirim tautan lain. Gagal menerima permintaan kontak Galat - Mungkin sidik jari sertifikat di alamat server salah + Sidik jari pada alamat server tidak cocok dengan sertifikat. Gagal mengatur alamat Gagal hapus profil pengguna Hapus antrian @@ -1193,7 +1192,7 @@ Gagal memuat obrolan Nama tampilan ini tidak valid. Silakan pilih nama lain. Pengirim mungkin telah hapus permintaan koneksi. - Server perlu otorisasi untuk membuat antrian, periksa kata sandi + Server perlu otorisasi untuk membuat antrean, periksa kata sandi. Gagal menghapus permintaan kontak Gagal menghapus koneksi kontak tertunda Gagal mengubah alamat @@ -1262,7 +1261,7 @@ Server tak dikenal! Kecuali kontak Anda hapus koneksi atau tautan ini sudah digunakan, mungkin ini adalah bug - harap laporkan.\nUntuk terhubung, harap minta kontak Anda untuk buat tautan koneksi lain dan periksa apakah Anda memiliki koneksi jaringan stabil. Gagal sinkronkan koneksi - Server perlu otorisasi untuk mengunggah, periksa kata sandi + Server perlu otorisasi untuk mengunggah, periksa kata sandi. Buat antrian Dapat dimatikan melalui pengaturan – notifikasi akan tetap ditampilkan saat aplikasi berjalan.]]> SimpleX berjalan di latar belakang alih-alih gunakan notifikasi push.]]> @@ -1340,7 +1339,7 @@ Ketentuan akan diterima pada: %s. Koneksi Peran - Ganti rol + Ganti peran Profil obrolan Anda akan dikirim ke anggota grup Profil obrolan Anda akan dikirim ke anggota obrolan Profil grup disimpan di perangkat anggota, bukan di server. @@ -1411,7 +1410,7 @@ Peningkatan basis data Konfirmasi peningkatan basis data Turunkan dan buka obrolan - mengubah hak %s menjadi %s + mengubah peran %s menjadi %s Dihapus pada: %s Hapus profil %1$s!]]> @@ -1491,7 +1490,7 @@ Anda dapat konfigurasi operator di pengaturan Jaringan dan server. Kesalahan basis data Frasa sandi basis data berbeda dengan yang disimpan di Keystore. - Ubah hak grup? + Ubah peran grup? %s.]]> Ketentuan Penggunaan %s.]]> @@ -1554,7 +1553,7 @@ Ketika lebih dari satu operator diaktifkan, tidak satupun dari mereka memiliki metadata untuk mengetahui siapa yang berkomunikasi. Obrolan sedang berjalan Basis data akan dienkripsi dan frasa sandi disimpan di Keystore. - mengubah hak Anda jadi %s + mengubah peran Anda jadi %s mengubah alamat… mengubah alamat untuk %s… ID basis data: %d @@ -1670,7 +1669,7 @@ Gagal kirim undangan Undang Status berkas - Gagal ganti hak + Gagal ganti peran Gagal hapus anggota Samaran Mode samaran melindungi privasi Anda dengan menggunakan profil acak baru untuk setiap kontak. @@ -1713,7 +1712,7 @@ Grup tidak aktif Undangan kedaluwarsa! profil grup diperbarui - Hak awal + Peran awal Gagal membuat tautan grup Gagal hapus tautan grup Gagal perbarui tautan grup @@ -1761,7 +1760,7 @@ Nama lengkap: Untuk melanjutkan, obrolan harus dihentikan. negosiasi ulang enkripsi diperlukan untuk %s - Perluas pemilihan hak + Perluas pemilihan peran Ditemukan desktop Gagal simpan pengaturan enkripsi ok untuk %s @@ -2026,7 +2025,6 @@ Platform perpesanan dan aplikasi yang melindungi privasi dan keamanan Anda. Untuk melindungi privasi Anda, SimpleX gunakan ID terpisah untuk setiap kontak. PROXY SOCKS - Alihkan daftar obrolan: Tingkatkan dan buka obrolan Ketuk untuk gabung ke samaran Anda memblokir %s @@ -2069,8 +2067,8 @@ Anda dapat ubah di pengaturan Tampilan. Grup ini tidak ada lagi. Anda menolak undangan grup - Anda mengubah hak Anda menjadi %s - Anda mengubah hak %s menjadi %s + Anda mengubah peran Anda menjadi %s + Anda mengubah peran %s menjadi %s Lihat ketentuan Server untuk berkas baru dari profil obrolan Anda saat ini Perbarui aplikasi secara otomatis @@ -2081,7 +2079,7 @@ Anda sudah terhubung melalui tautan 1-kali ini! Gunakan dari desktop Tindakan ini tidak dapat dibatalkan - semua berkas dan media yang diterima dan dikirim akan dihapus. Gambar beresolusi rendah akan tetap ada. - Hak akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. + Peran akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. Basis data obrolan Anda tidak dienkripsi - setel frasa sandi untuk melindunginya. Anda diundang ke grup Menunggu ponsel terhubung: @@ -2121,7 +2119,7 @@ Anda mengubah alamat untuk %s profil grup diperbarui %s, %s dan %s terhubung - Hak akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. + Peran akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. minggu Gagal unggah Mengunggah arsip @@ -2174,7 +2172,7 @@ Server SMP Profil obrolan Anda akan dikirim\nke kontak Anda Bagikan alamat - Hak akan diubah menjadi %s. Anggota akan menerima undangan baru. + Peran akan diubah menjadi %s. Anggota akan menerima undangan baru. Perbarui pengaturan jaringan? Ketuk untuk aktifkan profil. Mulai dari %s. @@ -2345,7 +2343,6 @@ Frasa sandi di Keystore tidak dapat dibaca. Hal ini mungkin terjadi setelah pembaruan sistem yang tidak kompatibel dengan aplikasi. Jika tidak demikian, silakan hubungi pengembang. Terima Dengan menggunakan SimpleX Chat, Anda setuju untuk:\n- hanya mengirim konten legal di grup publik.\n- hormati pengguna lain – tidak ada spam. - Konfigurasikan operator server Kebijakan privasi dan ketentuan penggunaan. Obrolan pribadi, grup, dan kontak Anda tidak dapat diakses oleh operator server. Frasa sandi di Keystore tidak dapat dibaca, silakan masukkan secara manual. Hal ini mungkin terjadi setelah pembaruan sistem yang tidak kompatibel dengan aplikasi. Jika tidak demikian, silakan hubungi pengembang. @@ -2388,7 +2385,7 @@ Tinjau anggota sebelum menerima (mengetuk) Mengobrol dengan pengurus semua - Ada kesalahan saat menghapus obrolan dengan anggota + Gagal hapus obrolan Laporan telah dikirim ke pengurus Anda tidak dapat mengirim pesan! kontak telah dihapus @@ -2503,4 +2500,15 @@ Tingkatkan tautan grup? Gunakan profil samaran Pesan sambutan + Gagal menandai dibaca + Sidik jari di alamat server tujuan tidak cocok dengan sertifikat: %1$s. + Sidik jari pada alamat server penerusan tidak cocok dengan sertifikat: %1$s. + Sidik jari di alamat server tidak cocok dengan sertifikat: %1$s. + tidak berlangganan + Anda tidak terhubung ke server yang digunakan untuk menerima pesan dari koneksi ini (tidak berlangganan). + Hapus pesan anggota + Hapus pesan anggota? + Hapus pesan + Pesan anggota akan dihapus - ini tidak dapat dibatalkan! + Hapus pesan diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 35b0bab541..adce58e804 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -18,8 +18,8 @@ connesso errore in connessione - Sei connesso al server usato per ricevere messaggi da questo contatto. - Tentativo di connessione al server usato per ricevere messaggi da questo contatto. + Sei connesso/a al server usato per ricevere messaggi da questa connessione. + Tentativo di connessione al server usato per ricevere messaggi da questa connessione. eliminato contrassegnato eliminato l\'invio di file non è ancora supportato @@ -73,7 +73,7 @@ Errore di eliminazione della connessione del contatto in attesa Errore cambiando l\'indirizzo Test fallito al passo %s. - Il server richiede l\'autorizzazione di creare code, controlla la password + Il server richiede l\'autorizzazione di creare code, controlla la password. Connetti Crea coda Coda sicura @@ -201,7 +201,7 @@ Ripristina OK Connettere via indirizzo del contatto? - Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %1$s). + Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %1$s). Ti connetterai a tutti i membri del gruppo. connessione %1$d Descrizione @@ -210,7 +210,7 @@ Sei già connesso a %1$s. A meno che il tuo contatto non abbia eliminato la connessione o che questo link non sia già stato usato, potrebbe essere un errore; per favore segnalalo. \nPer connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile. - Probabilmente l\'impronta del certificato nell\'indirizzo del server è sbagliata + L\'impronta digitale nell\'indirizzo del server non corrisponde al certificato. SimpleX funziona in secondo piano invece di usare le notifiche push.]]> Consentilo nella prossima schermata per ricevere le notifiche immediatamente.]]> Servizio SimpleX Chat @@ -270,7 +270,7 @@ Permetti ai tuoi contatti di inviare messaggi vocali. Database della chat eliminato ICONA APP - Ideale per la batteria. Riceverai notifiche solo quando l\'app è in esecuzione (NO servizio in secondo piano).]]> + Ideale per la batteria. Riceverai notifiche solo quando l\'app è in esecuzione (NESSUN servizio in secondo piano).]]> Consuma più batteria! L\'app funziona sempre in secondo piano: le notifiche vengono mostrate istantaneamente.]]> chiamata… annulla anteprima link @@ -299,7 +299,7 @@ Copiato negli appunti Crea link di invito una tantum Crea gruppo segreto - Scansiona codice QR.]]> + Scansiona un codice QR.]]> Dalla Galleria Immagine Video @@ -358,12 +358,11 @@ Videochiamata crittografata e2e terminata Come funziona - Come funziona SimpleX Rispondi alla chiamata Audio spento Audio acceso Chiamate audio e video - Auto-accetta le immagini + Accetta automaticamente le immagini hash del messaggio errato ID messaggio errato Chiamata terminata @@ -525,7 +524,7 @@ Autorizzazione negata! Rifiuta (scansiona o incolla dagli appunti) - Scansiona codice QR + Scansiona un codice QR Inizia una nuova conversazione Tocca il pulsante Grazie per aver installato SimpleX Chat! @@ -1015,7 +1014,7 @@ In attesa del video Errore nel caricamento dei server XFTP Errore nel salvataggio dei server XFTP - Il server richiede l\'autorizzazione per l\'invio, controlla la password + Il server richiede l\'autorizzazione per l\'invio, controlla la password. Confronta file Crea file Scarica file @@ -1105,11 +1104,11 @@ Indirizzo SimpleX COLORI DELL\'INTERFACCIA I tuoi contatti resteranno connessi. - Aggiungi l\'indirizzo al tuo profilo, in modo che i tuoi contatti possano condividerlo con altre persone. L\'aggiornamento del profilo verrà inviato ai tuoi contatti. + Aggiungi l\'indirizzo al tuo profilo, in modo che i tuoi contatti di SimpleX possano condividerlo con altre persone. L\'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX. Crea un indirizzo per consentire alle persone di connettersi con te. Crea indirizzo SimpleX - Condividi con i contatti - Condividere l\'indirizzo con i contatti\? + Condividi con i contatti di SimpleX + Condividere l\'indirizzo con i contatti di SimpleX? Smetti di condividere Inserisci il messaggio di benvenuto… (facoltativo) Ciao! @@ -1144,7 +1143,7 @@ Tema scuro Se non potete incontrarvi di persona, mostra il codice QR in una videochiamata o condividi il link. Parliamo in SimpleX Chat - L\'aggiornamento del profilo verrà inviato ai tuoi contatti. + L\'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX. Guida per l\'utente.]]> Menu e avvisi Smettere di condividere l\'indirizzo\? @@ -1362,7 +1361,7 @@ Apri cartella del database La password verrà conservata nelle impostazioni come testo normale dopo averla cambiata o il riavvio dell\'app. La password viene conservata nelle impostazioni come testo normale. - Nota bene: i relay di messaggi e file sono connessi via proxy SOCKS. Le chiamate e l\'invio di anteprime dei link usano una connessione diretta.]]> + Nota bene: i relay di messaggi e file sono connessi via proxy SOCKS. Le chiamate usano una connessione diretta.]]> Cripta i file locali Crittografia di file e media memorizzati Nuova app desktop! @@ -1670,7 +1669,7 @@ Migrazione Migrazione completata Apri la schermata di migrazione - O incolla il link dell\'archivio + O incolla un link dell\'archivio O condividi in modo sicuro questo link del file Incolla link dell\'archivio Chiamate picture-in-picture @@ -1943,7 +1942,7 @@ Riconnetti tutti i server L\'indirizzo del server non è compatibile con le impostazioni di rete: %1$s. File - Scansiona / Incolla link + Incolla link / Scansiona Dimensione carattere Totale inviato Messaggio inoltrato @@ -2014,7 +2013,7 @@ connetti Contatto eliminato! Confermare l\'eliminazione del contatto? - Messaggio + Chatta Nessuna selezione Seleziona Selezionato %d @@ -2041,7 +2040,6 @@ Protegge il tuo indirizzo IP e le connessioni. Salva e riconnetti Ripristina tutti i suggerimenti - Cambia l\'elenco delle chat: Puoi cambiarlo nelle impostazioni dell\'aspetto. Riproduci dall\'elenco delle chat. Aumenta la dimensione dei caratteri. @@ -2207,7 +2205,7 @@ - Apri la chat sul primo messaggio non letto.\n- Salta ai messaggi citati. Condividi indirizzo pubblicamente Condividi l\'indirizzo SimpleX sui social media. - O importa file archivio + O importa un file dell\'archivio Telefoni remoti I messaggi diretti tra i membri sono vietati in questa chat. Dispositivi Xiaomi: attiva l\'avvio automatico nelle impostazioni di sistema per fare funzionare le notifiche.]]> @@ -2376,10 +2374,9 @@ Bloccare i membri per tutti? moderatori Tutti i nuovi messaggi di questi membri verranno nascosti! - Usando SimpleX Chat accetti di:\n- inviare solo contenuto legale nei gruppi pubblici.\n- rispettare gli altri utenti - niente spam. - Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server. + Tu ti impegni a:\n- Pubblicare solo contenuto legale nei gruppi pubblici\n- Rispettare gli altri utenti. Niente spam + Gli operatori si impegnano a:\n- Essere indipendenti\n- Minimizzare l\'uso di metadati\n- Eseguire codice open source verificato Accetta - Configura gli operatori dei server Informativa sulla privacy e condizioni d\'uso. Questo link richiede una versione più recente dell\'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile. Link completo @@ -2437,7 +2434,7 @@ Eliminare la chat con il membro? Rifiutare il membro? Elimina chat - Errore di eliminazione della chat con il membro + Errore di eliminazione della chat Aggiorna l\'indirizzo Accetta la richiesta di contatto Aggiungi un messaggio @@ -2452,7 +2449,7 @@ Entra nel gruppo Apri la chat Apri una chat nuova - Apri un gruppo nuovo + Apri il nuovo gruppo Apri per accettare Apri per connettere Apri per entrare @@ -2534,6 +2531,275 @@ Apri link pulito Apri link completo Rimuovi il tracciamento del link - Link del relay SimpleX - Errore nel segnare la chat con il membro come letta + Indirizzo del relay SimpleX + Errore nel segnare la lettura + L\'impronta digitale nell\'indirizzo del server di destinazione non corrisponde al certificato: %1$s. + L\'impronta digitale nell\'indirizzo del server di inoltro non corrisponde al certificato: %1$s. + L\'impronta digitale nell\'indirizzo del server non corrisponde al certificato: %1$s. + nessuna iscrizione + Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione). + Elimina i messaggi del membro + Eliminare i messaggi del membro? + Elimina i messaggi + I messaggi del membro verranno eliminati. Non è reversibile! + Rimuovi ed elimina i messaggi + Tutti i messaggi + File + Immagini + Link + Cerca file + Cerca immagini + Cerca link + Cerca video + Cerca messaggi vocali + Video + Messaggi vocali + Filtro + CONNESSIONE FALLITA + fallito + Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente. + %1$d/%2$d relay attivo/i + %1$d/%2$d relay attivo/i, %3$d fallito/i + %1$d/%2$d relay connesso/i + %1$d/%2$d relay connesso/i, %3$d errori + %1$d iscritto + %1$d iscritti + accettato + attivo + Bloccare l\'iscritto per tutti? + Annullare la creazione del canale? + Prova il relay per recuperare il suo nome.]]> + %1$s!]]> + canale + Canale + Canale + Link del canale + Membri del canale + Nome del canale + Il canale verrà eliminato per tutti gli iscritti, non è reversibile! + Il canale verrà eliminato per te, non è reversibile! + Il canale sarà operativo con %1$d di %2$d relay. Procedere? + Relay di chat + Relay di chat + Relay di chat + Relay di chat + I relay di chat inoltrano i messaggi nei canali che crei. + I relay di chat inoltrano i messaggi agli iscritti del canale. + Controlla l\'indirizzo del relay e riprova. + Controlla il nome del relay e riprova. + Configura i relay + Connetti + connesso + in connessione + Crea canale pubblico + Crea canale pubblico + Crea canale pubblico (BETA) + Creazione canale + Decodifica il link + Elimina canale + Eliminare il canale? + eliminato + Elimina relay + Modifica profilo canale + Attiva almeno un relay di chat per creare un canale. + Inserisci il nome del relay… + Errore di aggiunta del relay + Errore di creazione del canale + Errore di apertura del canale + fallito + fallito + Ottieni link + Indirizzo del relay non valido! + Nome del relay non valido! + invitato + Link + nuovo + Nuovo relay di chat + Nessun relay di chat + Nessun relay di chat attivato. + Non tutti i relay sono connessi + Apri canale + Apri un canale nuovo + PROPRIETARIO + Proprietari + Indirizzo relay preimpostato + Nome relay preimpostato + relay + RELAY + Indirizzo del relay + Indirizzo del relay + Connessione del relay fallita + Link del relay + Prova del relay fallita! + Rimuovi iscritto + Rimuovere l\'iscritto? + Il server richiede l\'autorizzazione per connettersi al relay, controlla la password. + Avviso del server + Condividi l\'indirizzo del relay + ISCRITTO + Iscritti + Gli iscritti usano il link del relay per connettersi al canale.\nL\'indirizzo del relay è stato usato per impostare questo relay per il canale. + L\'iscritto verrà rimosso dal canale, non è reversibile! + Prova fallita al passo %s. + Prova relay + Questo è un indirizzo di relay di chat, non può essere usato per connettersi. + Sbloccare l\'iscritto per tutti? + Usa per canali nuovi + Usa relay + Verifica + via %1$s + La registrazione vocale non è supportata sulla tua piattaforma + Attendi + Attendi risposta + tu + sei iscritto/a + Ti sei connesso/a al canale attraverso questo link del relay. + Il tuo canale + Il tuo canale + Il tuo profilo %1$s verrà condiviso con i relay del canale e gli iscritti.\nI relay hanno accesso ai messaggi del canale. + L\'indirizzo del tuo relay + Il nome del tuo relay + Smetterai di ricevere messaggi da questo canale. La cronologia della chat sarà preservata. + Iscriviti al canale + Esci dal canale + Uscire dal canale? + Tocca Iscriviti al canale + Puoi condividere un link o un codice QR, chiunque sarà in grado di iscriversi al canale. + Trasmetti + Nome completo del canale: + Il profilo del canale è memorizzato sui dispositivi degli iscritti e sui relay di chat. + profilo del canale aggiornato + %d eventi del canale + canale eliminato + scartato (%1$d tentativi) + errore: %s + Errore di salvataggio del profilo del canale + Errore del messaggio + Salva e avvisa gli iscritti del canale + Salva il profilo del canale + L\'app ha rimosso questo messaggio dopo %1$d tentativi di riceverlo. + profilo del canale aggiornato + %1$d/%2$d relay attivi, %3$d errori + %1$d/%2$d relay attivi, %3$d rimossi + %1$d/%2$d relay connessi, %3$d falliti + %1$d/%2$d relay connessi, %3$d rimossi + %1$d relay falliti + %1$d relay non attivi + %1$d relay rimossi + L\'aggiunta di relay verrà supportata prossimamente. + Tutti i relay falliti + Tutti i relay rimossi + impossibile trasmettere + Il canale non ha relay attivi. Prova a iscriverti più tardi. + Canale non disponibile temporaneamente + inattivo + Nessun relay attivo + rimosso da un operatore + In attesa che il proprietario del canale aggiunga dei relay. + Indirizzo di lavoro + Link del canale + Indirizzo di contatto + Errore nella condivisione del canale + (dal proprietario) + Link del gruppo + Firma del link verificata. + Condividi canale… + Condividi via chat + ⚠️ Verifica della firma fallita: %s. + (firmato) + Tocca per aprire + Link una tantum + Disattiva + Attiva + Attivare le anteprime dei link? + Errore + Errore di rete + Risultati relay: + L\'invio di un\'anteprima del link può rivelare il tuo indirizzo IP al sito. Puoi modificarlo nelle impostazioni di Privacy più tardi. + La connessione ha raggiunto il limite di messaggi non consegnati + Preferenze del canale + Solo i proprietari del canale possono modificarne le preferenze. + Un link per una persona da connettere + Canali + Connetti via link o codice QR + Crea il tuo link + Crea il tuo indirizzo pubblico + Per chiunque debba raggiungerti + Invita qualcuno in modo privato + Lascia che qualcuno si connetta a te + Nuovo link una tantum + O mostra il QR di persona o via videochiamata. + O usa questo QR: stampalo o mostralo online. + Invia il link tramite qualsiasi messenger, è sicuro. Chiedi di incollarlo in SimpleX. + Parla con qualcuno + Usa questo indirizzo nel tuo profilo di social media, sito web o firma email. + Il tuo indirizzo pubblico + È più facile invitare i tuoi amici 👋 + Organizzazione non a scopo di lucro + Per la sostenibilità della rete di SimpleX. + - scegli se inviare anteprime dei link.\n- usa il proxy SOCKS se attivato\n- previeni il phishing dei collegamenti ipertestuali.\n- rimuovi il tracciamento dei link. + Proprietà: puoi gestire i tuoi relay personali. + Privacy: per i proprietari e gli iscritti. + Canali pubblici - parla liberamente 🚀 + Affidabilità: relay multipli per canale. + Link web sicuri + Sicurezza: solo i proprietari hanno le chiavi del canale. + Abbiamo semplificato la connessione per i nuovi utenti. + Sei nato senza un account. + Nessuno monitorava le tue conversazioni. Nessuno disegnava una mappa delle tue posizioni. La privacy non era mai stata una caratteristica, era uno stile di vita. + Poi ci siamo trasferiti online e ogni piattaforma ha chiesto un pezzo di noi: il nome, il numero, gli amici. Abbiamo accettato che il prezzo da pagare per comunicare con gli altri fosse quello di far sapere a qualcuno con chi parliamo. Ogni generazione, sia di persone che di tecnologia, ha funzionato così: telefono, email, messenger, social media. Sembrava l\'unico modo possibile. + C\'è un\'altra via. Una rete senza numeri di telefono. Senza nomi utente. Senza account. Senza identificatori utente di alcun tipo. Una rete che connette le persone e trasferisce messaggi crittografati senza sapere chi è connesso. + Non una serratura migliore sulla porta di qualcun altro. Non un padrone di casa più gentile che rispetta la tua privacy, ma che continua a tenere traccia di tutti i visitatori. Non sei un ospite. Sei a casa tua. Nessun re può entrarvi: sei tu il sovrano. + Le tue conversazioni appartengono a te, come è sempre stato prima dell\'avvento di internet. La rete non è un luogo che visiti. È un luogo che crei e possiedi. E nessuno può portartelo via, che tu lo renda privato o pubblico. + La più antica libertà umana, parlare con un\'altra persona senza essere osservati, si basa su un\'infrastruttura che non può tradirla. + Perché abbiamo distrutto il potere di sapere chi sei. In modo che il tuo potere non possa mai esserti sottratto. + Vivi libero nella tua rete. + + Segnalazioni degli iscritti + Permetti l\'invio di messaggi diretti agli iscritti. + Proibisci l\'invio di messaggi diretti agli iscritti. + Invia fino a 100 ultimi messaggi ai nuovi iscritti. + Non inviare la cronologia ai nuovi iscritti. + Gli iscritti al canale possono inviare messaggi a tempo. + Gli iscritti al canale possono inviare messaggi diretti. + I messaggi diretti tra gli iscritti sono vietati. + Gli iscritti al canale possono eliminare irreversibilmente i messaggi inviati. (24 ore) + Gli iscritti al canale possono aggiungere reazioni ai messaggi. + Gli iscritti al canale possono inviare messaggi vocali. + Gli iscritti al canale possono inviare file e contenuti multimediali. + Gli iscritti al canale possono inviare link di Simplex. + Gli iscritti possono segnalare messaggi ai moderatori. + Vengono inviati ai nuovi iscritti fino a 100 ultimi messaggi. + La cronologia non viene inviata ai nuovi iscritti. + Consenti ai membri di chattare con gli amministratori. + Consenti agli iscritti di chattare con gli amministratori. + Vivi libero\nnella tua rete + Le chat con gli amministratori sono vietate. + Le chat con amministratori in canali pubblici non hanno crittografia E2E: usale solo con relay di chat fidati. + Le chat con i membri sono disattivate + Chat con amministratori + Attiva + Attivare le chat con gli amministratori? + Inserisci nome profilo… + I membri possono chattare con gli amministratori. + non sono crittografati end-to-end. I relay di chat possono vedere questi messaggi.]]> + Migra + Impegni sulla rete + Gli instradatori di rete non possono\nsapere chi parla con chi + Nessun account. Nessun telefono. Nessuna email. Nessun identificatore.\nLa crittografia più sicura. + Sul tuo telefono, non sui server. + Aprire il link esterno? + Messaggistica privata e sicura. + Vieta le chat con gli amministratori. + Configura le notifiche + Configura gli instradatori + Gli iscritti possono chattare con gli amministratori. + La prima rete in cui possiedi\ni tuoi contatti e i tuoi gruppi. + Perché costruiamo SimpleX. + La tua rete + Il tuo profilo + Cominciamo + Barra inferiore + L\'anteprima del link verrà richiesta via proxy SOCKS. La ricerca DNS può ancora accadere localmente tramite il tuo risolutore DNS. + Barra superiore diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index b71831c9e0..faf69dfd03 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -482,7 +482,6 @@ הסתר פרופיל איך להשתמש במרקדאון איך זה עובד - איך SimpleX עובדת נתק עזרה הקבוצה תימחק עבור כל חברי הקבוצה – לא ניתן לבטל זאת! @@ -945,7 +944,7 @@ הצג קוד QR נעילת SimpleX כוכב ב־GitHub - לשתף כתובת עם אנשי קשר\? + לשתף כתובת עם אנשי קשר? עצור שיתוף הגדרת קוד גישה נעילת SimpleX @@ -1063,7 +1062,7 @@ כדי לאמת הצפנה מקצה־לקצה עם איש הקשר שלכם, יש להשוות (או לסרוק) את הקוד במכשירים שלכם. פרופילי צ׳אט לעדכן מצב בידוד תעבורה\? - מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה (שגיאה: %1$s). + מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה (שגיאה: %1$s). פורמט הודעה לא ידוע דרך הדפדפן בטל השתקה @@ -2095,7 +2094,6 @@ לשליחה צ\'אט אחד עם חבר הודעה חדשה - הגדרת מפעילי שרת ניתן להגדיר שרתים דרך הגדרות. אישר אותך ממתין לסקירה @@ -2138,4 +2136,17 @@ פתח שיחה חדשה פתח קבוצה חדשה שלח את המשוב הפרטי שלך לקבוצות. + הסכמה לבקשת חבר + הערות + לא נבחר כלום להעברה! + התראות וסוללה + הוסף הודעה + אפשר קבצים ומדיה רק כאשר החבר מאשר אותם + אפשר לאנשי קשר שלך לשלוח קבצים ומדיה + אודות: + האודות ארוך מדי + בוט + אתה והאיש קשר שלך יכולים לשלוח קבצים ומדיה + צ\'אט עסקי + אי אפשר לשנות תמונת פרופיל diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index efc1810bac..5c17946c24 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -153,7 +153,6 @@ 通知の常時受信 SMPサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 WebRTC ICEサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 - SimpleX の仕様 通話中 電池消費がより高い!非アクティブ時でもバックグラウンドのサービスが常に稼働します(着信してすぐに通知が出ます)。]]> 発信中 @@ -347,7 +346,7 @@ データベースパスフレーズ データベースをエクスポート データベースを削除 - データベースを読み込みますか? + データベースのインポート 新しいデータベースのアーカイブ 過去のデータベースアーカイブ ファイルを全て削除 @@ -887,7 +886,7 @@ あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 あなたのチャットプロフィールが他のグループメンバーに公開されます。 エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 - このコンタクトから受信するメッセージのサーバに接続しようとしてます。(エラー: %1$s)。 + このコンタクトから受信するメッセージのサーバに接続しようとしてます。(エラー: %1$s)。 使用済みリンク、または連絡先による接続の削除ではなければ、バッグの可能性があります。開発者にお伝えください。 \n繋がるには、連絡先に新しくリンクを発行してもらって、電波が安定かどうかご確認ください。 接続を完了するには、連絡相手がオンラインになる必要があります。 @@ -1097,7 +1096,7 @@ システム認証の代わりに設定します。 プロフィールを非表示にできます! リレー サーバーは IP アドレスを保護しますが、通話時間は監視されます。 - アドレスを連絡先と共有しますか\? + アドレスを連絡先と共有しますか? 保留中の通話 データ移行の確認が正しくない %s を提供しました @@ -1826,7 +1825,6 @@ SMPサーバーの構成 接続中 XFTPサーバーの構成 - チャトリスト切り替え 連絡先 メッセージサーバ メディア&ファイルサーバ @@ -2014,7 +2012,6 @@ プライバシーとセキュリティの向上 承諾 プライバシーポリシーと利用条件 - サーバオペレータの設定 承諾 サーバオペレータは、プライベートチャット・グループ・連絡先にはアクセスできません。 SimpleX Chat を利用することで、以下の事項に同意したものと見なされます:\n- パブリックグループでは合法なコンテンツのみを送信すること。\n- 他のユーザを尊重すること、またスパムメッセージを送信しないこと。 @@ -2042,4 +2039,23 @@ プライベートメッセージルーティング用のサーバーがありません。 メディアおよびファイルサーバーは存在しません。 ファイルを送信するサーバーがありません。 + ソーシャルメディア向け + サーバを利用する + あなたのサーバ + ビデオ + ファイル + 画像 + リンク + すべて + 音声メッセージ + フィルター + メンバーとして承認する + オブザーバーとして承認する + スパム + アーカイブ + 自己紹介 + 自己紹介の文字数が上限を超えています + ぼかし + 連絡先 + お気に入り diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index 651d32518f..83f937db32 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -500,7 +500,6 @@ 설명서 내 서버 사용법 마크다운 사용법 - SimpleX 작동 방식 그룹 초대가 만료되었어요. 그룹 멤버는 보낸 메시지를 영구 삭제할 수 있습니다. (24 시간) 그룹 멤버는 음성 메시지를 보낼 수 있어요. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml new file mode 100644 index 0000000000..92985b15be --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -0,0 +1,836 @@ + + + Profîla niha bişuxulîne + Komê veke + Komeke nû veke + Lînka xelet + Databas tê vekirin… + xeletî + Profîl nikarîbû were çêkirin! + Profîl nikarîbû were guhertin! + Ti serverên medya & dosyayan nînin. + Ji min re + Ji hemû moderatoran re + Xeletî: %1$s + Cewab bide + Kopî bike + Qeyd bike + Biguhere + Melûmat + Lê bigere + Li sûretan bigere + Li vîdyoyan bigere + Li dosyayan bigere + Li lînkan bigere + Sûret + Vîdyo + Dosya + Lînk + Tarîx + Tarîx nîne + Cewab ji bo + Qeydkirî + Hatiye qeydkirin ji + Jê bibe + Veşêre + Bihêle + Gilî bike + Hilbijêre + Mezin bike + Şandina dosyayê bisekinîne? + Şandina dosyayê wê bê sekinandin. + Standina dosyayê bisekinîne? + Bisekinîne + Dosya wê ji serveran bê jêbirin. + Daxe + Lîste + Endam ne aktîv e + guhertî + şandî + şandin bi ser neket + nexwendî + Bi xêr hatî %1$s! + Bi xêr hatî! + Ev nivîs di eyaran de heye + Eyar + Bi navê %s bikeviyê + redkirî + Hemû + Lîste lê zêde bike + 1 gilîkirin + %d gilîkirin + Gilîkirinên endaman + Zêde sûret hene! + Zêde vîdyo hene! + Tenê 10 sûret karin di derbekê de werin şandin + Tenê 10 vîdyo karin di derbekê de werin şandin + Xeletiya dekodkirinê + Sûret nikare were dekodkirin. Bi xêra xwe, sûretekî dî biceribîne yan jî xeberê bide mielifan. + Dosya û medya memnû in! + Tenê xwediyên koman karin dosya û medya aktîv bikin. + Lînkên SimpleXê memnû in + Xwestinê bişîne + xwestin şandî ye + ne sinkronîzekirî ye + Bi xêra xwe xeberê bide admînê komê. + kom jêbirî ye + ji komê derxistî + tu derketî + Sûret + Li hêviya sûret e + Sûret hat şandin + Li hêviya sûret e + Vîdyo + Li hêviya vîdyo ye + Vîdyo hat şandin + Li hêviya vîdyoyê ye + Dosya + Koda emniyetê tesdîq bike + Kamera + Ji Galeriyê + Dosya + Dosyakê hilbijêre + Sûret + Vîdyo + Pêl pişkokê bike + li ser, piştre: + Komekê çêke: ji bo çêkirina komeke nû.]]> + Qebûl bike + Red bike + Heya kesê ko şandiye jê çênabe. + Endam hatiye jêbirin - nikare xwestinê qebûl bike + Jê bibe + Jê bibe + Xwendî nîşan bide + Nexwendî nîşan bide + Bêdeng bike + Hemûka bêdeng bike + Bêdengkirinê betal bike + Bike favorît + Ji favorîtan derxe + Behsên nexwendî + Lîste çêke + Li lîstê zêde bike + Lîstê biguhere + Lîstê qeyd bike + Navê lîstê... + Navê lîstê û emojiya wê divê ji bo her lîsteyî cuda be. + Jê bibe + Lîstê jê bibe? + Biguhere + Rêzê biguhere + sûretê profîlê + Pişkoka girtinê + Eyar + Koda QRyê + Adresa SimpleXê + arîkarî + Taximê SimpleXê + Logoya SimpleXê + E-poste + Bêhtir + Koda QRyê nîşan bide + Lînka 1-carê bi hevalekî re parve bike + Ji bo ko tu xwe ji guhertina lînka biparêzî, tu karî kodên emniyetê yên kontaktê qiyas bikî. + Yan jî vê kodê nîşan bide + Lînka timam + Lînka kurt + Profîlê parve bike + Profîl nikarîbû were guhertin + Yan jî koda QRyê skan bike + Dewetiya neşuxulandî bihêle? + Bihêle + Lînk tê çêkirin… + Dîsa biceribîne + Vê lînka 1-carê parve bike + Lînka ko te standiye bizeliqîne + Nivîsa ko te zeliqand ne lînkeke SimpleXê ye. + Pêl vir bike ji bo zeliqandina lînkê + Profîla te + Profîl nikare were guhertin + Kod skan bike + Koda emniyetê xelet e! + Koda emniyetê + Tesdîqkirî nîşan bide + Tesdîqkirinê jê bibe + %s tesdîqkirî ye + %s ne tesdîqkirî ye + Eyarên te + Adresa te yî SimpleXê + Li ser SimpleX Chat + Çawa tê şuxulandin + Arîkariya Markdownê + Pirsan û fikran bişîne + E-poste ji me re bişîne + Server bi kar bîne + Serverên SimpleX Chat tên şuxulandin. + Çilo + Çilo yek serverên xwe dişuxulîne + Mecbûrî + Neparastî + Ne ti carî + Erê + Wextê ko IP veşartî ye + Na + Girtî + Saxlem + Beta + %s (%s) daxe + Cihê dosyayê veke + Dûvre bîne bîra min + Adresê jê bibe? + Lînkê parve bike + Adresa SimpleXê çêke + Hew adresê parve bike? + Hew parve bike + Qebûlkirina ji ber xwe ve + Eyaran qeyd bike? + Eyarên adresa SimpleXê qeyd bike + Adresê jê bibe + Hevalan dewet bike + Em li SimpleX Chat qise bikin + Ji bo medyaya sosyal + Yan ji bo parvekirina şexsî + Adresa SimpleXê yan jî lînka 1-carê? + Lînka 1-carê çêke + Eyarên adresê + Sûret jê bibe + Tercihan qeyd bike? + Bê qeydkirinê derkeve + Profîlê veşêre + Şîfra profîlê qeyd bike + Li ser SimpleXê + Markdown çawa tê şuxulandin + qalin + xwehr + a + b + bi reng + tê telefonkirin… + Kamera + Kamera û mîkrofon + Van destûran bide ji bo telefonkirinê + Di eyaran de destûrê bide + Vê destûrê di eyarên Androidê de bibîne û bixwe destûrê bide. + Eyaran veke + Bluetooth + Profîla xwe çêke + Çawa dişuxule + Çawa tesîrê li pîlê dike + Her serê pêlekê + Di cih de + Notîfîkasyon û pîl + Tu karî serveran ji eyaran eyar bikî. + Dewam bike + Qebûl bike + Red bike + Qebûl bike + Nîşan bide + Eyar nikarîbû were guhertin + Dewam bike + Şîfrê ji eyaran bibe? + Jê bibe + Şîfra niha… + Şîfra nû… + Endaman dewet bike + Çêtir tecrûba karber + Adresa xwe parve bike + saniye + deqe + seet + roj + heftî + heyv + Hilbijêre + Telefonekê girê de + Telefonên girêdayî + Ji telefonê skan bike + Navê vê cihazê + (ev cihaz v%s)]]> + Telefona girêdayî + Girêdayî telefonê + Navê vê cihazê binivîsîne… + Xeletî + Ev cihaz + Cihaz + Cihaza mobîlê yî nû + %s hat qutkirin]]> + Girêdan sekinî + Girêdan sekinî + Hemû statîstîkan vala bike + Serveran qeyd bike? + Skan bike / Lînk bizeliqîne + Koda QRyê skan bike + Ji kompîterê koda QRyê skan bike + Koda QRyê ya serverê skan bike + %s (niha) + %s daxistî + lê bigere + Lê bigere yan jî lînka SimpleXê bizeliqîne + san + Ê diwan + Dora emîn + koda emniyetê hat guhertin + Xeletiyên şandinê + şandina dosyayan hê ne mimkun e + Tê şandin bi riya + Pêşdîtinên lînkan bişîne + Gilîkirinên şexsî bişîne + Wextê şandinê: + Cewaba şandî + Bi riya proksiyê şandî + Server + Adresa serverê + Adres + Adresa serverê li eyarên torê nayê. + SERVER + Melûmata serveran + Ceribandina serverê bi ser neket! + Versiyona serverê li eyarên torê nayê. + 1 roj deyne + Tercihên komê diyar bike + EYAR + Parve bike + Lînka 1-carê parve bike + Adresê parve bike + Adresê bi hişkereyî parve bike + Dosya parve bike… + Medya parve bike… + Adresa kevn parve bike + Lînka kevn parve bike + Adresa SimpleXê li medayaya sosyal parve bike. + Behsa kin: + Adresa SimpleXê yî kin + Nîşan bide: + Pêşdîtinê nîşan bide + Bigire + Bigire? + SimpleX + Adresa SimpleXê + Lînka qenala SimpleXê + Xizmeta SimpleX Chatê + Lînka komê ya SimpleXê + Lînkên SimpleXê + Lînkên SimpleXê + Lînkên SimpleXê memnû in. + simplexmq: v%s (%2s) + Dewetiya yek carê ya SimpleXê + Mezinbûnî + Ser bakirina endaman ve derbas bibe + Funksiyona hêdî + Komên piçûk (herî zêde 20) + Servera SMPyê + Serverên SMPyê + Nerm + Bêdeng + Spam + Spam + Çarçik, girover, yan çi tiştê di neqebê de. + %s: %s + %s saniye + %s server + Li GitHubê stêrkê bide + dest pê dike… + Her serê pêlekê dest pê dike + Statîstîk + Bisekinîne + Dosyayê bisekinîne + xet/xêz/xîşk + Biqewet + Abonekirî + PIŞT BIDE SIMPLEX CHATÊ + Biguhere + Sîstem + Sîstem + Sîstem + Sîstem + Moda sîstemê + Terî/Dûvik + Pêl Adresa SimpleXê çêke di meniwê de ji bo ko tu dûvre çêkî. + Pêl Bikeve komê bike + Bikeviyê + Bikeve komê + Bikeve komê? + Bikeve komê? + Dikeve komê + Bikeve koma xwe? + %1$d dosya hê tê(n) daxistin. + %1$d dosya nikarîbû(n) wer(e/in) daxistin. + %1$d dosya hat(in) jêbirin. + %1$d dosya nehat(in) daxistin. + %1$d xeletiyên dosyayê ên dî. + %1$s ENDAM + 1 roj + 1 deqe + 1 heyv + lînka 1-carê + 1 heftî + 1 sal + 30 saniye + 5 deqe + Betal bike + Guhertina adresê betal bike + Guhertina adresê betal bike? + Li ser adresa SimpleXê + Qebûl bike + Qebûl bike + Qebûl bike + Wek endamekî qebûl bike + Şertan qebûl bike + %1$s hat qebûlkirin + Şertên qebûlkirî + dewetiyê qebûl kir + tu qebûl kirî + Endam qebûl bike + Girêdanên aktîv + Hevalan lê zêde bike + Guhertina adresê wê bê betalkirin. Adresa berê yî standinê wê bê şuxulandin. + Adres yan jî lînka 1-carê? + Serverekê lê zêde bike + Bi riya skankirina kodên QRyê serveran lê zêde bike. + Endamên têxim lê zêde bike + Cihazeke dî lê zêde bike + admîn + admîn + Admîn karin endamekî ji bo her kesî blok bikin. + Admîn karin lînkên lêzêdebûna koman çêkin. + Eyarên torê ên pêşketî + Eyarên pêşketî + Eyarên pêşketî + Hinek tiştên dî + hemû + Hemû dataya aplîkasyonê hat jêbirin. + hemû endam + Bihêle + Bihêle ko dosya û medya werin şandin. + Bihêle ko lînkên SimpleXê werin şandin. + Hemû profîl + Hemû server + Jixwe tê girêdan! + Jixwe dikeve komê! + hercar + Hercar + Hercar vekirî + û %d hewadîsên dî + Biguhere + Şîfra databasê biguhere? + rola %s hat guhertin %s + rola te hat guhertin %s + Rola komê biguhere? + Adresa standinê biguhere + Adresa standinê biguhere? + Rolê biguhere + adres tê guhertin… + adres tê guhertin… + adresa %s tê guhertin… + %1$d xeletiyên dosyayan:\n%2$s + %1$s dixwaze bi te re bikeve danûstandinê bi riya + Adresa serverê kontrol bike û dîsa biceribîne. + Girêdana xwe yî înternetê kontrol bike û dîsa biceribîne + Notên şexsî vala bike? + Pêl pişkoka melûmatê ya nêzîkî cihê adresê bike ji bo destûrdana mîkrofonê. + Moda reng + Di wextekî nêzîk de tê! + Dosya qiyas bike + timam + Timam bûye + Vala bike + Vala bike + Vala bike + Şert di %s de hatin qebûlkirin. + Şertên şuxulandinê + Şert wê di %s de bên qebûlkirin. + Serverên SMPyê ên eyarkirî + Serverên XFTPyê ên eyarkirî + Serverên ICEyê eyar bike + Dosyayên ji serverên nenas qebûl bike. + Eyarên torê tesdîq bike. + Şîfra nû dîsa binivîsîne… + Bi xwe re bikeve danûstandinê? + Bi riya lînkê bikeve danûstandinê + Bi riya lînkê bikeve danûstandinê? + Bi riya lînkê / koda QRyê bikeve danûstandinê + Bi riya lînka yek carê bikeve danûstandinê? + Bi %1$s re bikeve danûstandinê? + Muhtewa ne li gora şertên şuxulandinê ye + Îkona kontekstê + Dewam bike + Beşdar bibe + Tora xwe kontrol bike + Xeletiyê kopî bike + Çêke + Çêke + Adres çêke + Adresekê çêke ji bo ko xelk karibin bi te re bikevin danûstandinê. + Hat çêkirin + Wextê çêkirinê + Wextê çêkirinê: %s + Dosya çêke + Kom çêke + Lînka komê çêke + Lînk çêke + Lînkeke dewetiyê ya yek carî çêke + Profîl çêke + Profîl çêke + Dor çêke + Komeke veşartî çêke + Komeke veşartî çêke + Adresa xwe çêke + Lînka arşîvê tê çêkirin + kesê ko çêkiriye + Xeletiya cidî + (niha) + Profîla niha + Tarî + Tarî + Moda tarî + Rengên moda tarî + Xuyakirina tarî + IDya databasê + IDya databasê: %d + %dr + %d roj + %d roj + jiberxweve (%s) + jiberxweve (%s) + Jê bibe piştî + Hemû dosyayan jê bibe + Jêbirî + Wextê jêbirinê + Wextê jêbirinê: %s + Dosya jê bibe + Ji bo min jê bibe + Komê jê bibe + Komê jê bibe? + Lînkê jê bibe + Lînkê jê bibe? + Profîlê jê bibe + Dorê jê bibe + Serverê jê bibe + Xeletiyên jêbirinê + Gihan/Gihiştin + Cihazên kompîter + Kompîter mijûl e + Kompîter ne aktîv e + Girêdana bi kompîterê re qut bû + Detay + CIHAZ + %d dosya bi mezibnbûniya timam ya %s + %d hewadîsên komê + %d seet + %d seet + Bigire + girtî + girtî + Ji bo her kesî bigire + Ji bo hemû koman bigire + %d deqe + %d deqe + %d heyv + %d heyv + %d heyv + Adres çêneke + Dîsa nîşan nede + Daxe + Daxistî + Dosyayên daxistî + Xeletiyên daxistinê + Daxistin bi ser neket + Dosya daxe + Detayên lînkê tên daxistin + %d heftî + Profîla komê biguhere + Sûret biguhere + Veke + vekirî + Vekirî heta + ji te re vekirî + Ji bo her kesî veke + Ji bo hemû koman veke + xilasbûyî + Navê komê binivîsîne: + Şîfra rast binivîsîne. + Şîfrê binivîsîne + Şîfrê binivîsîne… + Di lêgeranê de şîfrê binivîsîne + Navê xwe binivîsîne: + Xeletî + Xeletî + Xeletî + Xeletî di betalkirina guhertina adresê de + Xeletî di qebûlkirina şertan de + Xeletî di qebûlkirina xwestina ketina danûstandinê de + Xeletî di qebûlkirina endêm de + Xeletî di lêzêdekirina endam(an) de + Xeletî di lêzêdekirina serverê de + Xeletî di guhertina adresê de + Xeletî di guhertina profîlê de + Xeletî di guhertina rolê de + Xeletî di çêkirina adresê de + Serverên te yên XFTPyê + Te dewetîke komê şand + Serverên te yên SMPyê + Serverên te + Adresa servera te + Servera te + Profîla te yî %1$s wê bê parvekirin. + Tercihên te + Serverên te yên ICEyê + Serverên te yên ICEyê + Koma te + te %1$s derxist + Te dewetiya komê red kir + Profîla te yî niha + Tu karî dûvre wê çêkî + Tu dikarî wê di Eyarên xuyakirinê de biguherî. + te %s blok kir + Tu hatiye dewetkirinî komê + Tu jixwe dikevî vê komê bi riya vê lînkê. + Tu dihêlî + te ev endam qebûl kir + tu: %1$s + TU + tu + Erê + erê + Serverên XFTPyê + Servera XFTPyê + Şîfra xelet! + Şîfra xelet ya databasê + Bi kêmtir xerckirina pîlê. + Bi kêmtir xerckirina pîlê. + Bê Tor yan VPNê, adresa te yî IPyê wê ji van relayên XFTPyê re xuya bike:\n%1$s, + Bê Tor yan jî VPNê, wê adresa te yî IPyê ji serverên dosyayen re xuya bike. + Etherneta bi qeblo + WiFi + Çi yî nû heye + Websîte + Serverên WebRTC ICEyê + Hişyarî: hinek dataya te kare winda bibe! + dixwaze bi te re bikeve danûstandinê! + Girtî + Bi xêra xwe aplîkasyonê ji nû ve veke. + Veşêre: + Navê profîlê: + Navê timam: + Qeyd bike û xeberê bide endamên komê + Şîfra nîşandanê + Şîfra profîla veşartî + Tu karî markdownê bişuxulînî ji bo formatkirina mesajan: + Bi şuxulandina SimpleX Chatê tu qebûl dikî ku tu:\n- di komên vekirî tenê muhtewaya qanûnî bişînî.\n- hurmeta karberên dî bigirî – spam çênabe. + Veke + bi riya relayê + Vidyo girtî + Vîdyo vekirî + Deng girtî + Deng vekirî + Ekrana aplîkasyonê biparêze + Ji ber xwe ve sûretan qebûl bike + Adresa IPyê biparêze + Girtî + Na + Bipirse + Ber lînka webê were vekirin? + Lînkê veke + Lînka timam veke + Lînka paqij veke + ARÎKARÎ + APLÎKASYON + DOSYA + Ji nû ve veke + PROKSIYA SOCKSÊ + Sûretên profîlan + Girêdana torê + Ji kompîterê bişuxulîne + Xeletiya databasê + Dosya: %s + Xeletî: %s + Xeletiya nenaskirî + dewetiya ji bo koma %1$s + Derkeve + Kom nehat dîtin! + Ev kom nema heye. + derket + tu derketî + %s û %s + %s, %s û %d endam + adresa ji bo te hat guhertin + te adresa ji bo %s guhert + te adres guhert + mielif + endam + moderator + xwedî + redkirî + derxistî + derketiye + nayê zanîn + Endam %1$s + Rola endamên nû + Rola pêşî + Ji komê derkeve + Lînka komê + Xeletî di şandina dewetiyê de + Halê dosyayê + Wextê standinê + Wextê nûkirina qeydiyê: %s + Halê dosyayê: %s + Wextê şandinê: %s + Wextê standinê: %s + nivîs nîne + Endam derxe? + Endaman derxe? + Endam derxe + Derxe + Endam derxe + Endam blok bike + Blok bike + Ji admîn blokkirî + blokkirî + ne aktîv + ENDAM + Rol + Kom + Te standin bi riya + Halê torê + Girêdanê biedilîne + Biedilîne + Navê timam î komê: + Profîla komê di cihazên endaman de qeydkirî ye, ne di serveran de. + Profîla komê qeyd bike + Serveran bişuxulîne + %s bişuxulîne + Li şertan meyzîne + Şertên nûkirî + %s bişuxulînî, şertên şuxulandinê qebûl bike.]]> + Ji bo dosyayan bişuxulîne + Serverên medya & dosyayê ên lêzedekirî + Şertan veke + Guhertinan veke + Protokola serverê hat guhertin. + Reqema PINGan + TCP keep-alive aktîv bike + Qeyd bike + Qeyd bike û dîse girê de + Eyarên torê nû bike? + Profîl lê zêde bike + Girêdanên profîl û serveran + Veşêre + Nîşan bide + Bêdeng bike + Bêdengkirinê betal bike + Profîlê bike şexsî! + Şîfra profîlê + Rehnik + Rehnik + Reş + Sernav + Cewaba standî + Sûret jê bibe + Mezinbûniya fontê + Şefafî + Êvara te bi xêr! + Sibeha te bi xêr! + Dagire + Endam karin dosya û medya bişînin. + Dosya û medya memnû in. + Endam karin lînkên SimpleXê bişînin. + %d san + %d heftî + UIa farisî + Eyarên nû yên medyayê + Aplîkasyonê bi yek destî bişuxulîne. + Mezinbûniya fontê zêde bike. + Sebeba qutbûna girêdanê: %s + Ev lînk bi telefoneke dî re hatiye şuxulandin, bi xêra xwe li kompîterê lînkeke nû çêke. + Girêdana bi kompîterê re qut bike? + Tenê yek cihaz kare di eynî wextî de bişuxule + Li hêviya girêdana telefonê: + Ji ber xwe ve girê de + %s ne aktîv e]]> + %s mijûl e]]> + %s re di halekî xirab de ye]]> + Siḧbetê veke + Xeletî di çêkirina lîsta siḧbetan de + Xeletî di vekirina siḧbetê de + Siḧbetê bisekinîne + Profîlên siḧbetê biguhere + Siḧbet + Ti siḧbetên te nînin + Ti siḧbet di lîsta %s de nînin. + Ti siḧbetên nexwendî nînin + Siḧbet nînin + Ti siḧbet nehatin dîtin + Siḧbeta hilbijartî nîne + Tiştekî hilbijartî nîne + %d hilbijartî + Favorît + Kom + %d siḧbetên bi endaman + 1 siḧbeta bi yek endamî + %d siḧbet + Robot + Kom + Navê siḧbetê deyne… + Siḧbeteke nû bide destpêkirin + Ji bo ko yek siḧbete nû bide destpêkirin + Siḧbet ber were valakirin? + Hemû mesaj wê bên jêbirin - ev nikare were betalkirin/vegerandin! Wê mesaj TENÊ ji bo te bên jêbirin. + Siḧbetê vala bike + Hemû siḧbet wê ji lîsta %s bên jêbirin, û wê lîste bê jêbirin + Siḧbeta nû + Profîla sihbetê hilbijêre + Profîlên te yên siḧbetê + Profîla siḧbetê çêke + Ber serverên SimpleX Chatê werin şuxulandin? + Profîla siḧbetê + Tu siḧbeta xwe qontrol dikî! + Siḧbetê bişuxulîne + SIḦBET + Rengên siḧbetê + Siḧbet sekinandî ye + DATABASA SIḦBETÊ + Ber siḧbet were sekinandin? + Xeletî di sekinandina siḧbetê de + Ber profîla siḧbetê were jêbirin? + ne ti carî + Şîfra databasê lazim e ji bo vekirina siḧbetê. + Şîfre qeyd bike û siḧbetê veke + Siḧbetê veke + Siḧbet sekinandî ye + Ber siḧbet were destpêkirin? + Tu dixwazî ji siḧbetê derkevî? + Siḧbetê jê bibe + Ber siḧbet were jêbirin? + Wê siḧbet ji bo te bê jêbirin - ev nikare were betalkirin/vegerandin! + Ji siḧbetê derkeve + Tenê xwediyên siḧbetê karin tercihan biguherin. + Bi admînan re siḧbetê bike + Bi endam re siḧbetê bike + Wê endêm ji siḧbetê bê derxistin - ev nikare were betalkirin/vegerandin! + Wê endam ji siḧbetê bên derxistin - ev nikare were betalkirin/vegerandin! + Siḧbet + Wê profîla te yî siḧbetê ji endamên komê re bê şandin + Wê profîla te yî siḧbetê ji endamên siḧbetê re bê şandin + Serverên ji bo dosyayên nû ên profîla te yî siḧbetê ya niha + Ber profîla siḧbetê were jêbirin? + Hemû mesaj wê bên jêbirin - ev nikare were betalkirin/vegerandin! + Profîla sihbetê jê bibe + Profîla siḧbetê hew veşêre + Vegerîne temaya aplîkasyonê + Vegerîne temaya karber + Temaya serî/pêşî diyar bike + Moda reḧnik + na + vekirî + girtî` + Tercihên siḧbetê + Mesajên ko winda dibin li vê siḧbetê nayên qebûlkirin. + Jêbirina ko nikare were betalkirin/vegerandin di vê siḧbetê de nayê qebûlkirin. + Siḧbetên bi endam + Ti siḧbetên bi endam nînin + Siḧbetê jê bibe + Bi admînan re siḧbetê bike + Siḧbetê ji nû ve veke + Siḧbet tê sekinandin + Siḧbetê bide destpêkirin + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index adf66650f1..bccd49eed9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -214,7 +214,6 @@ Ištrinti nuorodą Ištrinti nuorodą\? Klaida kuriant grupės nuorodą - Kaip SimpleX veikia Duomenų bazė šifruota! kūrėjas Ištrinti grupę\? @@ -527,8 +526,7 @@ Nutraukti adreso keitimą Automatiškai priimti kontaktų užklausas Jį įrašius visi duomenys bus pašalinti. - Kiekvienam kontaktui ir grupės nariui bus naudojamas atskiras TCP prisijungimas (ir SOCKS prisijungimo duomenys). -\nTurėkite omenyje: jei turite daug prisijungimų, akumuliatoriaus ir interneto duomenų sąnaudos gali būti žymiai didesnės ir, kartais, prisijungimai gali patirti nesėkmę. + Kiekvienam kontaktui ir grupės nariui bus naudojamas atskiras TCP prisijungimas (ir SOCKS prisijungimo duomenys). \nTurėkite omenyje: jei turite daug prisijungimų, akumuliatoriaus ir interneto duomenų sąnaudos gali būti žymiai didesnės ir, kartais, prisijungimai gali patirti nesėkmę.]]> %1$s nori su jumis susisiekti per Yra įjungtas akumuliatoriaus naudojimo optimizavimas, išjungiantis foninę tarnybą ir periodines užklausas apie naujas žinutes. Nustatymuose galite įjungti ją iš naujo. Visada įjungta @@ -1404,7 +1402,7 @@ Jūsų profilis bus išsiųstas kontaktui iš kurio gavote šią nuorodą. Prisijungsite prie visų grupės narių. Esate prisijungę prie serverio skirto gauti žinutes iš šio kontakto. - Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto (klaida: %1$s). + Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto (klaida: %1$s). Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto. nėra detalių SimpleX fono tarnybą - ji naudoja kelis procentus akumuliatoriaus per dieną.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml new file mode 100644 index 0000000000..c5473aeea4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -0,0 +1,2500 @@ + + + 1 minūte + 1 mēnesis + tūkst. + Vai izveidot savienojumu, izmantojot vienreizēju saiti? + Pievienoties grupai? + Izmantot pašreizējo profilu + Izmantot jaunu inkognito profilu + Izmantot inkognito profilu + Jūsu profils tiks nosūtīts kontaktpersonai, no kuras saņēmāt šo saiti. + Jūs pievienosities visiem grupas dalībniekiem. + Pievienoties + Atvērt tērzēšanu + Atvērt jaunu tērzēšanu + Atvērt grupu + Atvērt jaunu grupu + Nederīga saite + Lūdzu, pārbaudiet, vai SimpleX saite ir pareiza. + Tiek atvērta datubāze… + Notiek datubāzes migrācija.\nTas var aizņemt dažas minūtes. + Nederīgs faila ceļš + Jūs kopīgojāt nederīgu faila ceļu. Ziņojiet par problēmu lietotnes izstrādātājiem. + Skats pārstāja darboties + savienots + kļūda + notiek savienošana + Jūs esat izveidojis savienojumu ar serveri, kas tiek izmantots ziņojumu saņemšanai no šīs kontaktpersonas. + Mēģina izveidot savienojumu ar serveri, kas tiek izmantots ziņojumu saņemšanai no šīs kontaktpersonas. + izdzēsts + atzīmēts kā izdzēsts + %d ziņojumi atzīmēti kā izdzēsti + moderē %s + %1$d ziņojumus moderē %2$s + To redzat tikai Jūs un moderatori + 1 diena + 1 nedēļa + 1 gads + 30 sekundes + 4 jaunas saskarnes valodas + 5 minūtes + 6 jaunas saskarnes valodas + a + b + Pārtraukt + Atcelt adreses maiņu + Vai atcelt adreses maiņu? + bloķēts + bloķējis administrators + %d ziņojumi bloķēti + Administrators bloķējis %d ziņojumus + failu sūtīšana vēl netiek atbalstīta + failu saņemšana vēl netiek atbalstīta + Jūs + pārsūtīts + saglabāts + saglabāts no %s + nederīga tērzēšana + nederīgi dati + kļūda, rādot ziņojumu + kļūda, rādot saturu + Atšifrēšanas kļūda + Šifrēšanas atkārtotas saskaņošanas kļūda + Šī tērzēšana ir aizsargāta ar pilnīgu šifrēšanu. + Šo tērzēšanu aizsargā kvantu izturīga pilnīga šifrēšana. + Privātās piezīmes + pieprasīts savienojums + pieņemts ielūgums + savienojas… + Jūs kopīgojāt vienreizēju saiti + Pilnā saite + Saites atvēršana pārlūkprogrammā var samazināt savienojuma privātumu un drošību. Neuzticamas SimpleX saites būs sarkanas. + uzaicināts pievienoties + Savienoties, izmantojot kontaktpersonas saiti + Savienoties, izmantojot saiti inkognito režīmā + Ziņot par vienuma redzamību moderatoriem + Ziņot par vienuma arhivēšanu + Ziņot par vienuma arhivēšanu, ko veica + Nezināms ziņojuma formāts + Nederīgs ziņojuma formāts + Tiešraide + Moderēts apraksts + + + + Savienojuma lokālais displeja vārds + Displeja vārds Savienojums izveidots + Apraksts Jūs kopīgojāt vienreizējo saiti inkognito režīmā + Apraksts Izmantojot grupas saiti + Apraksts Izmantojot grupas saiti inkognito režīmā + Apraksts Izmantojot kontaktadreses saiti + Apraksts Izmantojot kontaktadreses saiti inkognito režīmā + Apraksts, izmantojot vienreizēju saiti + Apraksts, izmantojot vienreizēju saiti inkognito režīmā + Simplex saites kontakts + Simplex saites uzaicinājums + Simplex saites grupa + Simplex saites kanāls + Simplex saites relejs + Simplex saites savienojums + Simplex saites režīms + Simplex saites režīma apraksts + Simplex Link Mode Pārlūkprogramma + Ziņošanas iemesls: Spams + Ziņošanas iemesls: Nelegāls saturs + Ziņošanas iemesls: Kopienas noteikumu pārkāpums + Ziņošanas iemesls: Profils + Ziņošanas iemesls: Cits + Kļūda, saglabājot Smp serverus + Kļūda, saglabājot Xftp serverus + Pārliecinieties, vai Smp serveru adreses ir pareizā formātā un unikālas + Pārliecinieties, vai Xftp serveru adreses ir pareizā formātā un unikālas + Kļūda ielādējot Smp serverus + Kļūda ielādējot Xftp serverus + Kļūda iestatot tīkla konfigurāciju + Neizdevās parsēt čata nosaukumu + Neizdevās parsēt čatu nosaukumus + Sazinieties ar izstrādātājiem + Neizdevās izveidot lietotāju + Neizdevās izveidot lietotāju (dublikāts) + Neizdevās izveidot lietotāju (dublikāts) + Neizdevās izveidot lietotāju (nepareizs) + Neizdevās izveidot lietotāju, nederīgs apraksts + Neizdevās aktivizēt lietotāju + Neizdevās saglabāt serverus + Nav konfigurēti ziņojumu serveri + Nav konfigurēti ziņojumu serveri saņemšanai + Nav konfigurēti ziņojumu serveri privātai maršrutēšanai + Nav konfigurēti mediju serveri + Nav konfigurēti mediju serveri sūtīšanai + Nav konfigurēti mediju serveri privātai maršrutēšanai + Čata profilam + Kļūdas serveru konfigurācijā + Kļūda, pieņemot operatora nosacījumus + Bloķēšanas iemesls: spams + Bloķēšanas iemesls: saturs + Savienojuma taimauts + Savienojuma kļūda + Tīkla kļūda: nezināms CA + Tīkla kļūdas apraksts + Tīkla kļūda: brokera resursdatora apraksts + Tīkla kļūda: brokera versijas apraksts + Privātās maršrutēšanas taimauts + Privātās maršrutēšanas kļūda + Privātās maršrutēšanas sesijas nav + Smp Proxy kļūda, nezināms CA + Smp Proxy kļūda, savienojuma izveide + Smp Proxy kļūda, brokera resursdators + Smp Proxy kļūda, brokera versija + Proxy galamērķa kļūda, nezināms CA + Proxy galamērķa kļūda, neizdevās izveidot savienojumu + Proxy galamērķa kļūda, brokera resursdators + Proxy Destination Error Broker Version + Lūdzu, mēģiniet vēlāk + Kļūda, sūtot ziņojumu + Kļūda, pārsūtot ziņojumus + Kļūda, veidojot ziņojumu + Kļūda, veidojot atskaiti + Kļūda, ielādējot detaļas + Kļūda, pievienojot dalībniekus + Kļūda, pievienojoties grupai + Kļūda, apstiprinot dalībnieku + Kļūda, atzīmējot dalībnieka atbalsta čatu kā lasītu + Kļūda, dzēšot dalībnieka atbalsta čatu + Nevar saņemt failu + Sūtītājs atcēla faila pārsūtīšanu + Fails Nav Apstiprināts (Nosaukums) + Fails Nav Apstiprināts (Apraksts) + %d Citas Failu Kļūdas + Kļūda, saņemot failu + %d Failu Kļūdas + Kļūda, veidojot adresi + Kontakts jau eksistē + Jūs jau esat savienots ar %s caur šo saiti + Nederīga savienojuma saite + Lūdzu, pārbaudiet pareizu saiti un, iespējams, pieprasiet jaunu + Neatbalstīta savienojuma saite + Saite prasa jaunāku lietotnes versiju, lūdzu, atjauniniet + Savienojuma kļūda Auth + Savienojuma kļūda Auth apraksts + Savienojuma kļūda Bloķēts + Savienojuma kļūda Bloķēts apraksts + Auth Open Migration uz citu ierīci + Bloķēšana nav iespējota + Jūs varat ieslēgt bloķēšanu + Ziņojuma piegādes kļūdas virsraksts + Ziņojuma piegādes brīdinājuma virsraksts + Ziņojuma piegādes kļūdas apraksts + Ziņojums ir dzēsts vai nav saņemts, kļūdas virsraksts + Ziņojums ir dzēsts vai nav saņemts, kļūdas apraksts + Ziņošanas iemesla brīdinājuma virsraksts + Ziņošanas arhīva brīdinājuma virsraksts + Pārsūtīt Brīdinājumu Pārsūtīt Ziņas Bez Failiem + Pārsūtīt Failus Ziņas Tiek Dzēstas Pēc Izvēles Apraksta + Pārsūtīt Failus Nav Akceptēts Apraksts + Pārsūtīt Failus Notiek Apraksts + Pārsūtīt Failus Neizdevās Saņemt Apraksts + Pārsūtīt Failus Trūkst Apraksts + Pārsūtīt Failus Nav Akceptēts Saņemt Failus + Pārsūtīt Failus Ziņas Tiek Dzēstas Pēc Izvēles Nosaukuma + Čata Saraksts Izlase + Čata Saraksts Kontakti + Čatu saraksta grupas + Čatu saraksta uzņēmumi + Čatu saraksta piezīmes + Čatu saraksta grupu atskaites + Paziņojumu grupas atskaite + Čatu saraksts Visi + Čatu saraksts Pievienot sarakstu + Grupu atskaites Aktīva viena + Grupu atskaites Aktīvas + Grupu atskaites Dalībnieku atskaites + Jauni atbalsta grupas ziņojumi + Jaunas atbalsta grupas čata sarunas + Jauna atbalsta grupas čata saruna + Jaunas atbalsta grupas čata sar. + Pievienoties čata sarunai + Nosūtīt pieprasījumu, lai pievienotos + Pievienoties, lai izmantotu botu + Apstiprināt kontaktpersonas pieprasījumu + Jūsu kontaktpersona + Bots + Čata baneris - pievienoties grupai + Čata baneris - tava grupa + Čata baneris - grupa + Čata baneris - biznesa savienojums + Čata baneris - tavs biznesa kontakts + Dalīties ar ziņu + Dalīties ar attēlu + Dalīties ar failu + Pārsūtīt ziņu + Pārsūtīt vairākas + Nevar koplietot ziņas brīdinājuma virsraksts + Nevar koplietot ziņas brīdinājuma teksts + Pievienot + Ikonas apraksts kontekstam + Ikonas apraksts attēla priekšskatījuma atcelšanai + Ikonas apraksts faila priekšskatījuma atcelšanai + Attēlu limita virsraksts + Video limita virsraksts + Attēlu limita apraksts + Video limita apraksts + Attēla atkodēšanas izņēmums + Attēla atkodēšanas izņēmuma apraksts + Video atkodēšanas izņēmuma apraksts + Faili un multivide ir aizliegti + Tikai īpašnieki var atļaut failus un multividi + Rakstiet un sūtiet tiešo ziņojumu, lai izveidotu savienojumu + Pārsūtīt ziņojumus (%d) + Saglabāt ziņojumus (%d) + SimpleX saites nav atļautas + Faili un multivide nav atļauti + Balss ziņas nav atļautas + Ierakstiet ziņu + Maksimālais ziņas lielums + Ir sasniegts maksimālais ziņas lielums + Ir sasniegts maksimālais ziņas lielums (ne teksts) + Ir sasniegts maksimālais ziņas lielums (pārsūtīšana) + Ziņot par iemeslu: spams + Ziņot par iemeslu: profils + Ziņot par iemeslu: kopiena + Ziņot par iemeslu: nelikumīgs saturs + Ziņošanas iemesla virsraksts - cits + Ziņojums Nosūtīts - Brīdinājuma Virsraksts + Ziņojums Nosūtīts - Brīdinājuma Ziņa - Skatīt Atbalsta Čatā + Pievienoties grupai + Pievienot ziņu + Savienoties + Vai sūtīt kontakta pieprasījumu? + + Sūtīt pieprasījumu bez ziņas + Sūtīt pieprasījumu + Nevar nosūtīt ziņu + Nevar nosūtīt ziņu – kontaktpersona nav gatava + Nevar nosūtīt ziņu – pieprasījums ir nosūtīts + Nevar nosūtīt ziņu – kontaktpersona ir izdzēsta + Nevar nosūtīt ziņu – kontaktpersona nav sinhronizēta + Nevar nosūtīt ziņu – kontaktpersona ir atspējota + Novērotājs nevar nosūtīt ziņu + Novērotājs nevar nosūtīt ziņu + Nevar nosūtīt ziņu – noraidīts + Nevar nosūtīt ziņu – grupa ir izdzēsta + Nevar nosūtīt ziņu, dalībnieks ir noņemts + Nevar nosūtīt ziņu, jūs esat izgājis + Nevar nosūtīt ziņu + Jūs esat vērotājs + Pārbaudīts ar administratoriem + Nevar nosūtīt ziņu, dalībniekam ir veca versija + Nevar Nosūtīt Komandas Brīdinājuma Teksts + Attēla Apraksts + Ikonas Apraksts Gaidot Attēlu + Ikonas Apraksts Lūgts Saņemt + Ikona Apraksts Attēls Nosūtīts Pabeigts + Gaida Attēlu + Attēls Tiks Saņemts, Kad Kontaktpersona Pabeigs Augšupielādi + Attēls Tiks Saņemts, Kad Kontaktpersona Būs Tiešsaistē + Attēls Saglabāts + Video Apraksts + Ikona Apraksts Gaida Video + Ikona Apraksts Video Pieprasīts Saņemt + Ikona Apraksts Video Nosūtīts Pabeigts + Gaida Video + Izmantot kameras pogu + No galerijas + Izvēlēties failu + Izvēlēties faila nosaukumu + Galerijas attēla poga + Galerijas video poga + Paldies, ka instalējāt SimpleX + + Lai sāktu jaunu čatu, palīdzības virsraksts + Čata palīdzības pieskāriena poga + Saglabāt neizmantoto uzaicinājuma jautājumu + Jūs varat vēlreiz apskatīt uzaicinājuma saiti + Saglabāt uzaicinājuma saiti + Izveido saiti + Mēģināt vēlreiz + Kopīgojiet šo vienreizējo saiti + Ielīmējiet saņemto saiti + Ielīmētais teksts nav saite + Pieskarieties, lai ielīmētu saiti + Ielādē profilu + Nederīgs Qr kods + Noskenētais kods nav SimpleX Link Qr kods + Dzēstās sarunas + Nav filtrētu kontaktu + Kontaktu saraksta galvenes nosaukums + Konteksta lietotāja atlasītājs - Tavs profils + Konteksta lietotāja atlasītājs - Nevar mainīt profila brīdinājuma virsrakstu + Konteksta lietotāja atlasītājs - Nevar mainīt profila brīdinājuma ziņojums + Skenēt kodu + Nepareizs kods + Skenēt kodu no kontaktu lietotnes + Drošības kods + Atzīmēt kodu kā verificētu + Notīrīt verifikāciju + Salīdzināt, lai verificētu + Ir verificēts + Nav verificēts + Tavi iestatījumi + Tava SimpleX kontaktadreses + Tavi čata profili + Izveidot čata profilu + Datubāzes parole un eksportēšana + Par SimpleX Chat + Kā lietot SimpleX Chat + Markdown palīdzība + Markdown ziņās + Čats ar dibinātāju + Sūtiet mums e-pastu + Čata slēdzene + Čata konsole + Ziņojumu serveri + Smp serveri + Konfigurēti Smp serveri + Citi Smp serveri + Smp serveru iepriekš iestatīta adrese + Pievienot iepriekš iestatītu Smp serveri + Pievienot Smp serveri + Smp serveru testa serveris + Smp serveru testēšana + Saglabāt Smp serverus + Smp serveru tests neizdevās + Dažu Smp serveru tests neizdevās + Smp serveru QR koda skenēšana + Smp serveru ievadīšana manuāli + Smp serveru jauns serveris + Smp serveru iepriekš iestatīts serveris + Smp serveru jūsu serveris + Smp serveru jūsu servera adrese + Smp serveru izmantot serveri + Smp serveru izmantot serveri jaunam savienojumam + Smp Serveru pievienošana citai ierīcei + Smp Serveru nederīga adrese + Smp Serveru adreses pārbaude + Smp Servera dzēšana + Smp Serveri katram lietotājam + Vai saglabāt Smp Serverus? + Multivides un failu serveri + Xftp Serveri + Konfigurēti Xftp Serveri + Citi Xftp Serveri + Abonēšanas Procentuālais Daudzums + Instalēt SimpleX Chat Terminālim + Atiestatīt Visus Padomus + Atzīmēt Ar Zvaigzni Github + Ziedot + Novērtēt Aplikāciju + Izmantot SimpleX Chat Serverus? + Jūsu SMP Serveri + Jūsu XFTP Serveri + Izmantojot SimpleX Chat Serverus + Kā to darīt + Kā lietot savus serverus + Saglabātie ICE serveri tiks noņemti + Jūsu ICE serveri + Konfigurēt ICE serverus + Ievadiet vienu ICE serveri katrā rindā + Kļūda, saglabājot ICE serverus + Pārliecinieties, vai ICE serveru adreses ir pareizā formātā un unikālas + Poga \"Saglabāt serverus + Tīkls un serveri + Tīkla iestatījumi + Tīkla iestatījumu nosaukums + Tīkla Socks Proxy + Tīkla Socks Proxy iestatījumi + Tīkla Socks ieslēdzējs izmantot Socks Proxy + Tīkla Proxy autentifikācija + Tīkla Proxy nejauši akreditācijas dati + Tīkla Proxy autentifikācijas režīms izolēt pēc autentifikācijas lietotāja + Tīkla Proxy autentifikācijas režīms izolēt pēc autentifikācijas entītijas + Tīkla Proxy autentifikācijas režīms bez autentifikācijas + Tīkla starpniekservera autentifikācijas režīms - lietotājvārds un parole + Tīkla starpniekservera lietotājvārds + Tīkla starpniekservera parole + Tīkla starpniekservera ports + Nepareiza tīkla starpniekservera konfigurācija + Nepareiza tīkla starpniekservera konfigurācija + Resursdatora darbība + Porta darbība + Iespējot SOCKS tīklu + SOCKS tīkla informācija + Slīpsvītra Teksts + Pārsvītrots Teksts + Krāsains Teksts + Slepenais Teksts + Zvana Statuss Zvana + Zvana Statuss Garām Palaists + Zvana Statuss Noraidīts + Zvana Statuss Pieņemts + Zvana Statuss Savienojas + Zvana Statuss Notiek + Kontakts vēlas savienoties, izmantojot zvanu + Videozvans bez šifrēšanas + Šifrēts videozvans + Audiozvans bez šifrēšanas + Šifrēts audiozvans + Pieņemt + Noraidīt + Ignorēt + Zvans jau ir beidzies + Ikonas apraksts videozvanam + Ikona Audio zvans + Zvana piekļuves atļauja darbvirsmai noraidīta + Zvana piekļuves atļauja darbvirsmai noraidīta Chrome pārlūkā + Zvana piekļuves atļauja darbvirsmai noraidīta Safari pārlūkā + Audio un video zvanu iestatījumi + Tavi zvani + Vienmēr izmantot releju + Zvans bloķēšanas ekrānā + Pieņemt zvanu bloķēšanas ekrānā + Rādīt zvanu bloķēšanas ekrānā + Zvans nav atļauts bloķēšanas ekrānā + Jūsu Ice Serveri + Webrtc Ice Serveri + Relay Serveris Aizsargā Ip + Relay Serveris Ja Nepieciešams + Atveriet SimpleX Chat, lai pieņemtu zvanu + Atļaut zvanu pieņemšanu no bloķēšanas ekrāna + Atvērt + Statuss E2E Šifrēts + Statuss Bez E2E Šifrēšanas + Statusa kontaktam ir E2E šifrēšana + Statusa kontaktam nav E2E šifrēšanas + Zvana savienojums starp lietotājiem (Peer To Peer) + Zvana savienojums caur releju + Ikonas apraksts - pārtraukt zvanu + Ikonas apraksts - izslēgt video + Ikonas apraksts - ieslēgt video + Ikonas apraksts - izslēgt audio + Ikonas apraksts - ieslēgt audio + Ikonas apraksts - izslēgt skaļruni + Ikona Apraksts Skaļrunis Ieslēgts + Ikona Apraksts Skaņa Izslēgta + Ikona Apraksts Apgriezt Kameru + Ikona Apraksts Zvans Gaida Nosūtīšanu + Ikona Apraksts Neatbildēts Zvans + Ikona Apraksts Zvans Noraidīts + Ikona Apraksts Zvans Savienojas + Ikona Apraksts Zvans Notiek + Ikona Apraksts Zvans Beidzās + Atbildēt uz Zvanu + Izlaista integritātes ziņa + Bojāts integritātes ziņas jaucējkods (hash) + Bojāts integritātes ziņas ID + Dublēta integritātes ziņa + Brīdinājums par izlaistām ziņām + Brīdinājums par izlaistām ziņām. Tas var notikt, kad + Brīdinājums par ziņu ar bojātu jaucējkodu (hash) + Brīdinājums par ziņu ar bojātu jaucējkodu (hash) + Brīdinājums par ziņu ar bojātu ID + Brīdinājums par ziņu ar bojātu ID + Brīdinājums: Atšifrēšanas kļūda, neizdevās atšifrēt %d ziņas + Brīdinājums: Atšifrēšanas kļūda, pārāk daudz izlaistu ziņu + Brīdinājums: Fragmentu šifrēšana nav sinhronizēta, vecā datubāze + Brīdinājums: Neizdevās šifrēšanas atkārtota vienošanās + Brīdinājums: Lūdzu, ziņojiet par šo kļūdu izstrādātājiem + Privātums un drošība + Tavs privātums + Aizsargāt lietotnes ekrānu + Šifrēt lokālās datnes + Automātiski pieņemt attēlus + Aizsargāt IP adresi + Lietotne lūgs apstiprināt nezināmus failu serverus + Bez Tor vai VPN IP adrese būs redzama failu serveriem + Sūtīt saišu priekšskatījumus + Notīrīt saišu pārslēgu + Privātums Rādīt pēdējās ziņas + Privātums Ziņas melnraksts + Pilna rezerves kopija + Ieslēgt bloķēšanu + Bloķēšanas režīms + Bloķēt pēc + Iesniegt piekļuves kodu + Apstiprināt piekļuves kodu + Nepareizs piekļuves kods + Jauns piekļuves kods + Autentifikācija atcelta + La Mode System + La Mode Passcode + La App Passcode + La Mode Off + Parole uzstādīta + Parole mainīta + Parole nav mainīta + Mainīt bloķēšanas režīmu + Pašiznīcināšanās + Aktivizēta pašiznīcināšanās parole + Mainīt pašiznīcināšanās režīmu + Mainīt pašiznīcināšanās paroli + Pašiznīcināšanās parole aktivizēta + Pašiznīcināšanās parole mainīta + Pašiznīcināšanās parole + Ieslēgt pašiznīcināšanos + Pašiznīcināšanās Jauns Parādāmais Vārds + Ja ievadīsiet pašiznīcināšanās kodu + Visi lietotnes dati tiks dzēsti + Lietotnes parole aizstāta ar pašiznīcināšanos + Tiek izveidots tukšs čata profils + Ja ievadīsiet paroli, dati tiks izdzēsti + Uzstādīt paroli + Šis iestatījums ir paredzēts jūsu pašreizējam profilam + Saņemts grupas notikums: 1 dalībnieks pievienojies + Saņemts grupas notikums: 2 dalībnieki pievienojušies + Saņemts grupas notikums: 3 dalībnieki pievienojušies + Saņemts grupas notikums: N dalībnieki pievienojušies + Saņemto grupas notikumu skaits + Saņemti grupas un citi notikumi + Grupas dalībnieki 2 + Grupas dalībnieki N + Saņemts grupas notikums: Atvērt čatu + Profila atjaunināšanas notikums: Kontakta vārds ir mainīts + Snd Conn Event Switch Queue Phase Completed + Snd Conn Event Switch Queue Phase Changing + Conn Event Ratchet Sync Ok + Conn Event Ratchet Sync Allowed + Conn Event Ratchet Sync Required + Conn Event Ratchet Sync Started + Conn Event Ratchet Sync Agreed + Snd Conn Event Ratchet Sync Ok + Snd Conn Event Ratchet Sync Allowed + Snd Conn Event Ratchet Sync Required + Snd Conn Event Ratchet Sinhronizācija Sākta + Snd Conn Event Ratchet Sinhronizācija Apstiprināta + Rcv Conn Event Verifikācijas Kods Atiestatīts + Conn Event Iespējots Pq + Conn Event Atspējots Pq + Grupas Dalībnieka Loma Vērotājs + Grupas Dalībnieka Loma Autors + Grupas Dalībnieka Loma Dalībnieks + Grupas Dalībnieka Loma Moderators + Grupas Dalībnieka Loma Administrators + Grupas dalībnieka loma - īpašnieks + Grupas dalībnieka statuss - noraidīts + Grupas dalībnieka statuss - izņemts + Grupas dalībnieka statuss - atstājis + Grupas dalībnieka statuss - grupa izdzēsta + Grupas dalībnieka statuss - nezināms + Grupas dalībnieka statuss - uzaicināts + Grupas dalībnieka statuss - gaida apstiprinājumu + Grupas dalībnieka statuss - gaida apstiprinājumu (saīsināts) + Grupas dalībnieka statuss - gaida pārskatīšanu + Grupas dalībnieka statuss Gaida apstiprinājumu (īsais) + Grupas dalībnieka statuss Iepazīstināts + Grupas dalībnieka statuss Iepazīstināšanas uzaicinājums + Grupas dalībnieka statuss Pieņemts + Grupas dalībnieka statuss Paziņots + Grupas dalībnieka statuss Savienots + Grupas dalībnieka statuss Pabeigts + Grupas dalībnieka statuss Izveidotājs + Grupas dalībnieka statuss Savienojas + Grupas dalībnieka statuss Nezināms (īsais) + Iepriekšējais dalībnieks Vārds + Nav kontaktu, ko pievienot + Jaunā dalībnieka loma + Sākotnējā dalībnieka loma + Ikonas apraksts - izvērst lomu + Uzaicināt uz grupu + Uzaicināt uz čatu + Izlaist uzaicināšanu + Izvēlieties kontaktus + Ikonas apraksts - kontakts atzīmēts + Notīrīt kontaktu atlases pogu + Atlasīto kontaktu skaits + Nav atlasītu kontaktu + Uzaicinājums aizliegts + Uzaicinājuma aizlieguma apraksts + Poga Pievienot dalībniekus + Poga Pievienot komandas dalībniekus + Poga Pievienot draugus + Grupas informācijas sadaļas virsraksts - dalībnieku skaits + Grupas informācija - Tu + Poga Dzēst grupu + Poga Dzēst čatu + Vai dzēst grupu? + Vai dzēst čatu? + Dzēšot grupu visiem dalībniekiem, šo darbību nevarēs atsaukt. + Dzēšot čatu visiem dalībniekiem, šo darbību nevarēs atsaukt. + Dzēšot grupu sev, šo darbību nevarēs atsaukt. + Dzēšot čatu sev, šo darbību nevarēs atsaukt. + Poga Atstāt grupu + Poga Atstāt čatu + Poga Rediģēt grupas profilu + Poga Pievienot sagaidīšanas ziņu + Poga Sagaidīšanas ziņa + Grupas saite + Izveidot grupas saiti + Poga Izveidot grupas saiti + Dzēst saites jautājumu + Dzēst saiti + Jūs varat dalīties ar grupas saiti, un jebkurš varēs pievienoties + Visi grupas dalībnieki paliks savienoti + Kļūda, veidojot saiti grupai + Kļūda, atjauninot saiti grupai + Kļūda, dzēšot saiti grupai + Kļūda, veidojot dalībnieka kontaktu + Kļūda, sūtot ziņu kontakta uzaicinājumu + Tikai grupas īpašnieki var mainīt iestatījumus + Tikai čata īpašnieki var mainīt iestatījumus + Adreses sadaļas nosaukums + Dalīties ar adresi + Jūs varat dalīties ar šo adresi ar saviem kontaktiem + Koplietojamā teksta atjaunināšanas laiks + Koplietojamā teksta ziņojuma statuss + Koplietojamā teksta faila statuss + Koplietojamā teksta nosūtīšanas laiks + Koplietojamā teksta izveides laiks + Koplietojamā teksta saņemšanas laiks + Koplietojamā teksta dzēšanas laiks + Koplietojamā teksta moderēšanas laiks + Koplietojamā teksta pazušanas laiks + Pašreizējā vienuma informācija + Sūtītājs Ts laikā + Pašreizējās versijas laika zīmogs + Vienuma informācija bez teksta + Saņēmējs: Piegādes statuss + Saglabātās ziņas nosaukums + Poga \"Noņemt dalībnieku? + Poga \"Noņemt dalībniekus? + Poga \"Noņemt dalībnieku + Poga \"Atbalsta čata dalībnieks + Poga \"Sūtīt tiešo ziņu + Gaišā tēma + Tumšā tēma + SimpleX tēma + Melnā tēma + Sistēmas valoda + Tēma + Krāsu režīms + Tumšā tēma + Tumšā režīma krāsas + Importēt tēmu + Motīva importēšanas kļūda + Motīva importēšanas kļūdas apraksts + Eksportēt motīvu + Atiestatīt krāsu + Atiestatīt atsevišķu krāsu + Motīva galamērķa lietotnes motīvs + Primārā krāsa + Primārās krāsas variants + Sekundārā krāsa + Sekundārās krāsas variants + Fona krāsa + Virsmas krāsa + Virsraksta krāsa + Primary Variant2 krāsa + Nosūtītās ziņas krāsa + Nosūtītā citāta krāsa + Saņemtās ziņas krāsa + Saņemtā citāta krāsa + Fona tapetes krāsa + Fona tapetes tonis + Motīva Attēla Noņemšana + Izskata Fonta Izmērs + Izskata Tālummaiņa + Izskata Lietotnes Rīkjoslas + Izskata Lietotnē Iebūvēto Joslu Alfa + Izskata Joslu Izplūšanas Rādiuss + Sistēmas Režīma Uznirstošais Paziņojums + Fona Attēla Priekšskatījums - Sveika, Alise! + Fona Attēla Priekšskatījums - Sveiks, Bob! + Fona Attēla Mērogs + Fona Attēla Mērogošana Atkārtoti + Fona Attēla Mērogošana Aizpildīt + Fona Attēla Mērogošana Ietilpināt + Fona Attēla Paplašinātie Iestatījumi + Čata Tēmas Atiestatīšana uz Lietotnes Tēmu + Čata Tēmas Atiestatīšana uz Lietotāja Tēmu + Iestatīt Čata Tēmu uz Noklusējuma Tēmu + Lietot Čata Tēmu Režīmam + Lietot Čata Tēmu Visiem Režīmiem + Lietot Čata Tēmu Gaišajam Režīmam + Tērzēšanas tēma tumšajam režīmam + Tavas atļautās tērzēšanas preferences + Kontakta atļautās tērzēšanas preferences + Tērzēšanas preferenču noklusējums + Tērzēšanas preferences Jā + Tērzēšanas preferences Nē + Tērzēšanas preferences Vienmēr + Tērzēšanas preferences Ieslēgts + Tērzēšanas preferences Izslēgts` + Tērzēšanas preferences + Kontaktu preferences + Grupu preferences + Iestatīt grupu preferences + Iestatīt dalībnieku uzņemšanu + Tavas preferences + Ziņas ar taimeri + Tiešās ziņas + Pilnīga dzēšana + Ziņu reakcijas + Balss ziņas + Faili un Multivide + SimpleX Saites + Nesenā Vēsture + Audio Video Zvani + \nPieejams V51 versijā + Funkcija Ieslēgta + Funkcija Ieslēgta Jums + Funkcija Ieslēgta Kontaktam + Funkcija Izslēgta + Funkcijas Saņemšana Aizliegta + Pieņemt funkciju + Pieņemt funkciju kopumu 1 dienu + Atļaut saviem kontaktiem sūtīt pazūdošus ziņojumus + Atļaut pazūdošus ziņojumus tikai tad, ja + Pazūšanas laiks ir iestatīts tikai jauniem kontaktiem + Aizliegt sūtīt pazūdošus ziņojumus + Atļaut saviem kontaktiem neatgriezeniski dzēst + Atļaut neatgriezenisku ziņojumu dzēšanu tikai tad, ja + Kontakti var atzīmēt ziņojumus dzēšanai + Atļaut saviem kontaktiem sūtīt balss ziņojumus + Atļaut balss ziņas tikai tad, ja + Aizliegt balss ziņu sūtīšanu + Atļaut jūsu kontaktiem sūtīt failus un multivides saturu + Atļaut failus un multivides saturu tikai tad, ja + Aizliegt failu un multivides satura sūtīšanu + Atļaut jūsu kontaktiem pievienot ziņu reakcijas + Atļaut ziņu reakcijas tikai tad, ja + Aizliegt ziņu reakcijas + Atļaut jūsu kontaktiem zvanīt + Atļaut zvanus tikai tad, ja + Aizliegt zvanus + Gan jūs, gan jūsu kontaktpersona var sūtīt pazūdošus ziņojumus + Tikai jūs varat sūtīt pazūdošus ziņojumus + Tikai jūsu kontaktpersona var sūtīt pazūdošus ziņojumus + Pazūdoši ziņojumi šajā čatā ir aizliegti + Gan jūs, gan jūsu kontaktpersonas var dzēst + Tikai jūs varat dzēst ziņojumus + Tikai jūsu kontaktpersona var dzēst + Ziņojumu dzēšana ir aizliegta + Gan jūs, gan jūsu kontaktpersona var sūtīt balss ziņojumus + Aizliegt ziņu dzēšanu + Atļaut sūtīt balss ziņas + Aizliegt sūtīt balss ziņas + Atļaut ziņu reakcijas + Aizliegt ziņu reakcijas grupā + Atļaut sūtīt failus + Aizliegt sūtīt failus + Atļaut sūtīt SimpleX saites + Aizliegt sūtīt SimpleX saites + Iespējot nesenās vēstures sūtīšanu + V6 3 Reports Descr + V6 3 Organizēt čatu sarakstus + V6 3 Organizēt čatu sarakstu apraksts + V6 3 Labāka privātums un drošība + V6 3 Privāti multivides failu nosaukumi + V6 3 Iestatīt ziņojumu derīguma termiņu čatos + V6 3 Labāka grupu veiktspēja + V6 3 Ātrāka ziņojumu sūtīšana + V6 3 Ātrāka grupu dzēšana + V6 4 Savienoties ātrāk + V6 4 Ātrāks savienojums Descr + V6 4 Pārskatīt dalībniekus + V6 4 Pārskatīt dalībniekus Descr + V6 4 Atbalsta čats + V6 4 Atbalsta čats Descr + V6 4 Moderatora loma + V6 4 Moderatora loma Descr + V6 4 Ziņojumu piegāde Descr + V6 4 1 Sveicināti kontakti + V6 4 1 Sveicināti kontakti Descr + V6 4 1 Uzturiet tērzēšanas tīras + V6 4 1 Uzturiet tērzēšanas tīras Apraksts + V6 4 1 Īsā adrese + V6 4 1 Īsās adreses izveide + V6 4 1 Īsās adreses atjaunināšana + V6 4 1 Īsās adreses kopīgošana + V6 4 1 Jaunu saskarnes valodu apraksts + Skatīt atjauninātos nosacījumus + Pielāgota laika vienība sekundēs + Pielāgota laika vienība minūtēs + Pielāgots laika vienības stundas + Pielāgots laika vienības dienas + Pielāgots laika vienības nedēļas + Pielāgots laika vienības mēneši + Pielāgota laika izvēles atlasīšana + Pielāgota laika izvēles pielāgošana + Piegādes apstiprinājumu nosaukums + Ieslēgt apstiprinājumus visiem + Piegādes apstiprinājumu sūtīšana tiks ieslēgta visiem profiliem + Piegādes apstiprinājumu sūtīšana tiks ieslēgta + Neieslēgt kvītis + Jūs varat ieslēgt piegādes kvītis vēlāk + Piegādes kvītis ir atspējotas + Jūs varat ieslēgt piegādes kvītis vēlāk (brīdinājums) + Kļūda, ieslēdzot piegādes kvītis + Saistīt mobilo ierīci + Saistītās mobilās ierīces + Skenēt no mobilās ierīces + Pārbaudīt savienojumu + Pārbaudīt kodu mobilajā ierīcē + Šīs ierīces nosaukums + + Savienots mobilais tālrunis + Savienots ar mobilo tālruni + Ievadiet šīs ierīces nosaukumu + Šīs ierīces nosaukums, kas koplietots ar mobilo tālruni + Kļūda + Šī ierīce + Ierīces + Jauna mobilā ierīce + Atvienot darbvirsmas jautājums + Atvienot darbvirsmu + Atvienot attālo resursdatoru + Atvienot attālos resursdatorus + + Attālais resursdators tika atvienots (virsraksts) + Attālā vadība tika atvienota (virsraksts) + + Attālā vadība atvienota ar iemeslu + Attālās vadības savienojums apturēts (apraksts) + Remote Ctrl savienojums ir pārtraukts Identity Desc + Kopēšanas kļūda + Vai atvienoties no darbvirsmas? + Vienlaicīgi var darboties tikai viena ierīce + + Gaida, kad mobilā ierīce pieslēgsies + Nepareiza darbvirsmas adrese + Nesaderīga darbvirsmas versija + Darbvirsmas lietotnes versija nav saderīga + Darbvirsmas savienojums ir pārtraukts + Sesijas kods + Savienojamies ar darbvirsmu + Gaidām darbvirsmu + Atrasta darbvirsma + Savienoties ar darbvirsmu + Savienots ar darbvirsmu + Savienota darbvirsma + Pārbaudiet kodu ar darbvirsmu + + Saistītās darbvirsmas + Datoru Ierīces + Saistīto Datoru Iestatījumi + Skenēt QR Kodu No Datora + Datora Adrese + Pārbaudīt Savienojumus + Atklāt Tīklā + Multicast Atklājams Vietējā Tīklā + Multicast Automātiski Savienoties + Ielīmēt Datora Adresi + Datora Ierīce + Nav Saderīgs + Atjaunot QR Kodu + Nav Pievienots Mobilais Tālrunis + Nejaušs Ports + Atvērt Portu Ugunsmūrī + Atvērt Portu Ugunsmūrī Apraksts + + + + + Migrate To Device Imports Neizdevās + Migrate To Device Atkārtot Importu + Migrate To Device Ievadiet Paroli + Migrate To Device Faila Dzēšana Vai Saite Ir Nederīga + Migrate To Device Kļūda Arhīva Lejupielādē + Migrate To Device Čats Ir Pārcelts + Migrate To Device Pabeigt Migrāciju + Migrate To Device Apstipriniet Tīkla Iestatījumus + Migrate To Device Apstipriniet Tīkla Iestatījumu Kājeni + Migrate To Device Lietot Onion + Savienojuma kvotas kļūda + Savienojums neizdevās servera kvotas dēļ. + Kļūda, pieņemot kontaktu pieprasījumu + Kļūda, noraidot kontaktu pieprasījumu + Sūtītājs, iespējams, ir izdzēsis savienojuma pieprasījumu. + Kļūda, dzēšot kontaktu + Kļūda, dzēšot grupu + Kļūda, dzēšot piezīmju mapi + Kļūda, dzēšot kontaktu pieprasījumu + Kļūda, dzēšot gaidošo kontaktu savienojumu + Kļūda, mainot adresi + Kļūda, pārtraucot adreses maiņu + Kļūda, sinhronizējot savienojumu + Kļūda smp testa posmā + Kļūda smp testa servera autentifikācijā + Kļūda xftp testa servera autentifikācijā + Kļūda smp testa sertifikātā + Kļūda, iestādot adresi + Kļūda + Savienot + Atslēgt + Izveidot rindu + Droša rinda + Dzēst rindu + Izveidot failu + Augšupielādēt failu + Lejupielādēt failu + Salīdzināt failu + Dzēst failu + Kļūda, dzēšot lietotāju + Kļūda, atjauninot lietotāja privātumu + Iespējami lēni procesi + Iespējami lēni procesi + Kļūda, atjauninot čata tagus + Kļūda, izveidojot čata tagus + Kļūda, ielādējot čata tagus + Kļūda, sagatavojot kontaktu + Kļūda, sagatavojot grupu + Kļūda, mainot lietotāju + Tūlītējas paziņojumi + Pakalpojumu paziņojumi + Pakalpojumu paziņojumi atslēgti + + + + Izslēdzot pakalpojumu un periodiskos paziņojumus + Periodiskie paziņojumi + Periodiskie paziņojumi atslēgti + Periodiski paziņojumi + Izslēgt akumulatora optimizāciju + Izslēgt sistēmas ierobežojumu + Atslēgt paziņojumus + Sistēmas ierobežots fons + + Sistēmas ierobežots fons zvanā + Sistēmas ierobežots fons zvanā + + Ievadiet paroli + Ievadiet paroli + Datu bāzes inicializācijas kļūda + Neizdevās inicializēt datu bāzi. + + Simplex pakalpojumu paziņojums + Simplex pakalpojumu paziņojuma teksts + Zvana pakalpojumu paziņojums audio zvanam + Zvana pakalpojumu paziņojums video zvanam + Zvana pakalpojumu paziņojums par zvana beigām + Paslēpt paziņojumu + Paziņojumu kanāla ziņas + Paziņojumu kanāla zvanus + Iestatījumi par paziņojumu režīmu + Iestatījumi par paziņojumu priekšskatījuma režīmu + Iestatījumi par paziņojumu priekšskatījumu + Paziņojumu režīms izslēgts + Paziņojumu režīms periodisks + Paziņojumu režīms pakalpojums + Paziņojumu režīms izslēgts + Paziņojumu režīms periodisks + Paziņojumu režīms pakalpojums + Paziņojumu priekšskatījuma režīms + Paziņojuma priekšskatījuma režīms kontaktam + Paziņojuma priekšskatījuma režīms slēpts + Paziņojuma priekšskatījuma režīms + Paziņojuma priekšskatījuma režīms kontaktam + Paziņojuma attēlošanas režīms slēpts + Paziņojuma priekšskatījums kādam + Jauna ziņa + Jauns kontaktu pieprasījums + Kontakts savienots + Kļūda, rādot darbvirsmas paziņojumu + SimpleX Bloķēšana + Lai aizsargātu jūsu informāciju, ieslēdziet SimpleX bloķēšanu; jums tiks lūgts pabeigt autentifikāciju, pirms šī funkcija tiks aktivizēta. + Ieslēgt + Bloķēšanas režīms + Izmantot ierīces bloķēšanu + Izmantot lietotnes piekļuves kodu + Autentifikācija neizdevās + Nevarēja pārbaudīt + Nav lietotnes paroli + Ievadiet lietotnes piekļuves kodu + Pašreizējais lietotnes piekļuves kods + Mainīt lietotnes piekļuves kodu + Autentificēt + Uzreiz + sekundes + minūtes + Lūdzu, atcerieties saglabāt paroli + SimpleX bloķēšana ieslēgta + Jums būs jāveic autentifikācija, kad sākat vai atsākat + Atbloķēt + Pieslēgties, izmantojot akreditācijas datus + Ieslēgt SimpleX Bloķēšanu + Izslēgt SimpleX Bloķēšanu + Apstiprināt akreditācijas datus + Autentifikācija nav pieejama + Ierīces autentifikācija nav iespējota; to varat ieslēgt iestatījumos, kad tā būs iespējota. + Ierīces autentifikācija ir atspējota; izslēdzam. + Apturēt sarunu + Atvērt sarunu konsoli + Atvērt sarunu profilus + Ziņojumu arhīvs %dth + Ziņojumu arhīvs visi + Ziņojumu arhīvs + Ziņojumu arhīvs apraksts visi + Ziņojumu arhīvs man + Ziņojumu arhīvs visiem moderātoriem + Ci cita kļūda + Sūtīšanas autentifikācijas kļūda + Sūtīšanas kvotas kļūda + Sūtīšanas derīguma termiņš beidzies + Sūtīšanas pārsūtīšanas kļūda + Sūtīšanas starpniekservera kļūda + Sūtīšanas starpniekservera pārsūtīšanas kļūda + Servera hosta kļūda + Servera versijas kļūda + Faila autentifikācijas kļūda + Faila bloķēšanas kļūda + Faila nav + Faila pārsūtīšanas kļūda + Atbildēt + Kopīgot + Kopēt + Saglabāt + Rediģēt + Informācijas izvēlne + Meklēt + Arhivēt + Arhivēt ziņojumu + Arhivēt ziņojumus + Dzēst ziņojumu + Nosūtīta ziņa + Saņemta ziņa + Rediģēšanas vēsture + Nav vēstures + Atbilde uz + Saglabāts + Pārsūtīts + Saglabāts no + Pārsūtīts no + Saņēmēji nevar redzēt, no kura ir ziņa. + Piegāde + Nav piegādes informācijas + Dzēst + Atklāt + Paslēpt + Atļaut + Moderēt + Ziņot + Izvēlēties + Paplašināt + Dzēst ziņu? + Dzēst ziņas? + Šo darbību nevar atcelt. + Šo darbību nevar atcelt. + Ziņas dzēšanas atzīme dzēsta + Ziņu dzēšanas atzīme dzēsta + Dzēst? + Dzēst dalībniekus? + Moderētā ziņa tiks dzēsta + Moderētās ziņas tiks dzēstas + Moderētā ziņa tiks atzīmēta + Moderētās ziņas tiks atzīmētas + Tikai man + Visiem + Apturēt pārsūtīšanu + Apturēt faila sūtīšanu + Vai vēlaties apturēt šī faila sūtīšanu? + Apturēt faila saņemšanu + Vai vēlaties apturēt šī faila saņemšanu? + Apturēt + Atcelt failu + Atcelt failu + Vai vēlaties atcelt piekļuvi šim failam? + Atcelt + Pārsūtīt + Lejupielādēt failu + Saraksta izvēlne + Ziņa pārsūtīta + Šī ziņa tika pārsūtīta no citas sarunas. + Dalībnieks neaktīvs + Dalībnieks ir neaktīvs + Rediģēts + Ziņa nosūtīta + Ziņa nosūtīta bez atļaujas + Ziņas nosūtīšana neizdevās + Saņemtā ziņa nav izlasīta + Laipni lūdzam + Laipni lūdzam + Šis teksts ir pieejams iestatījumos + Jūsu sarunas + Rīkjoslas iestatījumi + Kontakta savienojums gaida apstiprinājumu + Dalībnieka kontakts nosūtīt tiešo ziņu + Grupas priekšskatījums ir atvērts pievienošanai + Grupas priekšskatījums, jūs esat aicināts + Grupas priekšskatījums, pievienojieties kā + Grupas priekšskatījums noraidīts + Grupas savienojums gaida apstiprinājumu + Noklikšķiniet, lai uzsāktu jaunu sarunu + Sarunājieties ar izstrādātājiem + Jums nav nevienas sarunas + Ielādēju sarunas… + Nav sarunu, kas atbilst filtram + Sarunu sarakstā nav + Nav neizlasītu sarunu + Nav sarunu + Nav atrastas sarunas + Kontakts, noklikšķiniet, lai savienotos + Atvērts savienojumam + Atvērts, lai izmantotu robotu + Atvērts, lai pieņemtu + Kontaktam jāpieņem + Savienoties ar kontaktu %s? + Meklējiet vai ielīmējiet simplex saiti + Adreses izveides instrukcija + Nav izvēlēta saruna + Izvēlēto sarunu vienības nav izvēlētas + Izvēlēto sarunu vienības izvēlētas %d + Pārsūtīt ziņas + Nav ko pārsūtīt + Video tiks saņemts, kad kontakts pabeigs augšupielādi. + Video tiks saņemts, kad kontakts būs tiešsaistē. + Fails + Liels fails + Kontakts nosūtīja lielu failu + Maksimālais atbalstītais faila izmērs + Gaidām failu + Fails tiks saņemts, kad kontakts pabeigs augšupielādi. + Fails tiks saņemts, kad kontakts būs tiešsaistē. + Fails saglabāts + Fails nav atrasts + Kļūda, saglabājot failu + Attālinātā faila ielāde + Attālinātā faila ielāde. + Faila kļūda + Pagaidu faila kļūda + Atvērt ar lietotni + Balss ziņa + Balss ziņa ar ilgumu + Nosūtīt + Paziņojumi + Atspējot automātisko dzēšanu? + Mainīt automātisko dzēšanu? + Atspējot automātisko dzēšanu + Mainīt automātisko sarunu dzēšanu + Atspējot automātisko dzēšanu + Sarunu laika ierobežojuma opciju apakšdaļa + Skatīt savienojumu + Skatīt atvērt + Skatīt + Skatīt zvanu + Skatīt meklēšanu + Skatīt video + Dzēst kontaktu? + Visas ziņas tiks dzēstas. To nevar atcelt. + Šo darbību nevar atcelt. + Saglabāt sarunu + Tikai dzēst sarunu + Apstiprināt kontakta dzēšanu? + Dzēst un paziņot kontaktam + Dzēst bez paziņojuma + Dzēst kontaktu + Saruna dzēsta + Jūs joprojām varat sūtīt ziņas kontaktam + Kontakts dzēsts + Jūs joprojām varat skatīt sarunu ar kontaktu + Ievadiet kontakta vārdu + Ievadiet čata nosaukumu + Serveris ir savienots + Serveris nav savienots + Servera kļūda + Serveris gaida + Vai vēlaties mainīt saņemšanas adresi? + Mainīt saņemšanas adresi + Pārtraukt saņemšanas adreses maiņu + Vai vēlaties piespiedu sinhronizāciju? + Piespiedu sinhronizācija + Apstiprināt piespiedu sinhronizāciju + Vai vēlaties sinhronizēt savienojumu? + Sinhronizēt savienojumu + Apstiprināt savienojuma sinhronizāciju + Šifrēšanas pārrunāšana notiek + Skatīt drošības kodu + Verificēt drošības kodu + Sūtīt ziņu + Ierakstīt balss ziņu + Vai vēlaties atļaut balss ziņas? + Jums jāatļauj balss ziņas, lai tās sūtītu. + Balss ziņas šajā čatā ir aizliegtas. + Lūdziet kontaktam iespējot balss ziņas + Tikai grupas īpašnieki var iespējot balss ziņas + Sūtīt tiešo ziņu + Izbeidzoša ziņa + Sūtīt izbeidzošu ziņu + Pielāgots laiks + Sūtīt + Tiešā ziņa + Sūtīt tiešo ziņu + Sūtīt + Atcelt tiešo ziņu + Atpakaļ + Atcelt + Apstiprināt + Atjaunot + Labi + Nav detaļu + Pievienot kontaktu + Kopēts + Pievienot kontaktu vai izveidot grupu + Kopīgot vienreizēju saiti + Savienot, izmantojot saiti vai QR + Nolasīt QR kodu + Izveidot grupu + Lai kopīgotu ar savu kontaktu + Savienot, izmantojot saiti vai QR no starpliktuves vai klātienē + Tikai saglabāts dalībnieku ierīcēs + Iespējot kameras piekļuvi + Noklikšķiniet, lai nolasītu + Kamera nav pieejama + Atļauja noraidīta + Augstāk minētais, tad prievārds turpinājums + + + Lai savienotu, izmantojot saiti + Ja esat saņēmis simplex ielūguma saiti, varat to atvērt pārlūkā + + + Pieņemt savienojuma pieprasījumu? + Ja izvēlēsieties noraidīt, sūtītājs netiks informēts + Pieņemt kontaktu + Pieņemt kontaktu inkognito + Noraidīt kontaktu + Pieņemt kontaktu pieprasījumu + Noraidīt kontaktu pieprasījumu + Sūtītājs netiks informēts + Dalībnieks ir izdzēsts, nevar pieņemt pieprasījumu + Notīrīt sarunu? + Notīrīt piezīmju mapi? + Notīrīt sarunu + Notīrīt piezīmju mapi + Notīrīt + Notīrīt sarunu + Notīrīt sarunas darbība + Dzēst kontaktu darbība + Dzēst grupu darbība + Atzīmēt kā izlasītu + Atzīmēt kā neizlasītu + Iestatīt kontakta vārdu + Atskaņot sarunu + Atskaņot visas sarunas + Atjaunot sarunu + Atzīmēt kā iecienītu + Noņemt no iecienītajiem + Neizlasītie minējumi + Izveidot sarakstu + Pievienot sarakstam + Mainīt sarakstu + Saglabāt sarakstu + Saraksta nosaukums + Saraksts ar šo nosaukumu jau pastāv + Dzēst sarakstu + Dzēst sarakstu? + Šo darbību nevar atcelt. + Rediģēt sarakstu + Mainīt secību + Jūs esat aicinājis kontaktu + Jūs esat pieņēmis savienojumu + Dzēst gaidošo? + Kontakts, ar kuru jūs dalījāties ar saiti, nevarēs izveidot savienojumu + Savienojums, ko jūs esat pieņēmis, tiks atcelts + Kontakta savienojums gaida + Savienojums gaida, viņiem jābūt tiešsaistē, varat dzēst un mēģināt vēlreiz + Kontakts vēlas izveidot savienojumu ar jums + Profila attēla vietturis + Profila attēls + Aizvērt + Saites priekšskatījums + Atcelt saites priekšskatījumu + Iestatījumi + QR kods + Adrese + Palīdzība + Simplex komanda + SimpleX logo + E-pasts + Vairāk + Rādīt QR kodu + Nederīgs QR kods + Šis QR kods nav saite + Nederīga kontaktu saite + Šī saite nav derīga savienojuma saite + Savienojuma pieprasījums nosūtīts + Jūs tiksiet savienots, kad grupas saimnieka ierīce būs tiešsaistē + Jūs tiksiet savienots, kad jūsu savienojuma pieprasījums tiks pieņemts + Jūs tiksiet savienots, kad jūsu kontaktu ierīce būs tiešsaistē + + Jūsu čata profils tiks nosūtīts jūsu kontaktam + + Kopīgot ielūguma saiti + Ielīmējiet saiti, ko saņēmāt, lai savienotos ar savu kontaktu + Uzzināt vairāk + Uzzināt vairāk par adresi + Savienojiet, tiks kopīgots jauns nejaušs profils + Savienojiet, jūsu profils tiks kopīgots + Skenējiet QR, lai savienotos ar kontaktu + Ja jūs nevarat tikties klātienē + Kopīgot adresi publiski + Kopīgot simplex adresi sociālajos tīklos + Jūs varat kopīgot savu adresi + Jūs nezaudēsiet savus kontaktus, ja izdzēsīsiet adresi + Kopīgot vienreizēju saiti ar draugu + + Jūs varat iestatīt savienojuma nosaukumu, lai atcerētos + Savienojuma drošība + Simplex adrese un vienreizējās saites ir drošas kopīgošanai + Lai pasargātu no jūsu saites aizvietošanas, salīdziniet kodus + Jūs varat pieņemt vai noraidīt savienojumu + + Adrese vai vienreizēja saite + Savienoties caur saiti + Savienoties + Ielīmēt + Šī virkne nav savienojuma saite + + Jauna saruna + Jauns + Pievienot kontaktu cilni + Skatīt ielīmēto saiti + Ielīmēt saiti + Vienreizēja saite + Vienreizēja saite īsi + Simplex adrese + Vai arī parādiet šo QR kodu + Pilna saite + Īsa saite + Jauna saruna dalīties profilā + Izvēlēties sarunas profilu + Profila maiņas kļūda + Profila maiņas kļūda + Vai arī skenējiet QR kodu + Tīkls atspējot SOCKS + Tīkls atspējot SOCKS + Tīkls izmantot sīpolu viesus + Tīkls izmantot sīpolu viesus priekšroka + Tīkls izmantot sīpolu viesus nē + Tīkls izmantot sīpolu viesus nepieciešams + Dodiet priekšroku sīpolu viesiem tīklā. + Sīpolu viesi netiks izmantoti tīklā. + Sīpolu viesi ir obligāti tīklā. + Tīkla sesijas režīms transporta izolācija + Tīkla sesijas režīms lietotājs + Tīkla sesijas režīms sesija + Tīkla sesijas režīms serveris + Tīkla sesijas režīms entitāte + + Tīkla sesijas režīms sesija. + Tīkla sesijas režīms serveris. + + Atjaunināt tīkla sesijas režīmu? + + + Tīkla smp proxy režīms privātā maršrutēšana + Tīkla smp proxy režīms vienmēr + Tīkla smp proxy režīms nezināms + Tīkla smp proxy režīms neaizsargāts + Tīkla smp proxy režīms nekad + Tīkla smp proxy režīms vienmēr + Tīkla smp proxy režīms nezināms + Tīkla smp proxy režīms neaizsargāts + Tīkla smp proxy režīms nekad + Atjaunināt tīkla smp proxy režīmu? + Tīkla smp proxy rezerves atļaut samazināšanu + Tīkla smp proxy rezerves atļaut + Tīkla smp proxy rezerves atļaut aizsargātu + Tīkla smp proxy rezerves aizliegt + Tīkla smp proxy rezerves atļaut + Tīkla smp proxy rezerves atļaut aizsargātu + Tīkla smp proxy rezerves aizliegt + Atjaunināt tīkla smp proxy rezerves? + Privātā maršrutēšana rādīt + Privātā maršrutēšana skaidrojums + Tīkla smp tīmekļa ports + Tīkla smp tīmekļa porta slēdzis + Tīkla smp tīmekļa porta kājenes + Tīkla smp tīmekļa porta iepriekš iestatītā kājenes + Tīkla smp tīmekļa ports viss + Tīkla smp tīmekļa porta iepriekš iestatījums + Tīkla smp tīmekļa ports izslēgts + Izskata iestatījumi + Pielāgot tēmu + Tēmas krāsas + Lietotnes versija + Lietotnes versijas nosaukums + Lietotnes versijas kods + Pamatversija + Pamat simplexmq versija + Pārbaudīt atjauninājumus + Lietotnes atjauninājumu pārbaude atspējota + Lietotnes atjauninājumu pārbaude stabila + Lietotnes atjauninājumu pārbaude beta + Ir pieejams atjauninājums + Lejupielādēt + Izlaist + Lejupielāde uzsākta + Lejupielāde pabeigta + Atvērt + Instalēt + Veiksmīgi instalēts + Atjauninājums veiksmīgi instalēts. + Lejupielāde atcelta + Atgādināt vēlāk + Paziņojums par atjauninājumu + Saņemiet paziņojumus par pieejamiem atjauninājumiem. + Atspējot paziņojumus par atjauninājumiem + Rādīt izstrādātāja opcijas + Paslēpt izstrādātāja opcijas + Rādīt izstrādātāja opcijas + Kļūdu žurnāli + Izstrādātāja opcijas + Izstrādātāja opcijas + Novecojušas opcijas + Rādīt iekšējās kļūdas + Rādīt lēnus API izsaukumus + Izslēgt? + Izslēgšana + Kļūda saglabājot iestatījumus + Izveidot adresi + Dzēst adresi? + Jūsu kontakti paliks savienoti. + Visi jūsu kontakti paliks savienoti. + Visi jūsu kontakti paliks savienoti, atjauninājums nosūtīts. + Kopīgot saiti + Pievienot adresi savai profilam + Izveidot adresi un ļaut cilvēkiem savienoties + Izveidot simplex adresi + Kopīgot ar kontaktiem + Kopīgot adresi ar kontaktiem? + Profila atjauninājums tiks nosūtīts kontaktiem. + Pārtraukt adreses kopīgošanu + Pārtraukt kopīgošanu + Automātiski pieņemt kontaktus + Nosūtīts jūsu kontaktam pēc savienojuma izveides + Sveiciena ziņa + Ievadiet sveiciena ziņu (pēc izvēles) + Saglabāt iestatījumus? + Saglabāt automātiskā pieņemšanas iestatījumus + Dzēst adresi + Aicināt draugus + E-pasta aicinājuma temats + E-pasta aicinājuma saturs + Sociālajiem tīkliem + Vai arī, lai dalītos privāti + Simplex adrese vai vienreizējs saite + Izveidot vienreizēju saiti + Adreses iestatījumi + Uzņēmuma adrese + Pievienojiet savus komandas locekļus sarunām + Pievienot īsu saiti + Dalīties ar profilu, izmantojot saiti + Dalīties ar profilu, izmantojot saiti (teksts) + Dalīties + Uzlabot grupas saiti + Dalīties ar grupas profilu, izmantojot saiti + Dalīties ar grupas profilu, izmantojot saiti (teksts) + Dalīties ar veco adresi + Dalīties ar veco saiti + Turpināt uz nākamo soli + Nekādā gadījumā neveidot adresi + Jūs varat to izveidot vēlāk + Jūs varat padarīt adresi redzamu caur iestatījumiem + Aicināt draugus (īsi) + Rādāmais vārds + Pilns vārds + Īsa apraksts + Biogrāfija ir pārāk liela + Jūsu pašreizējais profils + Jūsu profils tiek glabāts ierīcē un tiek dalīts tikai ar kontaktiem, Simplex to neredz + Rediģēt attēlu + Dzēst attēlu + Saglabāt uzņemšanu? + Vai vēlaties saglabāt preferences? + Saglabāt un paziņot kontaktam + Saglabāt un paziņot kontaktiem + Saglabāt un paziņot grupas dalībniekiem + Iziet bez saglabāšanas + Paslēpt profilu + Parole, lai parādītu + Saglabāt profila paroli + Paslēptā profila parole + Apstiprināt paroli + Lai atklātu profilu, ievadiet paroli + Kļūda, saglabājot lietotāja paroli + Jūs kontrolējat savu sarunu + Ziņojumu un lietotņu platforma, kas aizsargā jūsu privātumu un drošību + Mēs nesaglabājam kontaktus vai ziņas serveros + Izveidot profilu + Jūsu profils tiek saglabāts jūsu ierīcē + Profils tiek koplietots tikai ar jūsu kontaktiem + Parādāmā vārda laukā nedrīkst būt atstarpes + Parādāmais vārds + Īss + Izveidot profilu + Izveidot citu profilu + Izveidot adresi + Nederīgs vārds + Labot vārdu uz + Par SimpleX + Kā izmantot Markdown + Jūs varat izmantot Markdown, lai formatētu ziņas + Trekns teksts + Zvana statuss beidzies + Zvana statuss kļūda + Zvana stāvoklis sākas + Zvana stāvoklis gaida atbildi + Zvana stāvoklis gaida apstiprinājumu + Zvana stāvoklis saņēmis atbildi + Zvana stāvoklis saņēmis apstiprinājumu + Zvana stāvoklis savienojas + Zvana stāvoklis savienots + Zvana stāvoklis beidzies + Nevar atvērt pārlūkprogrammu + Nevar atvērt pārlūkprogrammu + Nepieciešamas atļaujas + Atļaujas audio ierakstīšanai + Atļaujas kamerai + Atļaujas kamerai un audio ierakstīšanai + Piešķirt atļaujas + Piešķirt atļaujas iestatījumos + Atrast iestatījumos un piešķirt atļaujas + Atvērt iestatījumus + Audio ierīce - austiņas + Audio ierīce - skaļrunis + Audio ierīce - vadu austiņas + Audio ierīce - Bluetooth + Kļūda, inicializējot tīmekļa skatu + WebView nav atbalstīts šajā ierīces arhitektūrā. + Nākamā paaudze privātajai ziņošanai + Privātums pārdefinēts + Pirmā platforma bez lietotāju ID + Imūna pret surogātpastu un ļaunprātīgu izmantošanu + Cilvēki var savienoties tikai caur saites, ko jūs kopīgojat + Decentralizēts + Atvērtā koda protokols un kods, ko ikviens var palaist serveros + Izveidot savu profilu + Izveidot privātu savienojumu + Migrēt no citas ierīces + Kā tas darbojas + Lai aizsargātu privātumu, SimpleX izmanto ID rindām + Tikai klientu ierīces glabā kontaktu grupas un e2e šifrētas ziņas + + + Izmantot čatu + Ievada paziņojumu režīms + Ievada paziņojumu režīma apakšvirsraksts + Ievada paziņojumu režīms izslēgts + Ievada paziņojumu režīms periodisks + Ievada paziņojumu režīms pakalpojums + + Ievada paziņojumu režīms izslēgts, īss apraksts + + Ievada paziņojumu režīms periodisks apraksts īsi + + Ievada paziņojumu režīms pakalpojuma apraksts īsi + Ievada paziņojumu režīms akumulators + Iestatīt datu bāzes paroli + Jūs varat to mainīt vēlāk + Izmantot nejaušu paroli + Ievada nosacījumi privātās sarunas nav pieejamas + Ievada nosacījumi, izmantojot jūs piekrītat + Ievada nosacījumi privātuma politika un lietošanas noteikumi + Ievada nosacījumi pieņemt + Ievada izvēlēties servera operatorus + Ievada tīkla operatori + Ievada tīkla operatori simplex flux vienošanās + Ievada tīkla operatori lietotne izmantos citus operatorus + Ievada tīkla operatori nevar redzēt, kas ar ko runā + Ievada tīkla operatori lietotne izmantos maršrutēšanai + Ievada tīkls par operatoriem + Ievada izvēlēties tīkla operatorus, ko izmantot + Kā tas palīdz privātumam + Ievada tīkla operatori konfigurēt caur iestatījumiem + Ievada tīkla operatori nosacījumi tiks pieņemti + Ievada tīkla operatori nosacījumi, ko jūs varat konfigurēt + Ievada tīkla operatori pārskatīt vēlāk + Ievada tīkla operatori atjaunināt + Ievada tīkla operatori turpināt + Ienākošais video zvans + Ienākošais audio zvans + Čeki + Čeku apraksts 1 + Čeku kontakti + Čeku kontakti iespējot + Čeku kontakti atspējot + Čeku kontakti pārsniegšana iespējota + Čeku kontakti pārsniegšana atspējota + Čeku kontakti iespējot saglabāt pārsniegumus + Čeku kontakti atspējot saglabāt pārsniegumus + Čeku kontakti iespējot visiem + Rēķinu kontaktu atslēgšana visiem + Rēķinu grupas + Rēķinu grupu aktivizēšana + Rēķinu grupu atslēgšana + Rēķinu grupu pārsniegšana aktivizēta + Rēķinu grupu pārsniegšana atslēgta + Rēķinu grupu aktivizēšana ar saglabātām pārsniegšanām + Rēķinu grupu atslēgšana ar saglabātām pārsniegšanām + Rēķinu grupu aktivizēšana visiem + Rēķinu grupu atslēgšana visiem + Privātuma mediju izplūšanas rādiuss + Privātuma mediju izplūšanas rādiuss izslēgts + Privātuma mediju izplūšanas rādiuss mīksts + Privātuma mediju izplūšanas rādiuss vidējs + Privātuma mediju izplūšanas rādiuss spēcīgs + Privātuma čata saraksta atvērtie saites + Privātuma čata saraksta atvērtie saites jā + Privātuma čata saraksta atvērtie saites nē + Privātuma čata saraksta atvērtie saites jautāt + Vai vēlaties atvērt tīmekļa saiti? + Privātuma čata saraksta atvērt tīmekļa saiti + Privātuma čata saraksta atvērt pilnu tīmekļa saiti + Privātuma čata saraksta atvērt tīru tīmekļa saiti + Iestatījumi jūs + Iestatījumi + Iestatījumi čata datubāze + Iestatījumi palīdzība + Iestatījumi atbalsts + Iestatījumi lietotne + Iestatījumi ierīce + Iestatījumi čati + Iestatījumi faili + Iestatījumi piegādes rēķini + Iestatījumi kontaktu pieprasījumi no grupām + Iestatījumi restartēt lietotni + Iestatījumi izslēgt + Iestatījumi izstrādātāja rīki + Iestatījumi eksperimentālās funkcijas + Iestatījumi zeķes + Iestatījumi + Tēmas + Profila attēli + Ziņu forma + Ziņu formas stūris + Ziņu formas aste + Sarunu tēmas iestatījumi + Lietotāja tēmas iestatījumi + Sarunu krāsu iestatījumi + Ziņu iestatījumi + Privāto ziņu maršrutēšanas iestatījumi + Zvanīšanas iestatījumi + Tīkla savienojuma iestatījumi + Incognito iestatījumi + Eksperimentālie iestatījumi + Izmantot no darbvirsmas + Jūsu sarunu datubāze + Palaist sarunu + Attālie hosti + Saruna notiek + Saruna ir apstājusies + Sarunu datubāze + Datubāzes parolfrāze + Eksportēt datubāzi + Importēt datubāzi + Jauns datubāzes arhīvs + Vecs datubāzes arhīvs + Atvērt datubāzes mapi + Dzēst datubāzi + Kļūda, sākot sarunu + Apstāt sarunu? + Apstāt sarunu, lai eksportētu, importētu vai dzēstu sarunu datubāzi + Apstiprināt sarunas apstāšanu + Iestatīt paroli eksportēšanai + Iestatiet paroli, lai eksportētu + Kļūda, apstādot sarunu + Kļūda, eksportējot sarunu datubāzi + Importēt datubāzi? + Jūsu pašreizējā sarunu datubāze tiks dzēsta un aizvietota ar importēto + Apstiprināt datubāzes importēšanu + Kļūda, dzēšot datubāzi + Kļūda, importējot datu bāzi + Sarunu datu bāze importēta + Restartējiet lietotni, lai izmantotu importēto sarunu datu bāzi + Notikušas nenozīmīgas kļūdas importēšanas laikā + Vai vēlaties dzēst sarunu? + Sarunu dzēšanas darbību nevar atcelt + Sarunu datu bāze dzēsta + Restartējiet lietotni, lai izveidotu jaunu sarunu profilu + Jums jāizmanto jaunākā datu bāzes versija + Faili un mediji + Dzēst failus un medijus visiem lietotājiem + Dzēst visus failus un medijus + Vai vēlaties dzēst failus un medijus? + Dzēst failus un medijus + Nav saņemtu lietotnes failu + Kopējais failu skaits un izmērs + Sarunu vienības derīguma termiņš nav + Sarunu vienības derīguma termiņš sekundēs + Sarunu vienības derīguma termiņš pēc noklusējuma + Ziņas + Ziņas + Dzēst ziņas pēc + Vai vēlaties iespējot automātisko dzēšanu? + Iespējot automātisko dzēšanu + Dzēst ziņas + Kļūda, mainot dzēšanu + Sarunu datu bāze eksportēta + Sarunu datu bāze eksportēta un saglabāta + Sarunu datu bāze eksportēta un migrēta + Sarunu datu bāze eksportēta, ne visi faili + Sarunu datu bāze eksportēta, turpināt + Kļūda, saglabājot datu bāzi + Saglabāt paroli atslēgu glabātājā + Saglabāt paroli iestatījumos + Datu bāze ir šifrēta + Kļūda, šifrējot datu bāzi + Noņemt paroli no atslēgu glabātāja + Noņemt paroli no iestatījumiem + Paziņojumi tiks slēpti + Noņemt paroli + Šifrēt datubāzi + Atjaunot datubāzi + Pašreizējā frāze + Jaunā frāze + Apstiprināt jauno frāzi + Atjaunot datubāzes frāzi + Iestatīt datubāzes frāzi + Iestatīt frāzi + Ievadiet pareizo pašreizējo frāzi + Datubāze nav šifrēta + Atslēgu glabātuve tiek droši glabāta + Iestatījumi tiek glabāti parastā tekstā + Šifrēts ar nejaušu frāzi + + Atslēgu glabātuve ļauj saņemt ntfs + Frāze tiks saglabāta iestatījumos + Jums katru reizi jāievada frāze + Šifrēt datubāzi? + Mainīt datubāzes frāzi? + Datubāze tiks šifrēta + Datubāze tiks šifrēta un frāze tiks saglabāta + Datubāze tiks šifrēta un frāze tiks saglabāta iestatījumos + Datubāzes šifrēšana tiks atjaunināta + Datubāzes šifrēšana tiks atjaunināta iestatījumos + Datubāzes frāze tiks atjaunināta + Droši glabāt frāzi + Droši glabāt frāzi bez atgūšanas + Nepareiza frāze + Kļūda, lasot frāzi + Šifrēta datubāze + Datubāzes kļūda + Atslēgu glabātuves kļūda + Frāze ir atšķirīga + Fails ar ceļu + Nepieciešama datubāzes frāze + Kļūda ar + Nav iespējams piekļūt atslēgu glabātuvei + Nezināma datubāzes kļūda ar + Nepareiza frāze + Ievadiet pareizo frāzi + Nezināma kļūda + Ievadiet atslēgvārdu + Saglabāt atslēgvārdu un atvērt sarunu + Atvērt sarunu + Datu bāzes dublējumu var atjaunot. + Atjaunot datu bāzi + Atjaunot datu bāzi + Vai tiešām vēlaties atjaunot datu bāzi? + Apstiprināt datu bāzes atjaunošanu + Datu bāzes atjaunošanas kļūda + Atslēgvārds nav atrasts + Atslēgvārdu nevar nolasīt + Atslēgvārdu nevar nolasīt, lūdzu, ievadiet to manuāli + Datu bāzes jaunināšana + Datu bāzes samazināšana + Nesaderīga datu bāzes versija + Apstiprināt datu bāzes jauninājumus + Vienas rokas saskarne + Sarunu apakšējā josla + Vienas rokas saskarnes maiņas instrukcija + Terminālis vienmēr redzams + Sarunu saraksts vienmēr redzams + Nederīga migrācijas apstiprināšana + Jaunināt un atvērt sarunu + Samazināt un atvērt sarunu + Mtr kļūda: nav lejupvērstas migrācijas + Mtr kļūda: atšķirīgs + Datu bāzes migrācijas + Datu bāzes samazināšana + Saruna ir apturēta + Jūs varat uzsākt sarunu, izmantojot iestatījumus vai restartējot lietotni + Sākt sarunu? + Saruna ir apturēta, jums jāveic datu bāzes pārsūtīšana + Grupas ielūguma vienums + Pievienoties grupai? + Jūs esat aicināts pievienoties grupai, lai sazinātos ar grupas dalībniekiem + Pievienoties grupai + Pievienoties grupai incognito + Pievienojos grupai + Jūs esat pieņēmuši grupas ielūgumu, savienojoties ar grupas dalībnieku, kurš jūs aicināja. + Atstāt grupu + Vai vēlaties atstāt grupu? + Vai vēlaties atstāt čatu? + Jūs pārtrauksiet saņemt ziņas no šīs grupas, bet sarunu vēsture tiks saglabāta. + Jūs pārtrauksiet saņemt ziņas no šī čata, bet sarunu vēsture tiks saglabāta. + Pievienot dalībniekus + Grupa ir neaktīva + Grupas ielūgums ir beidzies + Grupas ielūgums ir beidzies + Nav grupas + Nav grupas + Nav iespējams aicināt kontaktus + Nav iespējams aicināt kontaktus, jo nav pieejamu kontaktu. + Jūs nosūtījāt grupas ielūgumu + Jūs esat aicināts uz grupu + Grupas ielūgums - pieskarieties, lai pievienotos + Grupas ielūgums - pieskarieties, lai pievienotos incognito + Jūs pievienojāties šai grupai + Jūs noraidījāt grupas ielūgumu + Grupas ielūgums ir beidzies + Saņemts tiešais notikums: kontakts izdzēsts + Saņemts tiešais notikums: grupas ielūguma saite saņemta + Saņemts grupas notikums: dalībnieks pievienots + Saņemts grupas notikums: dalībnieks savienots + Saņemts grupas notikums: dalībnieks pieņēmis + Saņemts grupas notikums: lietotājs pieņēmis + Saņemts grupas notikums: dalībnieks atstājis + Saņemts grupas notikums: dalībnieka loma mainīta + Saņemts grupas notikums: dalībnieks bloķēts + Saņemts grupas notikums: dalībnieks atbloķēts + Saņemts grupas notikums: jūsu loma mainīta + Saņemts grupas notikums: dalībnieks izdzēsts + Saņemts grupas notikums: lietotājs izdzēsts + Saņemts grupas notikums: grupa izdzēsta + Saņemts grupas notikums: grupas profils atjaunināts + Saņemts grupas notikums: aicināts caur jūsu grupas saiti + Saņemts grupas notikums: dalībnieks izveidojis kontaktu + Saņemts grupas notikums: jauns dalībnieks gaida apstiprinājumu + Nosūtīts grupas notikums: dalībnieka loma mainīta + Grupas notikums mainījis lomu jums + Grupas notikums: dalībnieks bloķēts + Grupas notikums: dalībnieks atbloķēts + Grupas notikums: dalībnieks dzēsts + Grupas notikums: lietotājs pametis + Grupas notikums: grupas profils atjaunināts + Grupas notikums: dalībnieks pieņemts + Grupas notikums: lietotājs gaida pārskatīšanu + Profila atjauninājuma notikums: attēls noņemts + Profila atjauninājuma notikums: jauns attēls iestatīts + Profila atjauninājuma notikums: adrese noņemta + Profila atjauninājuma notikums: jauna adrese iestatīta + Profila atjauninājuma notikums: profils atjaunināts + Profila atjauninājuma notikums: dalībnieka vārds mainīts + Saņemšanas savienojuma notikums: maiņas rinda fāze pabeigta + Saņemšanas savienojuma notikums: maiņas rinda fāze mainās + Sūtīšanas savienojuma notikums: maiņas rinda fāze pabeigta dalībniekam + Sūtīšanas savienojuma notikums: maiņas rinda fāze mainās dalībniekam + Sūtīt kvītis + Kvīšu sūtīšana atspējota + Kvīšu sūtīšana atspējota + Kvīšu sūtīšana ir atspējota + Pievienot dalībniekus + Atbalsta čats + Konsolei + Rindas vietējais nosaukums + Rindas datu bāzes ID + Rindas debug piegāde + Rindas atjaunināts + Rindas ziņas statuss + Rindas faila statuss + Rindas nosūtīts + Rindas izveidots + Rindas saņemts + Rindas dzēsts + Rindas moderēts + Rindas pazūd + Kopīgojiet teksta datu bāzes ID + Dalībnieks tiks noņemts no grupas, šo darbību nevar atcelt + Dalībnieki tiks noņemti no grupas, šo darbību nevar atcelt + Dalībnieks tiks noņemts no sarunas, šo darbību nevar atcelt. + Dalībnieki tiks noņemti no sarunas, šo darbību nevar atcelt. + Apstiprināt dalībnieka noņemšanu + Noņemt dalībnieku + Bloķēt dalībnieku? + Bloķēt dalībnieku + Apstiprināt dalībnieka bloķēšanu + Bloķēt visiem? + Bloķēt dalībniekus visiem? + Bloķēt visiem + Bloķēt dalībnieku + Bloķēt dalībniekus + Atbloķēt dalībnieku? + Atbloķēt dalībnieku + Apstiprināt dalībnieka atbloķēšanu + Atbloķēt visiem? + Atbloķēt dalībniekus visiem? + Atbloķēt visiem + Atbloķēt dalībnieku + Atbloķēt dalībniekus + Dalībnieks ir bloķēts no administratora puses + Dalībnieks ir bloķēts + Dalībnieks ir atspējots + Dalībnieks ir neaktīvs + Dalībnieks + Loma grupā + Mainīt lomu + Mainīt + Pārslēgt + Mainīt dalībnieka lomu? + Dalībnieka loma tiks mainīta ar paziņojumu + Dalībnieka loma tiks mainīta ar paziņojumu sarunā + Dalībnieka loma tiks mainīta ar ielūgumu + Savienot caur dalībnieka adresi + Savienot caur dalībnieka adresi + Kļūda, noņemot dalībnieku + Kļūda, mainot lomu + Kļūda, bloķējot dalībnieku visiem + Rinda grupā + Rinda sarunā + Savienojuma rinda + Tiešais savienojuma līmenis + Netiešais savienojuma līmenis + Rinda + Nav rindas + Rindas serveris + Nevar zvanīt kontaktam + Nevar zvanīt kontaktam, lūdzu, gaidiet savienojumu. + Nevar zvanīt kontaktam, jo tas ir dzēsts. + Atļaut zvanus? + Jums jāatļauj zvanīt. + Zvanīšana aizliegta + Zvanīšana ir aizliegta, lūdzu, atļaujiet zvanus. + Nevar zvanīt dalībniekam + Nevar zvanīt dalībniekam, lūdzu, nosūtiet ziņu. + Nevar nosūtīt dalībniekam + Savienojums nav gatavs + Laipni lūdzam ziņa + Saglabāt laipni lūdzam? + Laipni lūdzam ziņa ir pārāk gara + Saglabāt un atjaunināt grupas profilu + Grupas laipni lūdzam priekšskatījums + Ievadiet laipni lūdzam ziņu + Pārāk liels + Savienojuma statistika serveriem + Saņemšana caur + Sūtīšana caur + Tīkla statuss + Mainīt saņemšanas adresi + Labot savienojumu + Labot savienojumu? + Apstiprināt savienojuma labošanu + Savienojuma labošana nav atbalstīta no kontakta puses + Savienojuma labošana nav atbalstīta no grupas dalībnieka puses + Pārrunāt šifrēšanu + Izveidot slepenu grupu + Grupa ir decentralizēta + Grupas redzamā nosaukuma lauks + Grupas pilnā nosaukuma lauks + Grupas īsā apraksta lauks + Grupa ir pārāk liela + Grupas galvenais profils nosūtīts + Sarunas galvenais profils nosūtīts + Izveidot grupu + Grupas profils tiek glabāts dalībnieku ierīcēs. + Saglabāt grupas profilu + Kļūda, saglabājot grupas profilu + Tīkla iepriekš iestatītie serveri + Operatora pārskata nosacījumi + Operatora nosacījumi pieņemti + Operatora nosacījumi pieņemti aktivizētiem operatoriem + Jūsu serveri + + + Operators + Operatora serveri + Operators + Operatora mājaslapa + Operatora nosacījumi pieņemti + Operatora nosacījumi tiks pieņemti + Operatora izmantošanas slēdzis + Izmantot operatora x serverus + Operatora nosacījumi neizdevās ielādēt + + + + + + + Skatīt nosacījumus + Pieņemt nosacījumus + Operatora lietošanas nosacījumi + Operatora atjaunotie nosacījumi + + Operatora izmantošana ziņām + Operatora izmantošana ziņu saņemšanai + Operatora izmantošana ziņu privātai maršrutēšanai + Operatora pievienotie serveri + Operatora izmantošana failiem + Operatora izmantošana nosūtīšanai + Xftp serveri uz lietotāju + Operatora pievienotie xftp serveri + Operatora atvērtie nosacījumi + Operatora atvērtās izmaiņas + Kļūda servera atjaunināšanā + Kļūda servera protokola maiņā + Kļūda servera operatora maiņā + Operatora serveris + Serveris pievienots operatora nosaukumam + Kļūda servera pievienošanā + Tīkla opcija TCP savienojums + Tīkla opcijas atiestatītas uz noklusējumu + Tīkla opcija sekundes + Tīkla opcija TCP savienojuma laika limits + Tīkla opcija TCP savienojuma laika limits fons + Tīkla opcija protokola laika limits + Tīkla opcija protokola laika limits fons + Tīkla opcija protokola laika limits uz kb + Tīkla opcija RCV vienlaicība + Tīkla opcija ping intervāls + Tīkla opcija ping skaits + Tīkla opcija iespējot TCP keep alive + Tīkla opcijas saglabāt + Tīkla opcijas saglabāt un atkārtoti savienot + Atjaunināt tīkla iestatījumus? + Iestatījumu atjaunināšana atkārtoti savienos klientu ar visiem serveriem + Atjaunināt iestatījumus? + Pievienot lietotājus + Dzēst lietotājus? + Lietotāji dzēš visas sarunas + Lietotāji dzēš profilu priekš + Lietotāji dzēš ar savienojumiem + Lietotāji dzēš tikai datus + Paslēpt lietotāju + Atklāt lietotāju + Noklusināt lietotāju + Atcelt lietotāja noklusināšanu + Ievadiet paroli, lai parādītu + Noklikšķiniet, lai aktivizētu profilu + Padarīt profilu privātu + Jūs varat paslēpt vai izslēgt lietotāja profilu + Nerādīt atkal + Izslēgts, kad neaktīvs + Jūs joprojām saņemsiet zvanus un paziņojumus + Dzēst profilu + Dzēst sarunas profilu + Atvērt profilu + Atvērt sarunas profilu + Profila parole + Incognito + Incognito nejaušs profils + Incognito aizsargā + Incognito ļauj + Incognito kopīgo + Krāsu režīms: sistēmas + Krāsu režīms: gaišs + Krāsu režīms: tumšs + Tēmas režīms: sistēmas + Tikai jūs varat sūtīt balsi + Tikai jūsu kontakts var sūtīt balsi + Balsi aizliegts šajā sarunā + Gan jūs, gan jūsu kontakts var sūtīt failus + Tikai jūs varat sūtīt failus + Tikai jūsu kontakts var sūtīt failus + Faili aizliegti šajā sarunā + Gan jūs, gan jūsu kontakts var pievienot reakcijas + Tikai jūs varat pievienot reakcijas + Tikai jūsu kontakts var pievienot reakcijas + Reakcijas aizliegtas šajā sarunā + Gan jūs, gan jūsu kontakts var veikt zvanus + Tikai jūs varat veikt zvanus + Tikai jūsu kontakts var veikt zvanus + Zvanus aizliegts ar šo kontaktu + Atļaut sūtīt iznīkstošas ziņas + Aizliegt sūtīt iznīkstošas ziņas + Atļaut tiešās ziņas + Aizliegt tiešās ziņas + Atļaut dzēst ziņas + Atspējot neseno vēstures sūtīšanu + Iespējot dalībnieku ziņojumu sūtīšanu + Aizliegt locekļu ziņojumu sūtīšanu + Grupas locekļi var sūtīt iznīkstošas ziņas + Iznīkstošas ziņas ir aizliegtas + Grupas locekļi var sūtīt tiešās ziņas + Tiešās ziņas ir aizliegtas + Tiešās ziņas grupā ir aizliegtas + Tiešās ziņas čatā ir aizliegtas + Grupas locekļi var dzēst + Dzēšana čatā ir aizliegta + Grupas locekļi var sūtīt balss ziņas + Balss ziņas ir aizliegtas + Grupas locekļi var pievienot reakcijas + Reakcijas ir aizliegtas + Grupas locekļi var sūtīt failus + Faili grupā ir aizliegti + Grupas locekļi var sūtīt simplex saites + Simplex saites grupā ir aizliegtas + Jauniem locekļiem tiek nosūtīta nesenā vēsture + Jauniem locekļiem netiek nosūtīta nesenā vēsture + Grupas locekļi var sūtīt ziņojumus + Dalībnieku ziņojumi ir aizliegti + Dzēst pēc + sekundes + s + minūtes + mēnesis + mēneši + m + mth + stunda + stundas + h + diena + dienas + d + nedēļa + nedēļas + w + Piedāvātā funkcija + Piedāvātā funkcija ar parametru + Funkcija atcelta + Funkcijas lomas visiem dalībniekiem + Funkcijas lomas moderatoriem + Funkcijas lomas administratoriem + Funkcijas lomas īpašniekiem + Funkcija iespējota + Dalībnieku uzņemšana + Uzņemšanas posma pārskats + Pārskats par uzņemšanas posmu. + Dalībnieku kritēriji izslēgti + Dalībnieku kritēriji visi + Dalībnieku atbalsts + Nav atbalsta sarunu + Dzēst dalībnieku atbalsta sarunu + Dzēst dalībnieku atbalsta sarunu + Atbalsta saruna + Noraidīt gaidošo dalībnieku + Noraidīt gaidošo dalībnieku + Pieņemt gaidošo dalībnieku + Pieņemt gaidošo dalībnieku + Vai pieņemt gaidošo dalībnieku? + Apstiprinājums par gaidošā dalībnieka pieņemšanu kā dalībniekam + Apstiprinājums par gaidošā dalībnieka pieņemšanu kā novērotājam + Kas jauns + Jaunumi šajā versijā + Lasiet vairāk par jaunumiem + Drošības novērtējums + Drošības novērtējums. + Grupu saites + Grupu saites. + Automātiski pieņemt kontaktu pieprasījumus + Automātiski pieņemt kontaktu pieprasījumus. + Balss ziņas + Balss ziņas. + Neatgriezeniska dzēšana + Neatgriezeniska ziņu dzēšana. + Uzlabota servera konfigurācija + Uzlabota servera konfigurācija. + Uzlabota privātums un drošība + Uzlabota privātums un drošība. + Izbeigušās ziņas + Izbeigušās ziņas, kas pazūd pēc noteikta laika. + Tiešraides ziņas + Ziņas, kas tiek nosūtītas un saņemtas reālajā laikā. + Pārbaudīt savienojuma drošību + Pārbaudiet, vai jūsu savienojums ir drošs. + Franču saskarne + Izvēlieties franču valodu kā saskarnes valodu. + Vairāki čata profili + Izveidojiet un pārvaldiet vairākus čata profilus. + Melnraksts + Saglabājiet ziņu kā melnrakstu, lai to vēlāk pabeigtu. + Transporta izolācija + Izolējiet transporta slāni, lai uzlabotu drošību. + Privāti failu nosaukumi + Izmantojiet privātus failu nosaukumus, lai aizsargātu jūsu datus. + Samazināta akumulatora lietošana + Uzlabota akumulatora efektivitāte, lai pagarinātu lietošanas laiku. + Itāļu saskarne + Izvēlieties itāļu valodu kā saskarnes valodu. + Slēptie čata profili + Pārvaldiet slēptos čata profilus, kas nav redzami citiem. + Audio un video zvanīšana + Veiciet audio un video zvanus ar citiem lietotājiem. + Grupas moderēšana + Moderējiet grupas sarunas un saturu. + Grupas sveiciena ziņa + Sveiciena ziņa, kas tiek nosūtīta jaunajiem grupas dalībniekiem. + Samazināta akumulatora lietošana + Uzlabota akumulatora efektivitāte, lai pagarinātu lietošanas laiku. + Ķīniešu un spāņu saskarne + Izvēlieties ķīniešu vai spāņu valodu kā saskarnes valodu. + Lielu failu atbalsts + Atbalstiet lielu failu nosūtīšanu un saņemšanu. + Lietotnes piekļuves kods + Iestatiet piekļuves kodu, lai aizsargātu lietotni. + Poļu saskarne + Izvēlieties poļu valodu kā saskarnes valodu. + Ziņu reakcijas + Izteikiet savas reakcijas uz ziņām. + Pašiznīcinošs piekļuves kods + Piekļuves kods, kas nodrošina pašiznīcināšanos. + Pielāgotas tēmas + Izvēlieties un pielāgojiet tēmas pēc savas gaumes. + Uzlabotas ziņas + Saņemiet labākas un skaidrākas ziņas. + Japāņu-portugāļu saskarne + Jaunumi, pateicoties lietotāju ieguldījumam Weblate. + Ziņu piegādes apstiprinājumi + Saņemiet apstiprinājumus par ziņu piegādi. + Iemīļoto filtrs + Filtrējiet savas iecienītākās ziņas. + Šifrēšanas labojums + Uzlabojiet šifrēšanas drošību. + Izzust viena ziņa + Ziņa izzudīs pēc noteikta laika. + Vairāk iespēju + Atklājiet vēl vairāk funkciju. + Jauna darbvirsmas lietotne + Izmantojiet jauno un uzlaboto darbvirsmas lietotni. + Šifrēt vietējās failus + Aizsargājiet savus failus ar šifrēšanu. + Atklājiet grupas + Pievienojieties interesantām grupām. + Vienkāršāks inkognito režīms + Izmantojiet inkognito režīmu vieglāk. + Jaunas saskarnes valodas + Saistīt mobilo un darbvirsmas lietotni + Savienojiet mobilo un darbvirsmas lietotni. + Uzlabotas grupas + Izveidojiet un pārvaldiet grupas efektīvāk. + Inkognito grupas + Izveidojiet grupas, kas ir privātas un anonīmas. + Bloķēt grupas dalībniekus + Bloķējiet nevēlamus grupas dalībniekus. + Atklājiet vēl vairāk iespēju. + Privātas piezīmes + Saglabājiet savas piezīmes drošībā. + Vienkāršāka savienojuma saskarne + Izmantojiet savienojuma saskarni vieglāk. + Pievienoties grupas sarunai + Pievienojieties grupas sarunai. + Piegāde + Ziņu piegāde. + Jaunas saskarnes valodas + Kvantumam izturīga šifrēšana + Kvantumam izturīga šifrēšana. + Lietotnes datu migrācija + Lietotnes datu migrācija. + Attēls attēlā zvani + Attēls attēlā zvani. + Drošākas grupas + Drošākas grupas. + Kvantumam izturīga šifrēšana. + Pārsūtīt + Pārsūtīt. + Zvana skaņas + Zvana skaņas. + Profila attēlu forma + Profila attēlu forma. + Tīkls + Tīkls. + Jaunas saskarnes valodas + Privāta maršrutēšana + Privāta maršrutēšana. + Sarunu tēmas + Sarunu tēmas. + Droši faili + Droši faili. + Piegāde + Ziņu piegāde. + Persiešu saskarne + Jauna sarunu pieredze + Jaunas mediju iespējas + Privāta maršrutēšana. + Jūsu kontakti. + Pieejamā sarunu rīkjosla + Pieejamā sarunu rīkjosla. + Savienojieties ātrāk. + Dzēst daudz ziņu. + Sarunu saraksta multivide + Privātuma izplūšana + Palielināt fonta izmēru + Atjaunināt lietotni + Atjauniniet lietotni, lai iegūtu jaunākās funkcijas un uzlabojumus. + Savienojuma serveri + Pārbaudiet savienojuma serveru statusu. + Labāka drošība + Uzlabota drošība jūsu datiem. + Labāki zvani + Uzlabota zvanu kvalitāte. + Labāka lietotāja pieredze + Mainiet sarunu profilu. + Pielāgojamas ziņas + Ziņu datumi + Pārsūtīt vairākas ziņas + Dzēst vairākas ziņas + Tīkla decentralizācija + Decentralizējiet tīklu, lai uzlabotu drošību. + Tīkla decentralizācija, iespējot plūsmu + Tīkla decentralizācija, iespējot plūsmu iemesls + Uzlabota sarunu navigācija + Vieglāka navigācija sarunās. + Uzņēmumu sarunas + Sarunas uzņēmumiem. + Atsauces + Atsauces uz jums sarunās. + Ziņojumi + + + + Attālinātā kontrole: neaktīva + Attālinātā kontrole: slikts stāvoklis + Attālinātā kontrole: aizņemta + Attālinātā kontrole: laika ierobežojums + Attālinātā kontrole: atslēgta + Attālinātā kontrole: slikta ielūgums + Attālinātā kontrole: slikta versija + Izstrādē + Šī funkcija ir izstrādē. + Savienojiet plānu, lai savienotos ar sevi + Šis ir jūsu personīgais vienreizējais saite + %1$s]]> + Jūs jau savienojaties + Jūs jau savienojaties, izmantojot šo vienreizējo saiti + Šis ir jūsu personīgais simplex adrese + Atkārtot savienojuma pieprasījumu + Jūs jau esat pieprasījis savienojumu, izmantojot šo adresi + Pievienojieties savai grupai + %1$s]]> + Atkārtot pievienošanās pieprasījumu + Grupa jau pastāv + Čats jau pastāv + %1$s]]> + Jūs jau pievienojaties grupai + Jūs jau pievienojaties grupai, izmantojot šo saiti + %1$s]]> + %1$s]]> + Savienojiet, izmantojot saiti + Aģenta kritiska kļūda + Notikusi kritiska kļūda aģentā. + Aģenta iekšēja kļūda + Notikusi iekšēja kļūda aģentā. + Restartēt čatu + Migrēt uz ierīci + Vai nu ielīmējiet arhīva saiti + Ielīmējiet arhīva saiti + Nederīga faila saite + Čata arhīvs + Migrē uz ierīci + Migrē uz ierīci, datu bāzes inicializācija + Migrē uz ierīci, lejupielādējot detaļas + Migrē uz ierīci, lejupielādējot arhīvu + Migrē uz ierīci, lejupielādēti %d baiti + Migrē uz ierīci, lejupielāde neizdevās + Atkārtot lejupielādi + Mēģiniet vēlreiz + Migrē uz ierīci, importējot arhīvu + Migrēt no ierīces + Migrēt no ierīces uz citu ierīci + Kļūda, migrējot no ierīces, saglabājot iestatījumus + Migrēt no ierīces, eksportētā faila nav + Kļūda, migrējot no ierīces, eksportējot arhīvu + Migrēt no ierīces, datu bāzes inicializācija + Kļūda, migrējot no ierīces, augšupielādējot arhīvu + Kļūda, migrējot no ierīces, dzēšot datu bāzi + Migrēt no ierīces, apturot sarunu + Migrēt no ierīces, sarunai jābūt apturētai + Migrēt no ierīces, arhivēt un augšupielādēt + Migrēt no ierīces, apstiprināt augšupielādi + Migrēt no ierīces, visi dati tiks augšupielādēti + Migrēt no ierīces, arhivējot datu bāzi + Migrēt no ierīces, augšupielādētie biti + Migrēt no ierīces, augšupielādējot arhīvu + Migrēt no ierīces, augšupielāde neizdevās + Migrēt no ierīces, atkārtot augšupielādi + Migrēt no ierīces, mēģiniet vēlreiz + Migrēt no ierīces, veidojot arhīva saiti + Migrēt no ierīces, atcelt migrāciju + Migrēt no ierīces, pabeigt migrāciju + Migrēt no ierīces, vai dzēst arhīvu? + Migrēt no ierīces, augšupielādētais arhīvs tiks dzēsts + + Migrēt no ierīces, vai kopīgot šo faila saiti + Migrēt no ierīces, dzēst datu bāzi no ierīces + Migrēt no ierīces, sarunas uzsākšana vairākās ierīcēs nav atbalstīta + Migrēt no ierīces, uzsākt sarunu + Migrēt no ierīces, migrācija pabeigta + + + Migrēt no ierīces, pārbaudīt datu bāzes paroli + Migrēt no ierīces, pārbaudīt paroli + Migrēt no ierīces, apstipriniet, ka atceraties paroli + Migrēt no ierīces, pārbaudiet savienojumu un mēģiniet vēlreiz + + Kļūda, migrējot no ierīces, pārbaudot paroli + Tīkla veids: nav tīkla savienojuma + Tīkla veids: mobilais + Tīkla veids: Wi-Fi + Tīkla veids: Ethernet + Cita tīkla veida + Serveri + Serveru failu cilne + Trūkstošie serveri + Serveru mērķis + Visi lietotāji + Pašreizējais lietotājs + Serveru transporta sesiju virsraksts + Savienotās serveru sesijas + Savienojamās serveru sesijas + Serveru sesiju kļūdas + Serveru statistikas virsraksts + Nosūtītās ziņas no serveriem + Saņemtās ziņas no serveriem + Serveru detaļas + Serveru privāto datu atruna + Serveru abonementu virsraksts + Abonētās serveru savienojumu + Gaidošie serveru abonementu savienojumi + Kopējais serveru abonementu skaits + Savienoto serveru virsraksts + Iepriekš savienoto serveru virsraksts + Proksēto serveru virsraksts + Proksēto serveru kājenes + Pārsavienot serverus + Pārsavienot serverus + Pārsavienot serveri + Pārsavienot serveri + Kļūda pārsavienojot serverus + Kļūda pārsavienojot serveri + Serveru modalitātes kļūda + Pārsavienot visus serverus + Atjaunot serveru statistiku + Atjaunot serveru statistiku + Atjaunot serveru statistiku + Apstiprināt serveru statistikas atjaunošanu + Kļūda atjaunojot serveru statistiku + Augšupielādēti serveri + Lejupielādēti serveri + Detalizēta serveru statistika + Serveru detalizētā statistika par nosūtītajām ziņām + Serveru detalizētā statistika par kopējo nosūtīto ziņu skaitu + Serveru detalizētā statistika par saņemtajām ziņām + Serveru detalizētā statistika par kopējo saņemto ziņu skaitu + Serveru detalizētā statistika par saņemšanas kļūdām + Serveri sākot no + Smp serveris + Xftp serveris + Pārlādēt + Mēģinājumi + Nosūtīts tieši + Nosūtīts caur proxy + Proxy + Nosūtīšanas kļūdas + Beidzies + Citi + Dublikāti + Atšifrēšanas kļūdas + Citas kļūdas + Apstiprināts + Apstiprināšanas kļūdas + Savienojumi + Izveidots + Aizsargāts + Pabeigts + Izdzēsts + Izdzēšanas kļūdas + Abonēts + Abonēšanas rezultāti ignorēti + Abonēšanas kļūdas + Augšupielādētie faili + Izmērs + Augšupielādītie fragmenti + Augšupielādes kļūdas + Izdzēstie fragmenti + Lejupielādētie fragmenti + Lejupielādētie faili + Lejupielādes kļūdas + Servera adrese + Atvērt servera iestatījumus + Sasniegts maksimālais grupas pieminējumu skaits ziņā. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml index d21b8b8f83..19aa92a4a0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml @@ -353,7 +353,6 @@ സ്വാഗത സന്ദേശം നൽകുക... (ഇച്ഛാനുസൃതമായ) സംരക്ഷിക്കാതെ പുറത്ത് പോവുക ഇത് എങ്ങനെ പ്രവർത്തിക്കുന്നു - SimpleX എങ്ങനെ പ്രവർത്തിക്കുന്നു കൃത്യസമയം പ്രവർത്തനരഹിതമാക്കുക എന്നതിൽ ഇല്ലാതാക്കി diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml new file mode 100644 index 0000000000..a6385a5ce0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -0,0 +1,220 @@ + + + 1 dag + 1 minutt + 1 måned + 1 rapport + Engangslenke + 1 uke + 1 år + 30 sekunder + 5 minutter + a + b + Avbryt + Avbryt adresseendring + Avbryt adresseendring? + Om operatører + Om SimpleX + Om SimpleX-adressen + Om SimpleX Chat + Aksentfarge + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter som medlem + Aksepter som observatør + Aksepter vilkår + Aksepter tilkoblingsforespørsel? + Aksepter kontaktforespørsel + Aksepter kontaktforespørsel + Akseptert %1$s + Akseptert anrop + Akseptert vilkår + Akseptert invitasjon + Aksepterte deg + Aksepter inkognito + Aksepter medlem + Koble til servere via SOCKS proxy på port %d? Proxy må startes før du skrur på dette valget. + Bekreftelsesfeil + Aktive tilkoblinger + Legg til en adresse i profilen din slik at kontaktene dine kan dele denne med andre. Profiloppdateringen vil bli sendt til dine kontakter. + Legg til kontakt + Lagt til medie- og filservere + Meldingsservere er lagt til + Legg til venner + Legg til en liste + Legg til en melding + Legg til forhåndsvalgte servere + Legg til en profil + Adresse + Adresseendringen vil bli avbrutt. Den gamle mottakeradressen vil bli brukt. + Adresse eller engangslenke? + Adresseinnstillinger + Legg til en server + Legg til servere ved å skanne QR-koder. + Legg til teammedlemmer + Legg til en annen enhet + Legg til listen + Legg til velkomstmelding + Legg til dine teammedlemmer i samtalene. + administrator + administratorer + Administratorer kan blokkere ett medlem for alle. + Administratorer kan lage lenker for å bli med i grupper. + Avanserte nettverksinnstillinger + Avanserte innstillinger + Avanserte innstillinger + godkjenner krypteringen… + alle + Alle + All appdata er slettet. + Alle chatter og meldinger vil bli slettet - dette kan ikke angres! + Alle chatter vil bli fjernet fra listen %s, og listen vil bli slettet + Alle gruppemedlemmer vil forbli tilkoblet. + alle medlemmer + Alle meldinger vil bli slettet - dette kan ikke angres! + Alle meldinger vil bli slettet – dette kan ikke angres! Meldingene vil KUN bli slettet for deg. + Alle nye meldinger fra %s vil bli skjult! + Alle nye meldinger fra disse medlemmene vil bli skjult! + Tillat + Tillate + Tillat anrop? + Tillat samtaler kun vis kontakten din tillater dem. + Tillat forsvinnende meldinger kun vis kontakten din tillater dem. + Tillat nedgradering + Tillat filer og medier kun vis kontakten din tillater det. + Tillat irreversibel sletting av meldinger kun hvis kontakten din tillater det. (24 timer) + Tillat meldingsreaksjoner. + Tillat meldingsreaksjoner kun vis kontakten din tillater det. + Tillat direktemeldinger til medlemmer. + Tillat å slette sendte meldinger irreversibelt. (24 timer) + Tillat å rapportere meldinger til moderatorer. + Tillat å sende forsvinnende meldinger. + Tillat å sende filer og medier. + Tillat sending av SimpleX-lenker. + Tillat sending av talemeldinger. + Tillat talemeldinger? + Tillat talemeldinger kun vis kontakten din tillater det. + Tillat kontaktene dine å sende meldingsreaksjoner. + Tillat kontaktene dine å ringe deg. + Tillat kontaktene dine å irreversibelt slette sendte meldinger. (24 timer) + Tillat kontaktene dine å sende forsvinnende meldinger. + Tillat kontaktene dine å sende filer og medier. + Tillat kontaktene dine å sende talemeldinger. + Alle profiler + Alle rapporter vil bli arkivert for deg. + Alle servere + Alle dine kontakter, samtaler og filer vil bli kryptert og lastet opp i deler til konfigurerte XFTP-reléer. + Alle kontaktene dine vil forbli tilkoblet. + Alle kontaktene dine vil forbli tilkoblet. Profiloppdatering vil bli sendt til kontaktene dine. + Kobler allerede til! + alltid + Alltid + Alltid på + Bruk alltid privat ruting. + Bruk alltid relé + og %d andre hendelser + Android Keystore brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer. + Android Keystore brukes til å trygt lagre passordet ditt etter at du restarter appen eller bytter passord - det gjør at du kan motta varsler. + En tom chat-profil med navnet du har valgt vil bli laget, og appen åpnes som vanlig. + En ny tilfeldig profil vil bli delt. + En annen grunn + Svar anrop + Hvem som helst kan være vert for servere. + APP + Appen kjører alltid i bakgrunnen + App build: %s + Appen kan bare motta varsler når den er åpen, ingen bakgrunnstjeneste vil bli startet. + Backup av appdata + Migrering av appdata + Utseende + Appen krypterer nye lokale filer (unntatt videoer). + Appikon + Bruk + Bruk på + App-passord + App-passord + App-passordet byttes med det selvdestruerende passordet. + Appøkt + Apptema + App-verktøylinjer + Appoppdatering er lastet ned. + Appversjon + Appversjon: v%s + Arabisk, bulgarsk, finsk, hebraisk, thai og ukrainks - takk til brukerne og Weblate. + Arkiv + Arkiver alle rapporter? + Arkiver og last opp + Arkiver kontakter for å chatte senere. + Arkiverte kontakter + arkivert rapport + arkivert rapport av %s + Arkiver %d rapporter? + Arkiver rapport + Arkiver rapport? + Arkiver rapporter + Arkiverer database + Spør + Bedt om å motta bildet + Bedt om å motta videoen + Legg ved + forsøk + Lyd- og videosamtaler + lydanrop + Lydanrop + lydanrop (ikke E2E-kryptert) + Lyd av + Lyd på + Lyd- og videosamtaler + Lyd/videosamtaler + Lyd/videosamtaler er ikke tillatt. + Autentisering avbrutt + Autentisering mislyktes + Autentisering er ikke tilgjengelig + forfatter + Godta automatisk + Godta kontaktforespørsler automatisk + Godta bilder automatisk + \nTilgjengelig i v5.1 + Tilbake + Bakgrunn + Bakgrunnstjenesten kjører alltid - varsler vises så snart meldingene er tilgjengelige. + Batterioptimalisering er aktivert, og bakgrunnstjenesten og periodiske forespørsler om nye meldinger er slått av. Du kan aktivere det igjen i innstillingene. + Beta + Bedre anrop + Bedre grupper + Bedre gruppeytelse + Bedre meldingsdatoer. + Bedre meldinger + Bedre personvern og sikkerhet + Bedre sikkerhet ✅ + Bedre brukeropplevelse + Bio: + Bio er for stor + Svart + Blokker + blokkert + blokkert + blokkert av administrator + Blokkert av administrator + blokkert %s + Blokker for alle + Blokker gruppemedlemmer + Blokker medlem + Blokker medlem? + Blokker medlem for alle? + Blokker medlemmer for alle? + Bluetooth + fet + Bot + Både du og kontakten din kan legge til reaksjoner på meldinger. + Både du og kontakten din kan slette sendte meldinger irreversibelt. (24 timer) + Både du og kontakten din kan ringe. + Både du og kontakten din kan sende forsvinnende meldinger. + Både du og kontakten din kan sende filer og medier. + Både du og kontakten din kan sende talemeldinger. + Firmaadresse + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index b5be8b9773..cc81e5365b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -424,7 +424,6 @@ cursief Hoe het werkt gemiste oproep - Hoe SimpleX werkt Inkomende audio oproep Inkomend video gesprek Negeren @@ -811,7 +810,7 @@ Dank aan de gebruikers – draag bij via Weblate! Transport isolation SimpleX - U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. + U bent verbonden met de server die wordt gebruikt om berichten van deze verbinding te ontvangen. Je profiel wordt verzonden naar het contact van wie je deze link hebt ontvangen. Je maakt verbinding met alle groepsleden. Uitvoeren bij geopende app @@ -912,8 +911,8 @@ SimpleX groep link SimpleX links Eenmalige SimpleX uitnodiging - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen. - Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %1$s). + Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van deze verbinding te ontvangen. + Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %1$s). onbekend berichtformaat Via browser via contact adres link @@ -1111,7 +1110,7 @@ Delen met contacten Uw contacten blijven verbonden. Profiel update wordt naar uw contacten verzonden. - Adres delen met contacten\? + Adres delen met contacten? Stop met delen Stop met het delen van adres\? Nodig vrienden uit @@ -2039,7 +2038,6 @@ Verwijder maximaal 20 berichten tegelijk. Sommige bestanden zijn niet geëxporteerd Alle hints resetten - Chat-lijst wisselen: U kunt dit wijzigen in de instellingen onder uiterlijk Creëren Vervagen voor betere privacy. @@ -2380,7 +2378,6 @@ Volledige link Niet-ondersteunde verbindingslink Korte link - Serveroperators configureren Privacybeleid en gebruiksvoorwaarden. Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders. contact verwijderd @@ -2436,4 +2433,11 @@ Accepteer Lid accepteren Lid zal toetreden tot de groep, lid accepteren? + Incognitoprofiel gebruiken + Open chat + Open een nieuwe chat + Nieuwe groep openen + geen abonnement + U bent niet verbonden met de server die u gebruikt om berichten van deze verbinding te ontvangen (geen abonnement). + end-to-end-encryptie.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a7862ffcf1..9cc43851d6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -25,12 +25,12 @@ zmoderowane przez %s wysyłanie plików nie jest jeszcze obsługiwane odbieranie plików nie jest jeszcze obsługiwane - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu. - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %1$s). + Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia. + Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %1$s). nieznany format wiadomości SimpleX Ty - Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu. + Jesteś połączony z serwerem, który służył do odbierania wiadomości z tego połączenia. Twój profil zostanie wysłany do kontaktu, od którego otrzymałeś ten link. udostępniłeś jednorazowy link incognito przez link grupowy @@ -71,10 +71,10 @@ Błąd usuwania kontaktu Błąd usuwania grupy Błąd usuwania oczekującego połączenia kontaku - Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy + Odcisk palca w adresie serwera nie pasuje do certyfikatu. Bezpieczna kolejka Nadawca mógł usunąć prośbę o połączenie. - Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło + Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło. Test nie powiódł się na etapie %s. Błąd usuwania profilu użytkownika Błąd aktualizacji prywatności użytkownika @@ -141,7 +141,7 @@ Usunąć wiadomość członka\? edytowana Dla wszystkich - dołącz jako %s + Dołącz jako %s wysyłanie nie powiodło się wyślij Udostępnij plik… @@ -154,7 +154,7 @@ oznacz jako nieprzeczytane Witaj! Witaj %1$s! - jesteś zaproszony do grupy + Jesteś zaproszony do grupy Nie masz czatów Czaty Poproszony o odbiór obrazu @@ -179,7 +179,7 @@ Oczekiwanie na film Oczekiwanie na film jesteś obserwatorem - Nie możesz wysyłać wiadomości! + Jesteś obserwatorem Połączony Obecnie maksymalny obsługiwany rozmiar pliku to %1$s. Usuń kontakt @@ -426,11 +426,10 @@ Możesz używać markdown do formatowania wiadomości: Utwórz swój profil Jak to działa - Jak SimpleX działa Natychmiastowy - Można to później zmienić w ustawieniach. + Jak wpływa na baterię Nawiąż prywatne połączenie - dwuwarstwowego szyfrowania end-to-end.]]> + Tylko urządzenia klienckie przechowują profile użytkowników, kontakty, grupy i wiadomości. Okresowo Prywatne powiadomienia repozytorium GitHub.]]> @@ -814,10 +813,10 @@ %d mies %ds %d sek - Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) - Członkowie grupy mogą wysyłać bezpośrednie wiadomości. + Członkowie mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) + Członkowie mogą wysyłać bezpośrednie wiadomości. Nieodwracalne usuwanie wiadomości jest na tym czacie zabronione. - Nieodwracalne usuwanie wiadomości jest w tej grupie zabronione. + Usuwanie wiadomości nieodwracalnych jest zabronione. Tylko Ty możesz nieodwracalnie usunąć wiadomości (Twój kontakt może oznaczyć je do usunięcia). (24 godziny) Tylko Ty możesz wysyłać znikające wiadomości. Tylko Ty możesz wysyłać wiadomości głosowe. @@ -827,7 +826,7 @@ Zabroń wysyłania bezpośrednich wiadomości do członków. Zabroń wysyłania znikających wiadomości. Wiadomości głosowe są zabronione na tym czacie. - Wiadomości głosowe są zabronione w tej grupie. + Wiadomości głosowe są zabronione. Administratorzy mogą tworzyć linki do dołączania do grup. Automatyczne akceptowanie próśb o kontakt anulowano %s @@ -921,7 +920,7 @@ %d dni Usuń Usuń wiadomości po - Znikające wiadomości są zabronione w tej grupie. + Znikające wiadomości są zabronione. Błąd usuwania prośby o kontakt Nie znaleziono pliku Błąd zapisu serwerów SMP @@ -939,9 +938,9 @@ zaproponował %s: %2s Tylko właściciele grup mogą włączyć wiadomości głosowe. Tylko Twój kontakt może nieodwracalnie usunąć wiadomości (możesz oznaczyć je do usunięcia). (24 godziny) - Hasło nie zostało znalezione w Keystore, wprowadź je ręcznie. Może się tak zdarzyć, gdy przywrócisz dane aplikacji za pomocą narzędzia do kopii zapasowych. Jeśli tak nie jest, skontaktuj się z programistami. - Członkowie grupy mogą wysyłać znikające wiadomości. - Członkowie grupy mogą wysyłać wiadomości głosowe. + Hasło nie znalezione w Keystore, proszę wpisać je ręcznie. Mogło się to zdarzyć, jeśli przywróciłeś dane aplikacji za pomocą narzędzia do tworzenia kopii zapasowej. Jeśli tak nie jest, skontaktuj się z deweloperami. + Członkowie mogą wysyłać znikające wiadomości. + Członkowie mogą wysyłać wiadomości głosowe. Grupa zostanie usunięta dla wszystkich członków - nie można tego cofnąć! Jak korzystać z Twoich serwerów zeskanować kod QR w rozmowie wideo, lub Twój rozmówca może udostępnić link z zaproszeniem.]]> @@ -951,7 +950,7 @@ Zaimportować bazę danych czatu\? Tryb incognito chroni Twoją prywatność używając nowego losowego profilu dla każdego kontaktu. pośrednie (%1$s) - pozwolić SimpleX na działanie tle w następnym oknie dialogowym. W przeciwnym razie powiadomienia zostaną wyłączone.]]> + Pozwól w następnym oknie dialogowym natychmiast otrzymywać powiadomienia.]]> Zainstaluj SimpleX Chat na terminal Nieprawidłowe potwierdzenie migracji zaproszenie do grupy %1$s @@ -985,8 +984,8 @@ Tego działania nie można cofnąć - wszystkie odebrane i wysłane pliki oraz media zostaną usunięte. Obrazy o niskiej rozdzielczości pozostaną. Adres odbiorczy zostanie zmieniony na inny serwer. Zmiana adresu zostanie zakończona gdy nadawca będzie online. Ten link nie jest prawidłowym linkiem połączenia! - SimpleX - zużywa ona kilka procent baterii dziennie.]]> - Aby chronić prywatność, zamiast identyfikatorów użytkowników używanych przez wszystkie inne platformy, SimpleX ma identyfikatory dla kolejek wiadomości, oddzielne dla każdego z Twoich kontaktów. + SimpleX działa w tle zamiast korzystać z powiadomień push.]]> + Aby chronić Twoją prywatność, SimpleX używa oddzielnych identyfikatorów dla każdego z Twoich kontaktów. Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach. Użyj dla nowych połączeń O ile Twój kontakt nie usunął połączenia lub ten link był już użyty, może to być błąd - zgłoś go. @@ -1115,7 +1114,7 @@ Dowiedz się więcej Przestać udostępniać adres\? Automatycznie akceptuj - Udostępnić adres kontaktom\? + Udostępnić adres kontaktom? Wpisz wiadomość powitalną… (opcjonalne) Aktualizacja profilu zostanie wysłana do Twoich kontaktów. Zapisać ustawienia\? @@ -1142,7 +1141,7 @@ Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. Utwórz adres, aby ludzie mogli się z Tobą połączyć. Utwórz adres SimpleX - Zapisz ustawienia automatycznej akceptacji + Zapisz ustawienia adresów SimpleX Udostępnij kontaktom Możesz go utworzyć później Adres @@ -1173,10 +1172,10 @@ Wyślij znikającą wiadomość Zabroń reakcje wiadomości. Reakcje wiadomości - Członkowie grupy mogą dodawać reakcje wiadomości. + Członkowie mogą dodawać reakcje na wiadomości. godziny Reakcje wiadomości są zabronione na tym czacie. - Reakcje wiadomości są zabronione w tej grupie. + Reakcje na wiadomości są zabronione. minuty miesiące Tylko Ty możesz dodawać reakcje wiadomości. @@ -1245,9 +1244,9 @@ Pozwól na wysyłanie plików i mediów. Brak filtrowanych czatów Nieulubione - Członkowie grupy mogą wysyłać pliki i media. + Członkowie mogą wysyłać pliki i media. Zakaz wysyłania plików i mediów. - Pliki i media są zabronione w tej grupie. + Pliki i media są zabronione. Tylko właściciele grup mogą włączać pliki i media. Szukaj Wyłączono @@ -1378,7 +1377,7 @@ Błąd tworzenia kontaktu członka Wyślij wiadomość bezpośrednią aby połączyć wyślij wiadomość bezpośrednią - połącz bezpośrednio + Prośba o połączenie usunięto kontakt Utwórz grupę Utwórz profil @@ -1556,7 +1555,7 @@ członek %1$s zmienił na %2$s ustaw nowy adres kontaktu zaktualizowano profil - Były członek %1$s + Członek %1$s Zablokować członka dla wszystkich? Utworzony o Zachowano wiadomość @@ -1619,7 +1618,7 @@ Zakończ połączenie Połączenie wideo Błąd podczas otwierania przeglądarki - Do połączeń wymagana jest domyślna przeglądarka. Proszę skonfigurować domyślną przeglądarkę systemową, i podzielić się informacją z twórcami. + Do wykonywania połączeń wymagana jest domyślna przeglądarka internetowa. Skonfiguruj domyślną przeglądarkę w systemie i przekaż więcej informacji programistom. Ten czat jest chroniony przez szyfrowanie e2e odporne na ataki kwantowe. szyfrowanie end-to-end z perfect forward secrecy, zaprzeczalnością i odzyskiwaniem bezpieczeństwa po kompromitacji.]]> Otwórz ekran migrowania @@ -1712,7 +1711,7 @@ Wiadomości głosowe są niedozwolone Włączony dla właściciele - Linki SimpleX są zablokowane na tej grupie. + Linki SimpleX są zablokowane. Inne WiFi Połączenie ethernet (po kablu) @@ -1720,7 +1719,7 @@ wszyscy członkowie Zezwól na wysyłanie linków SimpleX. Sieć komórkowa - Członkowie grupy mogą wysyłać linki SimpleX. + Członkowie mogą wysyłać linki SimpleX. Brak połączenia z siecią Zabroń wysyłania linków SimpleX Linki SimpleX @@ -1929,7 +1928,7 @@ Wyświetlanie informacji dla Statystyki Sesje transportowe - Zaczynanie od %s. \nWszystkie dane są prywatne na Twoim urządzeniu. + Zaczynanie od %s.\nWszystkie dane są prywatne na Twoim urządzeniu. Połącz ponownie wszystkie serwery Połączyć ponownie serwer? Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -2008,7 +2007,6 @@ Połączenie TCP Nic nie jest zaznaczone Sprawdź czy link SimpleX jest poprawny. - Przełącz listę czatów: Archiwizuj kontakty aby porozmawiać później. Chroni Twój adres IP i połączenia. Osiągalny pasek narzędzi czatu @@ -2040,7 +2038,7 @@ Zaznacz Wiadomości zostaną usunięte dla wszystkich członków. Wiadomości zostaną oznaczone jako moderowane dla wszystkich członków. - Osiągalny pasek narzędzi czatu + Osiągalny pasek narzędzi Wyeksportowano bazę danych czatu Kontynuuj Serwery mediów i plików @@ -2094,7 +2092,7 @@ Dźwięk wyciszony Wybierz profil czatu Udostępnij profil - Twoje połączenie zostało przeniesione do %s, ale podczas przekierowania do profilu wystąpił nieoczekiwany błąd. + Twoje połączenie zostało przeniesione na %s, ale pojawił się błąd podczas zmiany profilu. Tryb systemu Przesłane archiwum bazy danych zostanie trwale usunięte z serwerów. Serwer @@ -2189,4 +2187,380 @@ Nowy członek chce dołączyć do grupy. 1 rok Akceptuj + rozmowa z członkiem grupy + Akceptuj + Akceptuj jako członek grupy + Akceptuj jako obserwator grupy + Akceptuj dodanie kontaktu + Akceptuj dodanie kontaktu + Akceptuj użytkownika grupy + Dodaj wiadomość + Wszystko + Wszystkie nowe wiadomości od tego użytkownika będą ukryte + Zezwól na wszystkie pliki i media tylko jeśli twój kontakt na to pozwala. + Zezwól na zgłaszanie raportów do moderatorów. + Zezwól twoim kontaktom na wysyłanie plików i mediów. + Zezwól na archiwizowanie raportów dla ciebie. + Wszystkie serwery + Zarchiwizować wszystkie raporty? + Zarchiwizować %d raportów? + Archiwizuj raporty + Lepsze działanie grupy + Lepsza prywatność i bezpieczeństwo + Opis: + Opis zbyt duży + Bot + Ty i twój kontakt możecie wysyłać pliki i media. + Kontakt służbowy + Uzywając SimpleX Chat zgadzasz się na:\n- wysyłanie tylko prawnie dopuszczonych treści na publicznych grupach.\n- szanowanie innych użytkowników - nie wysyłanie SPAM-u + Nie mogę zmienić profilu + nie mogę wysłać wiadomości + 4 nowe języki interfejsu + Wszystkie wiadomości + Blokowanie członków dla wszystkich? + Kataloński, indonezyjski, rumuński i wietnamski - dzięki naszym użytkownikom! + szyfrowanie end-to-end.]]> + tylko po zaakceptowaniu twojego żądania.]]> + Zmienić automatyczne usuwania wiadomości? + Czaty z członkami + Czat z administratorami + Czar z administratorami + Czat z administratorami + Czat z członkiem + Czatuj z członkami, zanim dołączą. + Połącz + Połącz się szybciej! 🚀 + kontakt usunięty + kontakt wyłączony + kontakt nie gotowy + PROŚBY O KONTAKT OD GRUP + kontakt powinien zaakceptować… + Stwórz swój adres + %d czat(y) + %d czaty z członkami + domyślny (%s) + Usuń czat + Usuń wiadomości czatu z urządzenia. + Skasować czat z tym członkiem? + Skasuj wiadomości od tego członka + Skasować wiadomości od tego członka? + Skasuj wiadomości + Opcje wycofane + Opis jest zbyt duży + Bezpośrednie wiadomości między członkami są zabronione. + Wiadomości bezpośrednie między członkami są zabronione na tym czacie. + Wyłączyć automatyczne usuwanie wiadomości? + Zablokuj skasowane wiadomości + %d wiadomości + Nie przegap ważnych wiadomości. + %d raporty + Edytuj + Włącz domyślne znikanie wiadomości. + Włącz Flux w ustawieniach sieci i serwerów, aby uzyskać lepszą prywatność metadanych. + Włącz logi + Renegocjacja szyfrowania jest w toku. + Błąd podczas akceptacji warunków + Błąd podczas akceptacji członka + Błąd podczas dodawania serwera + Błąd podczas zmiany profilu + Błąd podczas tworzenia listy czatu + Błąd podczas tworzenia raportu + Błąd usuwania czatu + Błąd ładowania list czatu + Błąd oznaczania odczytu + Błąd otwierania czatu + Błąd otwierania grupy + Błąd odczytu bazy danych hasła + Błąd odrzucenia prośby o kontakt + Błąd zapisywania bazy danych + Błąd zapisywania serwerów + Błąd zapisywania ustawień + Błąd w konfiguracji serwerów. + Błąd aktualizowania listy czatu + Błąd aktualizacji serwera + Szybsze usuwania grup. + Szybsze wysyłanie wiadomości. + Ulubione + Plik jest zablokowany przez operatora serwera:\n%1$s. + Pliki + Pliki i media są zabronione na tym czacie. + Filtr + Odcisk palca w docelowym serwerze nie pasuje do certyfikatu: %1$s. + Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. + Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. + Napraw + Naprawić połączenie? + Dla wszystkich moderatorów + Lepsza prywatność metadanych. + Dla profilu czatu %s: + Na przykład, jeśli kontakt otrzyma wiadomości za pośrednictwem serwera czatu SimpleX, aplikacja dostarczy je za pośrednictwem serwera Flux. + Dla mnie + Dla prywatnego routingu + Dla mediów społecznościowych + Pełny link + Otrzymaj powiadomienie jeśli ktoś wspomni. + Grupa + grupa została usunięta + Grupy + Pomóż administratorom moderować ich grupy. + Jak to pomaga prywatności + Zdjęcia + Poprawiona nawigacja czatu + Niewłaściwa zawartość + Niewłaściwy profil + Zaproszenie do czatu + Dołącz do grupy + Zachowaj swoje czaty czyste + Opuść czat + Opuścić czat? + Mniejszy ruch w sieciach mobilnych. + Linki + Lista + Lista imion... + Nazwa i emoji powinny być inne dla wszystkich list. + Wczytywanie profilu… + Przyjęcie członkostwa + członek posiada starą wersję + Członek został usunięty - nie można przyjąć żądania + Wiadomości członkowskie zostaną usunięte - nie można tego cofnąć! + Raporty członkowskie + Członkowie mogą zgłaszać wiadomości moderatorom. + Członkowie zostaną usunięci z czatu - tego nie da się cofnąć! + Członkowie zostaną usunięci z grupy - nie można tego cofnąć! + Członek zostanie usunięty z czatu - nie można tego cofnąć! + Członek dołączy do grupy, czy zaakceptować tego członka? + Wspomnij członka 👋 + Wyślij wiadomość natychmiast po dotknięciu Połącz. + Wiadomość jest za duża! + Wiadomości od tych członków zostaną pokazane! + Wiadomości na tym czacie nigdy nie zostaną usunięte. + moderator + moderatorzy + Wycisz wszystko + Decentralizacja sieci + Operator sieci + Operatorzy sieci + Nowa rola w grupie: Moderator + Nowy serwer + Nie + Brak usług w tle + Żadnych czatów + Nie znaleziono żadnych czatów + Nie ma czatów na liście %s. + Żadnych rozmów z członkami + Brak mediów i serwerów plików multimedialnych. + Brak wiadomości + Brak serwerów wiadomości. + Brak prywatnej sesji routingu + Brak serwerów prywatnej sesji routingu + Brak serwerów do otrzymania plików. + Brak serwerów aby otrzymać wiadomości. + Brak serwerów do wysyłania plików. + brak subskrypcji + Notatki + Powiadomienia i bateria + nie zsynchronizowano + Brak nieprzeczytanych czatów + wyłączony + Wyłącz + Tylko właściciele czatu mogą zmieniać preferencje. + Widzą to tylko nadawca i moderatorzy + Widzisz to tylko Ty i moderatorzy + Tylko Ty możesz wysyłać pliki i multimedia. + Tylko Twój kontakt może wysyłać pliki i multimedia. + Otwórz zmiany + Otwórz czat + - Otwórz czat w pierwszej nieprzeczytanej wiadomości.\n- Przejdź do cytowanych wiadomości. + Otwórz czysty link + Otwórz warunki + Otwórz pełny link + Otwórz link + Otwórz linki z listy czatów + Otwórz nowy czat + Otwórz nową grupę + Otwórz by zaakceptować + Otwórz aby się połączyć + Otwórz aby dołączyć + Otwórz aby skorzystać z bota + Otworzyć link sieci web? + Otwórz z %s + Operator + Serwer Operatora + Organizuj czaty jako listy + Lub zaimportuj plik archiwalny + Lub udostępnij prywatnie + Nie można odczytać hasła w magazynie kluczy. Wprowadź je ręcznie. Mogło się to zdarzyć po aktualizacji systemu niezgodnej z aplikacją. Jeśli tak nie jest, skontaktuj się z programistami. + Nie można odczytać hasła w magazynie kluczy. Mogło się to zdarzyć po aktualizacji systemu niezgodnej z aplikacją. Jeśli tak nie jest, skontaktuj się z programistami. + oczekuje + oczekuje na zatwierdzenie + oczekuje na ocenę + Zmniejsz rozmiar wiadomości i wyślij ją ponownie. + Zmniejsz rozmiar wiadomości lub usuń multimedia i wyślij ponownie. + Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy. + Domyślne serwery + Domyślne serwery + Prywatność dla Twoich klientów. + Polityka prywatności i warunki korzystania. + Prywatne czaty, grupy i Twoje kontakty nie są dostępne dla operatorów serwerów. + Nazwy prywatnych plików multimedialnych. + Limit czasu routingu prywatnego + Zabroń raportowania wiadomości moderatorom. + Zabroń wysyłania plików i multimediów. + Limit czasu protokołu w tle + Dostępny pasek narzędzi czatu + Odrzuć + Odrzuć prośbę o kontakt + odrzucono + odrzucono + Odrzucić członka? + Zdalne telefony komórkowe + Usuń i skasuj wiadomości + usunięty z grupy + Usuń śledzenie linków + Usunąć członka? + Usuwa wiadomości i blokuje członków. + Zgłoś + Zgłoś treść: zobaczą ją tylko moderatorzy grupy. + Na tej grupie zabronione jest zgłaszanie wiadomości. + Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy. + Zgłoś inne: zobaczą to tylko moderatorzy grupy. + Jaki jest powód zgłoszenia? + Zgłoszenie: %s + Zgłoszenia + Zgłoszenia wysłane do moderatorów + Zgłoś spam: tylko moderatorzy grupy będą to widzieć. + Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy. + Prośba o połączenie od grupy %1$s + poproszono o połączenie + prośba została wysłana + prośba o dołączenie została odrzucona + ocena + Przejrzyj warunki + sprawdzone przez administratorów + Przejrzyj członków grupy + Przejrzyj później + Przejrzyj członków + Przejrzyj członków przed przyjęciem (pukanie). + Zapisać ustawienia wstępu? + Zapisz listę + Szukaj plików + Szukaj zdjęć + Szukaj linków + Szukaj wideo + Szukaj wiadomości głosowych + Wybierz operatora sieci + Wysłać prośbę o kontakt? + Wyślij prywatne zgłoszenia + Wyślij prośbę + Wyślij prośbę bez wiadomości + Wyślij swoją prywatną opinię do grup. + Wysłano do Twojego kontaktu po połączeniu. + Serwer dodany do operatora %s. + Operator serwera został zmieniony. + Operatorzy serwera + Protokół serwera zmieniony. + Ustaw nazwę czatu… + Ustaw przyjęcie członka + Ustaw datę wygaśnięcia wiadomości na czatach. + Ustaw biografię profilu i wiadomość powitalną. + Udostępnij adres publicznie + Udostępnij stary adres + Udostępnij stary link + Udostępnij adres SimpleX w mediach społecznościowych. + Udostępnij swój adres + Krótki opis: + Krótki link + Krótki adres SimpleX + Link do kanału na SimpleX + SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux. + łącze przekaźnikowe SimpleX + Spam + Spam + %s serwery + Dotknij Połącz aby rozpocząć czat + Dotknij Połącz, aby wysłać prośbę + Dotknij Połącz aby użyć bota + Dotknij Stwórz adres SimpleX w menu aby utworzyć go później. + Dotknij Dołącz do grupy + Przekroczono limit czasu połączenia TCP + Port TCP dla wiadomości + Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu. + Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie. + Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline. + Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link. + Raport zostanie dla Ciebie zarchiwizowany. + Rola zostanie zmieniona na %s. Wszyscy uczestnicy czatu zostaną powiadomieni. + Drugi predefiniowany operator w aplikacji! + Nadawca NIE zostanie poinformowany. + Serwery dla nowych plików Twojego bieżącego profilu czatu + Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte. + Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza. + Ta wiadomość została usunięta lub jeszcze nie otrzymana. + To ustawienie jest dla Twojego obecnego profilu. + Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. + Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu. + Żeby odebrać + Żeby wysłać + Aby wysyłać polecenia, musisz być podłączony. + Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie. + Przeźroczystość + Odblokować członków dla wszystkich? + Niedostarczone wiadomości + Nieprzeczytane wzmianki + Nieobsługiwane łącze połączenia + Aktualizacja + Warunki aktualizacji + Aktualizuj swój adres + Upgrade + Uaktualnij adres + Uaktualnić adres? + Uaktualnij link do grupy + Uaktualnić link do grupy? + Użyj dla plików + Użyj dla wiadomości + Użyj profilu incognito + Użyj %s + Użyj serwerów + Użyj portu TCP %1$s, jeśli nie określono żadnego portu. + Używaj portu TCP 443 tylko dla wstępnie ustawionych serwerów. + Użyj portu internetowego + Wideo + Zobacz warunki + Zobacz zaktualizowane warunki + Wiadomości głosowe + Strona Internetowa + Wiadomość powitalna + Powitaj swoje kontakty + Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje. + Tak + zaakceptowałeś tego członka + Nie masz połączenia z serwerem używanym do odbierania wiadomości z tego połączenia (brak subskrypcji). + Możesz skonfigurować operatorów w ustawieniach sieci i serwerów. + Serwery można skonfigurować w ustawieniach. + Możesz skopiować i zmniejszyć rozmiar wiadomości, aby ją wysłać. + Możesz wzmiankować do %1$s członków na wiadomość! + Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony. + Nie możesz wysyłać wiadomości! + Możesz przeglądać swoje raporty na czacie z administratorami. + odszedłeś + Twój opis: + Twój kontakt biznesowy + Twój profil na czacie zostanie wysłany do członków czatu + Twój kontakt + Twoja grupa + Twój profil + Twoje serwery + Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana. + Połączenie nie powiodło się + niepowodzenie + Jeśli dołączyłeś do kanałów lub je utworzyłeś, przestaną one działać na stałe. + Urodziłeś się bez konta. + Nikt nie śledził twoich rozmów. Nikt nie rysował mapy miejsc, w których byłeś. Prywatność nigdy nie była funkcją - była sposobem na życie. + Następnie przenieśliśmy się do sieci, a każda platforma prosiła o podanie danych osobowych - imienia i nazwiska, numeru telefonu, znajomych. Zaakceptowaliśmy fakt, że ceną za możliwość komunikowania się z innymi jest ujawnienie komuś, z kim rozmawiamy. Tak było w przypadku każdego pokolenia, ludzi i technologii - telefonu, poczty elektronicznej, komunikatorów, mediów społecznościowych. Wydawało się to jedyną możliwą opcją. + Jest jeszcze inny sposób. Sieć bez numerów telefonów. Bez nazw użytkowników. Bez kont. Bez jakichkolwiek tożsamości użytkowników. Sieć, która łączy ludzi i przesyła zaszyfrowane wiadomości, nie wiedząc, kto jest podłączony. + Nie chodzi o lepszy zamek w drzwiach kogoś innego. Nie chodzi o milszego właściciela, który szanuje twoją prywatność, ale nadal prowadzi rejestr wszystkich odwiedzających. Nie jesteś gościem. Jesteś w domu. Żaden król nie może do niego wejść - jesteś suwerenem. + Twoje rozmowy należą do Ciebie, tak jak zawsze było przed pojawieniem się Internetu. Sieć nie jest miejscem, które odwiedzasz. Jest miejscem, które tworzysz i które należy do Ciebie. Nikt nie może Ci tego odebrać, niezależnie od tego, czy jest to miejsce prywatne, czy publiczne. + Najstarsza ludzka wolność - możliwość rozmowy z inną osobą bez bycia obserwowanym - opiera się na infrastrukturze, która nie może jej zdradzić. + Ponieważ zniszczyliśmy moc pozwalającą poznać, kim jesteś. Więc twoja moc nigdy nie będzie Ci odebrana. + Ciesz się swobodą w swojej sieci. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 285d6f3802..c129d68521 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -87,8 +87,7 @@ Aceitar solicitações de contato automaticamente Aparência O serviço em segundo plano está sempre em execução - as notificações serão exibidas assim que as mensagens estiverem disponíveis. - Uma conexão TCP separada (e credencial SOCKS) será usada para cada contato e membro do grupo. -\nAtenção: se você tiver muitas conexões, o consumo de bateria e tráfego pode ser substancialmente maior e algumas conexões podem falhar. + para cada contato e membro do grupo. \nAtenção: se você tiver muitas conexões, o consumo de bateria e tráfego pode ser substancialmente maior e algumas conexões podem falhar.]]> Bom para bateria. O aplicativo procura por mensagens a cada 10 minutos. Você pode perder chamadas ou mensagens urgentes.]]> chamda encerrada %1$s Converse com os desenvolvedores @@ -418,7 +417,6 @@ O arquivo será recebido quando seu contato estiver online, aguarde ou verifique mais tarde! O perfil do grupo é armazenado nos dispositivos dos membros, não nos servidores. ajuda - Como o SimpleX funciona Servidores ICE (um por linha) Ignorar A imagem será recebida quando seu contato estiver online, aguarde ou verifique mais tarde! @@ -971,7 +969,7 @@ Toque para ativar o perfil. Mostrar perfil de chat Mostrar perfil - Tentando se conectar ao servidor utilizado para receber mensagens deste contato (erro:%1$s). + Tentando se conectar ao servidor utilizado para receber mensagens deste contato (erro:%1$s). Tentando se conectar ao servidor utilizado para receber mensagens deste contato. Você está conectado ao servidor usado para receber mensagens desse contato. Seu servidor @@ -1150,7 +1148,7 @@ Salvar configurações de aceitação automática Abrindo banco de dados… Alterar perfis de conversa - Compartilhar endereço com os contatos\? + Compartilhar endereço com os contatos? Seus contatos continuarão conectados. Todos os dados do aplicativo serão excluídos. A senha do aplicativo é substituída por uma senha de auto-destruição. @@ -1872,7 +1870,6 @@ Migrar para outro dispositivo Verificar palavra-passe WiFi - Alternar lista de conversa: Você pode mudar isso em configurações de Aparência. desativado nenhum @@ -2377,7 +2374,6 @@ Os servidores para novos arquivos do seu perfil de chat atual A conexão atingiu o limite de mensagens não entregues, seu contato pode estar offline. Mensagens não entregues - Configurar operadores de servidor Chats privados, grupos e seus contatos não são acessíveis aos operadores de servidor. Aceitar Ao usar o SimpleX Chat, você concorda em:\n- enviar apenas conteúdo legal em grupos públicos.\n- respeitar outros usuários – sem spam. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index 7232cc56d3..81cf8ed452 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -525,8 +525,7 @@ Negru Blocați membrul pentru toți? Atât tu, cât și contactul tău puteți șterge definitiv mesajele trimise. (24 de ore) - Folosește mai multă baterie! -\nServiciul în fundal rulează mereu – notificările sunt afișate imediat ce mesajele sunt disponibile. + Folosește mai multă baterie! \nServiciul în fundal rulează mereu – notificările sunt afișate imediat ce mesajele sunt disponibile.]]> Nu se poate trimite mesajul Șterge Confirmați fișiere de la servere necunoscute. @@ -1505,7 +1504,6 @@ Ieșire fără salvare Parolă profil ascuns italic - Cum funcționează SimpleX Fără identificatori de utilizator. Acest lucru se poate întâmpla atunci când:\n1. Mesajele au expirat în aplicația de trimitere după 2 zile sau pe server după 30 de zile.\n2. Decriptarea mesajului a eșuat, deoarece tu sau contactul tău ați folosit un backup vechi al bazei de date.\n3. Conexiunea a fost compromisă. Eroare la exportarea bazei de date a conversației @@ -1892,7 +1890,6 @@ Video Mesaj vocal (%1$s) Trimite - Comută lista de conversații: Pentru a primi notificări, te rugăm să introduci parola bazei de date Se așteaptă videoclipul Mesaje nelivrate @@ -1902,7 +1899,7 @@ Bun venit, %1$s! Atinge pentru a începe o conversație nouă Folosește acreditări proxy diferite pentru fiecare conexiune. - Adresă SimpleX sau link unic? + Adresă SimpleX sau link de unică folosință? Pentru a-ți proteja confidențialitatea, SimpleX folosește ID-uri separate pentru fiecare persoană de contact. Ai fost invitat în grup Pentru a primi @@ -1933,7 +1930,7 @@ necunoscut Poți ascunde sau dezactiva notificările unui profil de utilizator – ține apăsat pentru meniu. Ați permis - Partajează linkul unic cu un prieten + Partajează link de unică folosință cu un prieten Mesaje vocale Mesaj vocal… Setări proxy SOCKS @@ -1946,7 +1943,7 @@ Mesajele vocale sunt interzise! Ați acceptat conexiunea Partajează adresa public - Partajează acest link de invitație unic + Partajează acest link de invitație de unică folosință Pentru a verifica criptarea end-to-end, compară (sau scanează) codul de pe dispozitivele voastre cu persoana de contact. Utilizare pentru conexiuni noi Utilizați proxy SOCKS @@ -1989,7 +1986,7 @@ Videoclipuri și fișiere de până la 1 GB Comută modul incognito la conectare. Formă imagine de profil - Schimbă profilul de conversații pentru invitații unice. + Schimbă profilul de conversație pentru invitații de unică folosință. Puteți activa mai târziu prin Setări săptămâni Acesta este linkul tău unic, valabil o singură dată! @@ -2077,7 +2074,7 @@ Imaginea nu poate fi decodificată. Vă rugăm să încercați o altă imagine sau să contactați dezvoltatorii. Serverele pentru fișierele noi ale profilului tău de conversații actual Acest grup nu mai există. - Se încearcă conectarea la serverul folosit pentru a primi mesaje de la acest contact (eroare: %1$s). + Se încearcă conectarea la serverul folosit pentru a primi mesaje de la acest contact (eroare: %1$s). Actualizarea setărilor va reconecta clientul la toate serverele. Acest șir de caractere nu este un link! S-a atins timpul de expirare la conectarea la desktop @@ -2099,7 +2096,7 @@ (pentru a partaja cu persoana de contact) Pentru a începe o nouă conversație Video - Partajează link unic + Partajează link de unică folosință Acest link nu este un link de conectare valid! Acest cod QR nu este un link! Link scurt @@ -2174,7 +2171,7 @@ Partajează adresa SimpleX pe rețelele de socializare. Pentru a te conecta, persoana ta de contact poate scana codul QR sau poate folosi linkul din aplicație. Poți seta numele conexiunii, ca să-ți amintești cu cine ai partajat linkul. - Adresa SimpleX și linkurile unice pot fi partajate în siguranță prin orice aplicație de mesagerie. + Adresa SimpleX și linkurile de unică folosință pot fi partajate în siguranță prin orice aplicație de mesagerie. Codul scanat nu este un cod QR de tip link SimpleX. Server de testare Servere de testare @@ -2257,7 +2254,7 @@ Întreabă %s.]]> Schimbați ștergerea automată a mesajelor? - Adresă sau link unic? + Adresă sau link de unică folosință? Sesiune de aplicație Contactele tale Datele tale de conectare pot fi trimise necriptat. @@ -2270,7 +2267,7 @@ Contacte Conexiunea necesită renegocierea criptării. Adresa ta SimpleX - Creează link unic + Creează un link de unică folosință Profilul tău actual Aplicația rulează întotdeauna în fundal acceptat %1$s @@ -2284,7 +2281,6 @@ Ai partajat o cale de fișier nevalidă. Raportează problema dezvoltatorilor aplicației. Deschide în aplicația mobilă, apoi atinge Conectare în aplicație.]]> Actualizează adresa - Configurați operatorii serverului Condițiile vor fi acceptate pentru operatorii activați după 30 de zile. Apasă pe butonul de informații de lângă bara de adrese pentru a permite accesul la microfon. Colţ @@ -2388,7 +2384,7 @@ toți Afaceri Prin utilizarea SimpleX Chat ești de acord să:\n- trimiți doar conținut legal în grupurile publice.\n- respecți ceilalți utilizatori – fără spam. - doar cu un singur contact - partajează-l personal sau prin orice altă aplicație de mesagerie.]]> + doar cu un singur contact - partajează-l personal sau prin orice aplicație de mesagerie.]]> Nu trebuie să utilizați aceeași bază de date pe două dispozitive. arătați codul QR în apelul video sau distribuiți linkul.]]> Utilizare de pe desktop în aplicația mobilă și scanează codul QR.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 6bb357fea8..b5bbf47973 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -10,11 +10,11 @@ Вы соединитесь со всеми членами группы. Соединиться - соединено + соединен(а) ошибка соединяется - Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта. - Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %1$s). + Вы подключены к серверу, используемому для приёма сообщений от этого соединения. + Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %1$s). Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта. удалено @@ -27,7 +27,7 @@ connection %1$d соединение установлено - приглашение соединиться + приглашение соединяется… Вы создали одноразовую ссылку Вы создали одноразовую ссылку инкогнито @@ -42,7 +42,7 @@ SimpleX одноразовая ссылка SimpleX ссылка группы через %1$s - SimpleX ссылки + Ссылки SimpleX Описание Полная ссылка В браузере @@ -54,7 +54,7 @@ Превышено время соединения Ошибка соединения - Пожалуйста, проверьте Ваше соединение с сервером %1$s и попробуйте ещё раз. + Пожалуйста, проверьте Ваше соединение с %1$s и попробуйте ещё раз. Ошибка при отправке сообщения Ошибка при добавлении членов группы Ошибка при вступлении в группу @@ -78,22 +78,22 @@ Ошибка теста на шаге %s. Сервер требует авторизации для создания очередей, проверьте пароль. Хэш в адресе сервера не соответствует сертификату. - Соединение + Соединиться Создание очереди Защита очереди Удаление очереди - Разрыв соединения + Отключить Мгновенные уведомления Мгновенные уведомления! Мгновенные уведомления выключены! SimpleX выполняется в фоне вместо уведомлений через сервер.]]> - Он может быть выключен через Настройки – Вы продолжите получать уведомления о сообщениях пока приложение запущено.]]> + Он может быть выключен через Настройки - Вы продолжите получать уведомления о сообщениях пока приложение запущено.]]> Разрешите это в следующем окне, чтобы получать уведомления мгновенно.]]> Оптимизация батареи включена, поэтому сервис уведомлений выключен. Вы можете снова включить его через Настройки. Периодические уведомления Периодические уведомления выключены! - Приложение периодически получает новые сообщения — это потребляет несколько процентов батареи в день. Приложение не использует push уведомления — данные не отправляются с Вашего устройства на сервер. + Приложение периодически получает новые сообщения - это потребляет несколько процентов батареи в день. Приложение не использует push уведомления - данные не отправляются с Вашего устройства на сервер. Введите пароль Для получения уведомлений, пожалуйста, введите пароль от базы данных Ошибка базы данных @@ -114,7 +114,7 @@ Всегда включен Приложение может получить сообщение только тогда, когда запущено, в фоне сервис запускаться не будет Проверять новые сообщения раз в 10 минут на протяжении до 1 минуты - Фоновый сервис всегда включен. Уведомления будут показаны без задержки при наличии сообщений + Фоновый сервис всегда включен. Уведомления будут показаны без задержки при наличии сообщений. Текст сообщения Имена контактов Скрытое @@ -154,10 +154,10 @@ Редактировать Удалить Показать - Спрятать + Скрыть Разрешить Удалить сообщение? - Сообщение будет удалено – это действие нельзя отменить! + Сообщение будет удалено - это действие нельзя отменить! Сообщение будет помечено на удаление. Получатель(и) сможет(смогут) посмотреть это сообщение. Удалить для меня Для всех @@ -203,11 +203,11 @@ Файл Большой файл! - Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s). - В настоящее время максимальный поддерживаемый размер файла составляет %1$s. + Ваш контакт отправил файл, размер которого превышает максимальный размер (%1$s). + Максимальный размер файла - %1$s. Ожидается приём файла Файл будет принят, когда Ваш контакт будет в сети, подождите или проверьте позже! - Файл сохранен + Файл сохранён Файл не найден Ошибка сохранения файла @@ -221,11 +221,11 @@ Контакт и все сообщения будут удалены - это действие нельзя отменить! Удалить контакт Имя контакта… - Соединение с сервером установлено + Соединено Соединение с сервером не установлено - Ошибка соединения с сервером - Ожидается соединение с сервером - Переключить адрес получения? + Ошибка + Ожидает + Поменять адрес получения? Адрес получения сообщений будет перемещён на другой сервер. Изменение адреса завершится после того как отправитель будет онлайн. Отправить сообщение @@ -248,7 +248,7 @@ Начать новый разговор Создать ссылку-приглашение Соединиться через ссылку или QR-код - Сканировать\nQR-код + Сканировать QR-код Создать секретную группу (чтобы отправить Вашему контакту) (сканировать или вставить из буфера) @@ -271,7 +271,7 @@ Сканировать QR-код.]]> Open in mobile app на веб странице, затем нажмите Соединиться в приложении.]]> - Принять запрос на соединение? + Принять запрос? Отправителю НЕ будет послано уведомление, если Вы отклоните запрос на соединение. Принять Принять инкогнито @@ -291,7 +291,7 @@ Без звука Уведомлять - Вы пригласили Ваш контакт + Вы пригласили контакт Вы приняли приглашение соединиться Удалить ожидаемое соединение? Контакт, которому Вы отправили эту ссылку, не сможет соединиться! @@ -311,7 +311,7 @@ удалить превью ссылки Настройки QR-код - SimpleX адрес + Адрес SimpleX помощь SimpleX команда SimpleX логотип @@ -320,14 +320,14 @@ Показать QR-код - Неверный QR-код + Ошибка QR-кода Этот QR-код не является ссылкой! Неверная ссылка! Эта ссылка не является ссылкой-приглашением! - Запрос на соединение послан! + Запрос на соединение отправлен! Соединение с группой будет установлено, когда хост группы будет онлайн. Пожалуйста, подождите или проверьте позже! Соединение будет установлено, когда Ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! - Соединение будет установлено, когда Ваш контакт будет в сети. Пожалуйста, подождите или проверьте позже. + Соединение будет установлено, когда Ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже! показать QR-код во время видеозвонка или поделиться ссылкой.]]> Ваш профиль будет отправлен \nВашему контакту @@ -340,9 +340,9 @@ Одноразовая ссылка Настройки - Ваш SimpleX адрес - База данных - Подробнее о SimpleX Chat + Ваш адрес SimpleX + Пароль и экспорт базы + Информация о SimpleX Chat Как использовать Форматирование сообщений Форматирование сообщений @@ -374,21 +374,21 @@ Поставить звёздочку на GitHub Внести свой вклад Оценить приложение - Использовать серверы, предосталенные SimpleX Chat? + Использовать серверы, предоставленные SimpleX Chat? Ваши SMP-серверы - Используются серверы предоставленные SimpleX Chat. + Используются серверы, предоставленные SimpleX Chat. Инфо Как использовать серверы - Сохраненные WebRTC ICE серверы будут удалены. - Ваши ICE серверы - Настройка ICE серверов - ICE серверы (один на строке) - Ошибка при сохранении ICE серверов - Пожалуйста, проверьте, что адреса WebRTC ICE серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. + Сохранённые WebRTC ICE-серверы будут удалены. + Ваши ICE-серверы + Настройка ICE-серверов + ICE-серверы (один на строке) + Ошибка при сохранении ICE-серверов + Пожалуйста, проверьте, что адреса WebRTC ICE-серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. Сохранить Сеть и серверы Настройки сети - Настройки сети + Дополнительные настройки Использовать SOCKS-прокси? Соединяться с серверами через SOCKS-прокси через порт %d? Прокси должен быть запущен до включения этой опции. Использовать прямое соединение с Интернет? @@ -406,13 +406,13 @@ Создать адрес Удалить адрес? Все контакты, которые соединились через этот адрес, сохранятся. - Поделиться\nссылкой + Поделиться ссылкой Удалить адрес Имя профиля: Полное имя: Ваш активный профиль - Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю. + Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. Серверы SimpleX не могут получить доступ к Вашему профилю. Поменять аватар Удалить аватар Сохранить предпочтения? @@ -428,7 +428,7 @@ Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве. Профиль отправляется только Вашим контактам. Имя профиля не может содержать пробелы. - Введите ваше имя: + Имя: Создать О SimpleX @@ -444,9 +444,9 @@ Эта строка не является ссылкой-приглашением! Открыть в приложении.]]> - звонок… + входящий звонок… пропущенный звонок - отклоненный звонок + отклонённый звонок принятый звонок звонок соединяется… активный звонок @@ -459,8 +459,8 @@ получен ответ… получено подтверждение… соединяется… - соединено - завершен + соединен(а) + завершён Будущее коммуникаций Более конфиденциальный @@ -473,8 +473,7 @@ Добавьте контакт Как это работает - Как SimpleX работает - Чтобы защитить Вашу конфиденциальность, SimpleX использует разные ID для всех ваших контактов. + Чтобы защитить Вашу конфиденциальность, SimpleX использует разные ID для каждого Вашего контакта. Только пользовательские устройства хранят контакты, группы и сообщения. GitHub репозитория.]]> @@ -491,22 +490,22 @@ e2e зашифрованный аудиозвонок Принять Отклонить - Закрыть - Звонок уже завершен! + Не отвечать + Звонок уже завершён! видеозвонок аудиозвонок - Аудио- и видеозвонки + Аудио и видеозвонки Ваши звонки - Всегда соединяться через relay + Всегда соединяться через релей Звонки на экране блокировки: - Принимать + Принять Показывать Выключить - Ваши ICE серверы - WebRTC ICE серверы - Relay-сервер защищает Ваш IP-адрес, но может отслеживать продолжительность звонка. - Relay-сервер используется только при необходимости. Другая сторона может видеть Ваш IP-адрес. + Ваши ICE-серверы + WebRTC ICE-серверы + Релей-сервер защищает Ваш IP-адрес, но может отслеживать продолжительность звонка. + Релей-сервер используется только при необходимости. Другая сторона может видеть Ваш IP-адрес. Откройте SimpleX Chat\nчтобы принять звонок Вы можете разрешить принимать звонки на экране блокировки через Настройки. @@ -517,7 +516,7 @@ у контакта есть e2e шифрование у контакта нет e2e шифрования peer-to-peer - через relay сервер + через релей-сервер Закончить звонок Выключить видео Включить видео @@ -532,7 +531,7 @@ Отклоненный звонок Соединяющийся звонок Текущий звонок - Звонок завершен + Звонок завершён Принять звонок %1$d пропущенных сообщений @@ -540,10 +539,7 @@ ошибка ID сообщения повторное сообщение Пропущенные сообщения - Это может произойти, когда: -\n1. Клиент отправителя удалил неотправленные сообщения через 2 дня, или сервер – через 30 дней. -\n2. Расшифровка сообщения была невозможна, когда Вы или Ваш контакт использовали старую копию базы данных. -\n3. Соединение компроментировано. + Это может произойти, когда:\n1. Клиент отправителя удалил неотправленные сообщения через 2 дня, или сервер – через 30 дней.\n2. Расшифровка сообщения была невозможна, когда Вы или Ваш контакт использовали старую копию базы данных.\n3. Соединение компроментировано. Конфиденциальность Конфиденциальность @@ -580,7 +576,7 @@ Удалить данные чата Ошибка при запуске чата Остановить чат? - Остановите чат, чтобы экспортировать или импортировать архив чата или удалить базу данных. Вы не сможете получать и отправлять сообщения, пока чат остановлен. + Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен. Остановить Установите пароль База данных зашифрована случайным паролем. Пожалуйста, поменяйте его перед экспортом. @@ -588,21 +584,21 @@ Ошибка при экспорте архива чата Импортировать архив чата? Текущие данные Вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными. -\nЭто действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. +\nЭто действие нельзя отменить - ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. Импортировать Ошибка при удалении данных чата Ошибка при импорте архива чата Архив чата импортирован Перезапустите приложение, чтобы использовать импортированные данные чата. Удалить профиль? - Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. + Это действие нельзя отменить - Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. Данные чата удалены Перезапустите приложение, чтобы создать новый профиль. Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе Вы можете перестать получать сообщения от некоторых контактов. Удалить файлы во всех профилях чата Удалить все файлы Удалить файлы и медиа? - Это действие нельзя отменить — все полученные и отправленные файлы будут удалены. Изображения останутся в низком разрешении. + Это действие нельзя отменить - все полученные и отправленные файлы будут удалены. Изображения останутся в низком разрешении. Нет полученных или отправленных файлов %d файл(ов) общим размером %s никогда @@ -612,7 +608,7 @@ %s секунд Удалять сообщения через Включить автоматическое удаление сообщений? - Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут. + Это действие нельзя отменить - все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут. Удалить сообщения Ошибка при изменении настройки @@ -623,7 +619,7 @@ Уведомления будут работать только до остановки приложения! Удалить Зашифровать - Поменять + Обновить Текущий пароль… Новый пароль… Подтвердите новый пароль… @@ -633,22 +629,22 @@ Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. База данных зашифрована случайным паролем, Вы можете его поменять. Внимание: Вы не сможете восстановить или поменять пароль, если потеряете его.]]> - Пароль базы данных будет безопасно сохранен в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. - Пароль не сохранен на устройстве — Вы будете должны ввести его при каждом запуске чата. + Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. + Пароль не сохранён на устройстве - Вы будете должны ввести его при каждом запуске чата. Зашифровать базу данных? Поменять пароль базы данных? База данных будет зашифрована. - База данных будет зашифрована и пароль сохранен в Keystore. - Пароль базы данных будет изменен и сохранен в Keystore. + База данных будет зашифрована и пароль сохранён в Keystore. + Пароль базы данных будет изменён и сохранён в Keystore. Пароль базы данных будет изменен. - Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. - Пожалуйста, надежно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его. + Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. + Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его. Неправильный пароль базы данных База данных зашифрована Ошибка базы данных Ошибка Keystore - Пароль базы данных отличается от сохраненного в Keystore. + Пароль базы данных отличается от сохранённого в Keystore. Файл: %s Введите пароль базы данных, чтобы открыть чат. Ошибка: %s @@ -680,16 +676,16 @@ Вступление в группу Вы вступили в группу. Устанавливается соединение с пригласившим Вас членом группы. Выйти - Выйти из группы + Выйти из группы? Вы перестанете получать сообщения от этой группы. История чата будет сохранена. - Пригласить членов группы + Пригласить в группу Группа неактивна Приглашение истекло! Приглашение в группу больше не действительно, оно было удалено отправителем. Группа не найдена! Эта группа больше не существует. Нельзя пригласить контакты! - Вы используете профиль инкогнито в этой группе. Для защиты Вашего основного профиля приглашать контакты запрещено. + Вы используете профиль инкогнито в этой группе. Для защиты Вашего основного профиля приглашать контакты запрещено Вы отправили приглашение в группу Вы приглашены в группу @@ -713,7 +709,7 @@ Вы поменяли роль себе на: %s Вы удалили %1$s Вы покинули группу - профиль группы обновлен + профиль группы обновлён поменял(а) адрес для Вас смена адреса… @@ -727,7 +723,7 @@ владелец удален(а) - покинул(а) + покинул(а) группу группа удалена приглашен(а) соединяется (представлен(а)) @@ -740,7 +736,7 @@ соединяется Нет контактов для добавления - Роль нового члена группы + Роль члена группы Развернуть выбор роли Пригласить в группу Не приглашать членов @@ -750,9 +746,9 @@ Выбрано контактов: %d Контакты не выбраны Нельзя пригласить контакт! - Вы пытаетесь пригласить контакт, который знает Ваш профиль инкогнито, в группу, где Вы используете основной профиль. + Вы пытаетесь пригласить контакт, который знает Ваш профиль инкогнито, в группу, где Вы используете основной профиль - Пригласить членов группы + Пригласить в группу %1$s ЧЛЕНОВ ГРУППЫ Вы: %1$s Удалить группу @@ -765,8 +761,8 @@ Создать ссылку Удалить ссылку? Удалить ссылку - Вы можете поделиться ссылкой или QR-кодом — любой сможет присоединиться к группе. Члены группы останутся, даже если вы позже удалите ссылку. - Все члены группы, которые соединились через эту ссылку, останутся в группе. + Вы можете поделиться ссылкой или QR-кодом - любой сможет присоединиться к группе. Члены группы останутся, даже если вы позже удалите ссылку. + Все члены группы останутся соединены. Ошибка при создании ссылки группы Ошибка при удалении ссылки группы Только владельцы группы могут изменять предпочтения группы. @@ -777,7 +773,7 @@ Удалить члена группы Отправить сообщение - Член группы будет удалён - это действие нельзя отменить. + Член группы будет удалён - это действие нельзя отменить! Удалить ЧЛЕН ГРУППЫ Роль @@ -798,7 +794,7 @@ Получение через Отправка через Состояние сети - Переключить адрес получения + Поменять адрес получения Создать скрытую группу Группа полностью децентрализована – она видна только членам. @@ -818,12 +814,12 @@ Включить TCP keep-alive Сохранить Обновить настройки сети? - Обновление настроек приведет к переподключению клиента ко всем серверам. + Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами. Обновить Инкогнито Случайный профиль - Режим Инкогнито защищает Вашу конфиденциальность — для каждого контакта создаётся новый случайный профиль. + Режим Инкогнито защищает Вашу конфиденциальность - для каждого контакта создаётся новый случайный профиль. Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя. Когда Вы соединены с контактом инкогнито, тот же самый профиль инкогнито будет использоваться для групп с этим контактом. @@ -847,41 +843,41 @@ Предпочтения контакта Предпочтения группы Предпочтения группы - Настройки чатов + Ваши предпочтения Прямые сообщения Удаление для всех Голосовые сообщения включено включено для Вас включено для контакта - выключено + нет получено, не разрешено Разрешить Вашим контактам необратимо удалять отправленные сообщения. (24 часа) Разрешить необратимое удаление сообщений, только если Ваш контакт разрешает это Вам. (24 часа) Контакты могут помечать сообщения для удаления; Вы сможете просмотреть их. Разрешить Вашим контактам отправлять голосовые сообщения. Разрешить голосовые сообщения, только если их разрешает Ваш контакт. - Запретить отправлять голосовые сообщений. + Запретить отправлять голосовые сообщения. Вы и Ваш контакт можете необратимо удалять отправленные сообщения. (24 часа) Только Вы можете необратимо удалять сообщения (Ваш контакт может помечать их на удаление). (24 часа) Только Ваш контакт может необратимо удалять сообщения (Вы можете помечать их на удаление). (24 часа) - Необратимое удаление сообщений запрещено в этой группе. + Необратимое удаление сообщений запрещено. Вы и Ваш контакт можете отправлять голосовые сообщения. Только Вы можете отправлять голосовые сообщения. Только Ваш контакт может отправлять голосовые сообщения. Голосовые сообщения запрещены в этом чате. - Разрешить посылать прямые сообщения членам группы. - Запретить посылать прямые сообщения членам группы. + Разрешить личные сообщения членам группы. + Запретить посылать личные сообщения членам группы. Разрешить необратимо удалять отправленные сообщения. (24 часа) Запретить необратимое удаление сообщений. Разрешить отправлять голосовые сообщения. Запретить отправлять голосовые сообщения. - Члены могут посылать прямые сообщения. + Члены могут посылать личные сообщения. Прямые сообщения между членами группы запрещены. Члены могут необратимо удалять отправленные сообщения. (24 часа) Необратимое удаление сообщений запрещено. - Члены могут отправлять голосовые сообщения. - Голосовые сообщения запрещены. + Члены группы могут отправлять голосовые сообщения. + Голосовые сообщения запрещены в этой группе. Минимальный расход батареи. Вы получите уведомления только когда приложение запущено, без фонового сервиса.]]> Уведомления Когда приложение запущено @@ -899,15 +895,15 @@ %d сек %dс %d мин - %d мес. - %d мес. + %d мес + %d мес %dм %dмес %d час - %d ч. + %d ч %dч %d день - %d нед. + %d нед Исчезающие сообщения Показать код безопасности Подтвердить код безопасности @@ -922,22 +918,22 @@ Не удалось открыть чаты Неправильный код безопасности! Сканировать код - Отправить живое сообщение — оно будет обновляться для получателей по мере того, как Вы его вводите + Отправить живое сообщение - оно будет обновляться для получателей по мере того, как Вы его вводите Создать ссылку группы Запретить отправлять исчезающие сообщения. Исчезающие сообщения запрещены. %dнед %dд - %d нед. + %d недель %d дней - Чтобы подтвердить безопасность end-to-end шифрования с Вашим контактом сравните (или сканируйте) код на ваших устройствах. + Чтобы подтвердить безопасность сквозного шифрования с Вашим контактом сравните (или сканируйте) код на ваших устройствах. %s подтверждён %s не подтверждён Код безопасности Подтвердить Сбросить подтверждение Разрешить исчезающие сообщения, только если Ваш контакт разрешает их Вам. - Запретить посылать исчезающие сообщения. + Запретить отправлять исчезающие сообщения. Члены могут посылать исчезающие сообщения. Что нового Новое в %s @@ -960,7 +956,7 @@ ошибка чата Принять Установить 1 день - неверные данные + ошибка данных Ссылки групп Админы могут создать ссылки для вступления в группу. Автоматически принимать запросы контактов @@ -981,23 +977,23 @@ Только локальные данные профиля Сообщения Серверы для новых соединений Вашего текущего профиля чата - Ваши профили + Ваши профили чата Все чаты и сообщения будут удалены - это нельзя отменить! Сборка приложения: %s Версия приложения: v%s для каждого контакта и члена группы. \nОбратите внимание: если у Вас много контактов, потребление батареи и трафика может быть значительно выше, и некоторые соединения могут не работать.]]> для каждого профиля чата, который Вы имеете в приложении.]]> Версия ядра: v%s - Удалить профиль чата\? + Удалить профиль? Удалить профиль чата для Эта настройка применяется к сообщениям в Вашем текущем профиле чата - Отдельные сессии для + Отдельные транспортные сессии Обновить режим отдельных сессий\? - Имя профиля уже используется + Имя профиля уже используется! Ошибка создания профиля! У Вас уже есть профиль с таким именем. Пожалуйста, выберите другое имя. Ошибка выбора профиля! - По профилю чата или по соединению (БЕТА) + По профилю чата или по соединению (БЕТА). Благодаря пользователям – добавьте переводы через Weblate! Разные имена, аватары и транспортные сессии. Итальянский интерфейс @@ -1012,18 +1008,18 @@ Уменьшенное потребление батареи Отдельные транспортные сессии модерировано - модерировано %s + удалено %s Удалить сообщение члена группы\? Модерировать Сообщение будет удалено для всех членов группы. Сообщение будет помечено как удалённое для всех членов группы. Пожалуйста, свяжитесь с админом группы. - Вы \"читатель\" + только чтение сообщений только чтение сообщений читатель Роль при вступлении Ошибка обновления ссылки группы - Системный + Системная Аудио и видео звонки Ошибка при сохранении пароля пользователя Сохранить серверы\? @@ -1049,19 +1045,19 @@ Приветственное сообщение группы Скрыть профиль Без звука - Сделайте профиль конфиденциальным! + Сделайте профиль скрытым! Дополнительные улучшения скоро! - Теперь админы могут: \n- удалять сообщения членов группы. \n- приостанавливать членов группы (роль наблюдатель) + Теперь админы могут:\n- удалять сообщения членов.\n- приостанавливать членов (роль наблюдатель) Защитите Ваши профили чата паролем! Раскрыть Поддержка bluetooth и другие улучшения. Сохранить приветственное сообщение\? - Установите приветственное сообщение для новых членов группы. - Нажмите на профиль, чтобы переключиться на него. - Благодаря пользователям - добавьте переводы через Weblate! - Вы все равно получите звонки и уведомления в профилях без звука, когда они активные. + Установить сообщение для новых членов группы! + Нажмите на профиль, чтобы переключиться. + Благодаря пользователям – добавьте переводы через Weblate! + Вы всё равно получите звонки и уведомления в профилях без звука, когда они активные. Вы можете скрыть или отключить уведомления профиля - нажмите и удерживайте профиль, чтобы открыть меню. - Изображение будет принято когда Ваш контакт его загрузит. + Изображение будет принято, когда Ваш контакт его загрузит. Файл будет принят когда Ваш контакт загрузит его. Обновление базы данных Подтвердить обновление базы данных @@ -1074,7 +1070,7 @@ версия базы данных новее чем приложения, но нет миграции для отката: %s разная миграция в приложении/базе данных: %s / %s Откатить версию и открыть чат - Предупреждение: Вы можете потерять какие то данные! + Предупреждение: Вы можете потерять некоторые данные! ID базы данных и опция Отдельные транспортные сессии. Показать опции для разработчиков Удалить профиль чата @@ -1129,7 +1125,7 @@ Ошибка расшифровки Блокировка SimpleX не включена! Ошибка хэша сообщения - Хэш предыдущего сообщения отличается. + Хэш предыдущего сообщения отличается Подтвердить код Неправильный код Заблокировать через @@ -1203,7 +1199,7 @@ Изменить код самоуничтожения Самоуничтожение Код самоуничтожения включен! - Код доступа в приложение будет заменен кодом самоуничтожения. + Код доступа в приложение будет заменён кодом самоуничтожения. Включить код самоуничтожения Код самоуничтожения Код самоуничтожения изменен! @@ -1231,8 +1227,8 @@ Запретить реакции на сообщения. секунд ЦВЕТА ИНТЕРФЕЙСА - Поделиться адресом с контактами\? - Обновлённый профиль будет отправлен Вашим контактам. + Поделиться адресом с контактами SimpleX? + Обновление профиля будет отправлено Вашим SimpleX контактам. Об адресе SimpleX Узнать больше Если Вы не можете встретиться лично, покажите QR-код во время видеозвонка или поделитесь ссылкой. @@ -1243,10 +1239,10 @@ Ваши контакты сохранятся. Настроить тему Создайте адрес, чтобы можно было соединиться с Вами. - Все Ваши контакты сохранятся. Обновленный профиль будет отправлен Вашим контактам. - Добавьте адрес в свой профиль, чтобы Ваши контакты могли поделиться им. Профиль будет отправлен Вашим контактам. + Все Ваши контакты сохранятся. Обновлённый профиль будет отправлен Вашим контактам. + Добавьте адрес в свой профиль, чтобы Ваши SimpleX контакты могли поделиться им. Профиль будет отправлен Вашим SimpleX контактам. Создать адрес SimpleX - Поделиться с контактами + Поделиться с контактами SimpleX Прекратить делиться адресом\? Автоприём Введите приветственное сообщение... (опционально) @@ -1311,13 +1307,13 @@ Во время импорта произошли некоторые ошибки: нет текста Поиск - Отключено + Выключено Они могут быть изменены в настройках контактов и групп. Отчёты о доставке выключены! шифрование работает для %s требуется новое соглашение о шифровании для %s Изменение адреса будет прекращено. Будет использоваться старый адрес. - Остановить изменение адреса + Прекратить изменение адреса Контакты Выключить (кроме исключений) шифрование согласовывается… @@ -1330,17 +1326,17 @@ Починить шифрование после восстановления из бэкапа. Починить Нет истории - Отправка отчётов о доставке включена для %d контактов. + Отправка отчётов о доставке включена для %d контактов Отправка отчётов о доставке будет включена для всех контактов во всех видимых профилях чата. Установка для Вашего активного профиля Установки для Вашего активного профиля - Отправка отчётов о доставке выключена для %d контактов. + Отправка отчётов о доставке выключена для %d контактов Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! - Вторая галочка, когда сообщение доставлено! ✅ + Вторая галочка - знать, что доставлено! ✅ Вы можете включить их позже в настройках Конфиденциальности. Ошибка при прекращении изменения адреса Прекратить - Остановить изменение адреса? + Прекратить изменение адреса? Не избранный Нотификации перестанут работать, пока вы не перезапустите приложение Таймаут протокола на KB @@ -1365,15 +1361,15 @@ шифрование работает новое соглашение о шифровании разрешено код безопасности изменился - Отправлять отчёты о доставке + Отчёты о доставке %s в %s Починить соединение - Починка не поддерживается контактом. - Восстановление шифрования не поддерживается членом группы + Починка не поддерживается контактом + Починка не поддерживается членом группы Пересогласовать шифрование Быстрый поиск чатов Отчёты о доставке сообщений! - Еще несколько изменений + Ещё несколько изменений Отчёты о доставке! Включить Даже когда они выключены в разговоре. @@ -1392,7 +1388,7 @@ Нет отфильтрованных разговоров Пересогласовать Пересогласовать шифрование\? - Запретить посылать файлы и медиа. + Запретить отправлять файлы и медиа. Соединиться Инкогнито Разрешить Открыть настройки приложения @@ -1416,7 +1412,7 @@ %s: %s В этой группе более %1$d членов, отчёты о доставке не отправляются. %s, %s и %d других членов соединены - выключено + выключен Эта функция ещё не поддерживается. Проверьте в следующем релизе. Соединиться напрямую\? Запрос на соединение будет отправлен этому члену группы. @@ -1433,9 +1429,9 @@ Использовать активный профиль Использовать новый профиль инкогнито Расход батареи приложением / Без ограничений в настройках приложения.]]> - База данных будет зашифрована, и пароль сохранен в настройках. - Шифруйте сохраненные файлы и медиа - Обратите внимание: соединение с серверами файлов и сообщений устанавливаются через SOCKS-прокси. Звонки и картинки ссылок используют прямое соединение.]]> + База данных будет зашифрована, и пароль сохранён в настройках. + Шифруйте сохранённые файлы и медиа + Обратите внимание: соединение с серверами файлов и сообщений устанавливаются через SOCKS-прокси. Звонки используют прямое соединение.]]> Шифровать локальные файлы Приложение для компьютера! 6 новых языков интерфейса @@ -1459,12 +1455,12 @@ Пароль хранится в настройках, как открытый текст. Открыть Ошибка при создании контакта - Послать прямое сообщение контакту + Отправить личное сообщение контакту Ошибка отправки приглашения Отправьте сообщение чтобы соединиться запрос на соединение Раскрыть - Блокируйте членов группы + Заблокировать членов группы Повторить запрос на соединение? Ошибка нового соглашения о шифровании удалил(а) контакт @@ -1492,18 +1488,18 @@ Обнаружение по локальной сети и %d других событий Соединиться через ссылку? - Группы инкогнито + Инкогнито группы Вступление в группу уже начато! - %1$d сообщений отмодерировано членом %2$s + %1$d сообщений модерировано членом %2$s %s был отключен]]> - Быстрое вступление и надежная доставка сообщений. + Быстрое вступление и надёжная доставка сообщений. Соединиться с самим собой? Связанные мобильные Компьютер Компьютер подключен Загрузка файла Подключение к компьютеру - Ошибка нового соглашения о шифровании + Ошибка нового соглашения о шифровании. Компьютеры Исправить имя на %s? Удалить %d сообщений? @@ -1538,7 +1534,7 @@ Неверный путь к файлу Сканируйте с мобильного Отключить компьютер? - Пожалуйста, подождите, пока файл загружается со связанного мобильного устройства. + Пожалуйста, подождите, пока файл загружается со связанного мобильного устройства Все новые сообщения от %s будут скрыты! Версия настольного приложения %s несовместима с этим приложением. заблокировано @@ -1595,15 +1591,15 @@ Поле поиска поддерживает ссылки-приглашения. История сообщений и улучшенный каталог групп. %s неактивен]]> - Превышено максимальное время соединения с компьютером. + Превышено максимальное время соединения с компьютером Компьютер отсоединён Неверный код приглашения у компьютера Загрузка чатов… Включить доступ к камере Нажмите, чтобы сканировать Создать группу: создать новую группу.]]> - Не отправлять историю новым членам группы. - Отправить до 100 последних сообщений новым членам группы. + Не отправлять историю новым членам. + Отправить до 100 последних сообщений новым членам. Все сообщения будут удалены - это нельзя отменить! Камера недоступна Код доступа в приложение @@ -1622,7 +1618,7 @@ Ошибка создания сообщения Ошибка удаления заметки Венгерский и Турецкий интерфейс - Искать или вставьте ссылку SimpleX + Искать или вставить ссылку SimpleX Этот QR-код не является SimpleX-ccылкой. С зашифрованными файлами и медиа. С уменьшенным потреблением батареи. @@ -1633,8 +1629,8 @@ Запустить чат? Личные заметки Доступ к истории - История не отправляется новым членам группы. - До 100 последних сообщений отправляются новым членам группы. + История не отправляется новым членам. + До 100 последних сообщений отправляются новым членам. Показывать внутренние ошибки Ошибка соединения с компьютером %s]]> @@ -1670,7 +1666,7 @@ установлен новый адрес контакта установлена новая картинка профиля профиль обновлён - Версия приложения на компьютере не поддерживается. Пожалуйста, установите одинаковую версию на оба устройства. + Версия приложения на компьютере не поддерживается. Пожалуйста, установите одинаковую версию на оба устройства Внутренняя ошибка Очистить личные заметки? Новый чат @@ -1682,26 +1678,26 @@ Сохранённое сообщение неизвестно неизвестный статус - %d сообщений заблокировано администратором + %d сообщений заблокировано админом %s заблокирован %s разблокирован Вы разблокировали %s Разблокировать для всех - Заблокировать члена группы для всех? + Заблокировать для всех? заблокирован заблокировано администратором Заблокирован администратором Заблокировать для всех Ошибка при блокировании члена группы для всех - Разблокировать члена группы для всех? + Разблокировать члена для всех? Вы заблокировали %s - end-to-end шифрованием с прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома.]]> - Чат защищён end-to-end шифрованием. - Чат защищён квантово-устойчивым end-to-end шифрованием. + сквозным шифрованием с прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома.]]> + Чат защищён сквозным шифрованием. + Чат защищён квантово-устойчивым сквозным шифрованием. Открыть экран миграции Миграция с другого устройства Установить пароль - стандартное end-to-end шифрование + стандартное сквозное шифрование Приветственное сообщение слишком длинное Сообщение слишком большое Повторить загрузку @@ -1739,7 +1735,7 @@ Остановка чата Архивировать и загрузить Подтвердить загрузку - Все ваши контакты, разговоры и файлы будут надежно зашифрованы и загружены на выбранные XFTP-серверы. + Все ваши контакты, разговоры и файлы будут надёжно зашифрованы и загружены на выбранные XFTP-серверы. Ошибка загрузки Повторить загрузку Вы можете попробовать ещё раз. @@ -1750,7 +1746,7 @@ Завершить миграцию Или передайте эту ссылку Миграция завершена - Внимание: запуск чата на нескольких устройствах не поддерживается и приведет к сбоям доставки сообщений. + Внимание: запуск чата на нескольких устройствах не поддерживается и приведёт к сбоям доставки сообщений не должны использовать одну и ту же базу данных на двух устройствах.]]> Проверьте подключение к Интернету и повторите попытку Подтвердите, что Вы помните пароль базы данных для её миграции. @@ -1762,9 +1758,9 @@ Видеозвонок Чтобы продолжить, чат должен быть остановлен. Обратите внимание: использование одной и той же базы данных на двух устройствах нарушит расшифровку сообщений от ваших контактов, как свойство защиты соединений.]]> - Внимание: архив будет удален.]]> + Внимание: архив будет удалён.]]> Подтвердите настройки сети - квантово-устойчивым end-to-end шифрованием с идеальной прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома.]]> + квантово-устойчивым сквозным шифрованием с идеальной прямой секретностью (PFS), правдоподобным отрицанием и восстановлением от взлома.]]> Мигрировать сюда Мигрировать на другое устройство Мигрируйте на другое устройство через QR-код. @@ -1774,7 +1770,7 @@ Квантово-устойчивое шифрование Используйте приложение во время звонка. Экспортированный файл не существует - Файл удален или ошибка ссылки + Файл удалён или ошибка ссылки Завершите миграцию на другом устройстве. Выполняется миграция базы данных. \nЭто может занять несколько минут. @@ -1801,7 +1797,7 @@ Наушники Громкоговоритель Звуки во время звонков - Более надежное соединение с сетью. + Более надёжное соединение с сетью. Статус сети сохранено сохранено из %s @@ -1815,7 +1811,7 @@ Ссылки SimpleX Разрешить отправлять ссылки SimpleX. Запретить отправку ссылок SimpleX - Члены могут отправлять ссылки SimpleX + Члены группы могут отправлять ссылки SimpleX. админы все члены владельцы @@ -1825,7 +1821,7 @@ Включено для Переслать Переслать и сохранить сообщение - Ссылки SimpleX запрещены. + Ссылки SimpleX запрещены в этой группе. Переслать сообщение… Литовский интерфейс Источник сообщения остаётся конфиденциальным. @@ -1880,22 +1876,22 @@ Информация об очереди сообщений Персидский интерфейс Защитить IP-адрес - Защитите ваш IP-адрес от серверов сообщений, выбранных Вашими контактами. \nВключите в настройках Сеть и серверы. - Отправьте сообщения напрямую, когда Ваш сервер или сервер получателя не поддерживает конфиденциальную доставку. + Защитите ваш IP-адрес от серверов сообщений, выбранных Вашими контактами.\nВключите в настройках *Сети и серверов*. + Отправлять сообщения напрямую, когда Ваш сервер или сервер получателя не поддерживает конфиденциальную доставку. Конфиденциальная доставка Использовать конфиденциальную доставку с неизвестными серверами. Использовать конфиденциальную доставку с неизвестными серверами, когда IP-адрес не защищён. Когда IP защищён Да Чтобы защитить Ваш IP-адрес, приложение использует Ваши SMP-серверы для конфиденциальной доставки сообщений. - Изображения профилей + Картинки профилей Все режимы Тема приложения Сбросить на тему приложения Сбросить на тему пользователя Неизвестные серверы! Без Tor или VPN, Ваш IP-адрес будет доступен этим серверам файлов: \n%1$s. - Не использовать конфиденциальную маршрутизацию. + Не использовать конфиденциальную доставку. Никогда Неизвестные серверы Нет @@ -1924,7 +1920,7 @@ Применить к Не удаётся отправить сообщение Бета - Соединeно + Соединено попытки Готово Потвердить удаление контакта? @@ -1945,10 +1941,10 @@ Пересылающий сервер %1$s не смог подключиться к серверу назначения %2$s. Попробуйте позже. Версия пересылающего сервера несовместима с настройками сети: %1$s. Версия сервера назначения %1$s несовместима с пересылающим сервером %2$s. - Неверный ключ или неизвестный адрес блока файла - скорее всего, файл удален. + Неверный ключ или неизвестный адрес блока файла - скорее всего, файл удалён. Выбранные настройки чата запрещают это сообщение. Ошибка файла - Сканировать / Вставить ссылку + Вставить ссылку / Сканировать Другие XFTP-серверы Настроенные XFTP-серверы Загрузка %s (%s) @@ -1964,7 +1960,7 @@ Всего Активные соединения Приём сообщений - В ожидании + Ожидает Загружено Статистика серверов будет сброшена - это нельзя отменить! Всего отправлено @@ -2000,9 +1996,9 @@ Блоков удалено Блоков принято Подписок игнорировано - Ошибка копирования + Скопировать ошибку видеозвонок - Контакт будет удален — это нельзя отменить! + Контакт будет удалён - это нельзя отменить! Оставить разговор Удалить только разговор Удалить без уведомления @@ -2015,8 +2011,8 @@ Показать процент Слабое Среднее - Выключено - Доступная панель приложения + Нет + Панель приложения внизу Текущий профиль Нет информации, попробуйте перезагрузить Информация о серверах @@ -2045,8 +2041,8 @@ Выбрать Сообщения будут удалены для всех членов группы. Сообщения будут помечены как удалённые для всех членов группы. - Контакт удален! - Разговор удален! + Контакт удалён! + Разговор удалён! Член группы неактивен Прямого соединения пока нет, сообщение переслано или будет переслано админом. Ничего не выбрано @@ -2078,7 +2074,7 @@ Пригласить Статус сообщения Контакт соединяется, подождите или проверьте позже! - Контакт удален. + Контакт удалён. Попросите Вашего контакта разрешить звонки. Сохранить и переподключиться Отправьте сообщение, чтобы включить звонки. @@ -2089,14 +2085,14 @@ Ошибки Получено сообщений Сообщений отправлено - Переподключить все подключенные серверы для устранения неполадок доставки сообщений. Это использует дополнительный трафик. + Повторно подключите все серверы, чтобы принудительно доставить сообщения. Используется дополнительный трафик. Переподключить все серверы Переподключить сервер? Переподключить серверы? Сбросить всю статистику Статистика Вы не подключены к этим серверам. Для доставки сообщений на них используется конфиденциальная доставка. - Соединяйтесь с друзьями быстрее + Соединяйтесь с друзьями быстрее. Управляйте своей сетью Защищает ваш IP-адрес и соединения. Открыть настройки серверов @@ -2108,8 +2104,8 @@ Транспортные сессии Состояние соединения и серверов. Удаляйте до 20 сообщений за раз. - Загрузка обновления, не закрывайте приложение. - Файл не найден - скорее всего, файл был удален или отменен. + Загрузка обновления, не закрывайте приложение + Файл не найден - скорее всего, файл был удалён или отменен. Адрес пересылающего сервера несовместим с настройками сети: %1$s. написать Сообщение @@ -2126,13 +2122,12 @@ Новые медиа-опции Пригласить Новое сообщение - Переключите список чатов: Обновление приложения Загружать новые версии из GitHub. Увеличить размер шрифтов. Новый интерфейс 🎉 Открыть из списка чатов. - Сбросить все подсказки. + Сбросить все подсказки Вы можете изменить это в настройках Интерфейса. Пересылка %1$s сообщений Сохранение %1$s сообщений @@ -2155,11 +2150,11 @@ %1$s сообщений не переслано Переслать сообщения… Проверьте правильность ссылки SimpleX. - Неверная ссылка + Ошибка ссылки БАЗА ДАННЫХ - Ошибка инициализации WebView. Убедитесь, что у вас установлен WebView и его поддерживаемая архитектура – arm64.\nОшибка: %s + Ошибка инициализации WebView. Убедитесь, что у вас установлен WebView и его поддерживаемая архитектура - arm64.\nОшибка: %s Звук отключен - Сообщения будут удалены — это нельзя отменить! + Сообщения будут удалены - это нельзя отменить! Ошибка переключения профиля Выберите профиль чата Поделиться профилем @@ -2192,13 +2187,13 @@ Имя пользователя Ваши учётные данные могут быть отправлены в незашифрованном виде. Удалить архив? - Загруженный архив базы данных будет навсегда удален с серверов. + Загруженный архив базы данных будет навсегда удалён с серверов. Принятые условия Принять условия Нет серверов сообщений. Нет серверов для приёма сообщений. Ошибки в настройках серверов. - Для профиля %s: + Для профиля чата %s: Нет серверов файлов и медиа. Нет серверов для приёма файлов. Нет серверов для отправки файлов. @@ -2211,13 +2206,13 @@ Посмотреть условия Посмотреть условия %s.]]> - Условия будут автоматически приняты для включенных операторов: %s + Условия будут автоматически приняты для включенных операторов: %s. Условия приняты: %s. Вебсайт %s.]]> %s.]]> %s, примите условия использования.]]> - Для оправки + Для отправки Дополнительные серверы сообщений Использовать для файлов Открыть условия @@ -2251,8 +2246,8 @@ Адрес SimpleX или одноразовая ссылка? Настройки адреса Добавьте сотрудников в разговор. - Бизнес адрес - end-to-end шифрованием, с пост-квантовой безопасностью в прямых разговорах.]]> + Бизнес-адрес + сквозным шифрованием, с пост-квантовой безопасностью в прямых разговорах.]]> Приложение всегда выполняется в фоне Проверять сообщения каждые 10 минут Без фонового сервиса @@ -2272,11 +2267,11 @@ Удалить разговор Удалить разговор? Пригласить в разговор - Разговор будет удален для всех участников - это действие нельзя отменить! + Разговор будет удалён для всех участников - это действие нельзя отменить! Оператор %s серверы %s.]]> - Условия будут приняты: %s + Условия будут приняты: %s. Оператор сети Использовать %s Использовать серверы @@ -2284,15 +2279,15 @@ %s.]]> Или импортировать файл архива Доступная панель чата - Разговор будет удален для Вас - это действие нельзя отменить! + Разговор будет удалён для Вас - это действие нельзя отменить! Покинуть разговор Только владельцы разговора могут поменять предпочтения. Текст условий использования не может быть показан, вы можете посмотреть их через ссылку: Разговор - Участник будет удалён из разговора - это действие нельзя отменить. + Член будет удалён из разговора - это действие нельзя отменить! Серверы по умолчанию Роль будет изменена на %s. Все участники разговора получат уведомление. - Ваш профиль будет отправлен участникам разговора. + Ваш профиль будет отправлен участникам разговора %s.]]> %s.]]> Условия использования @@ -2316,15 +2311,15 @@ Нет серверов для доставки сообщений. Вы можете настроить серверы позже. SimpleX Chat и Flux заключили соглашение добавить серверы под управлением Flux в приложение. - Приложение защищает вашу конфиденциальность, используя разные операторы в каждом разговоре. + Приложение улучшает конфиденциальность, используя разных операторов в каждом разговоре. Когда больше чем один оператор включен, ни один из них не видит метаданные, чтобы определить, кто соединен с кем. Ошибка сохранения серверов Условия будут приняты для включенных операторов через 30 дней. Ошибка приёма условий Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети. Чтобы защитить Вашу ссылку от замены, Вы можете сравнить код безопасности. - Например, если ваш контакт получает сообщения через сервер SimpleX Chat, ваше приложение будет доставлять их через сервер Flux. - Прямые сообщения между участниками запрещены в этом разговоре. + Например, если Ваш контакт получает сообщения через сервер SimpleX Chat, Ваше приложение доставит их через сервер Flux. + Прямые сообщения между членами группы запрещены. Группы Удалить Удалить список? @@ -2333,30 +2328,30 @@ Избранное запрошено соединение Редактировать - Предприятия + Бизнесы Включить журналы - О операторах + Об операторах Ошибка при сохранении базы данных Соединение не готово. Ошибка обновления списка чата Ошибка создания списка чатов Список - Никаких чатов в списке %s. - Без непрочитанных чатов - Никаких чатов + Нет чатов в списке %s. + Нет непрочитанных чатов + Нет чатов Чаты не найдены - Все чаты будут удалены из списка %s, а сам список удален + Все чаты будут удалены из списка %s, а сам список удалён Добавить список - Примечания + Заметки Открыть в %s Создать список Добавить в список Изменить список Сохранить список Имя списка... - Исправить соединение? + Починить соединение? Соединение требует повторного согласования шифрования. - Исправление + Починить Выполняется повторное согласование шифрования. принятое приглашение Ошибка при загрузке списков чатов @@ -2365,25 +2360,25 @@ Пожаловаться Спам Пожаловаться на спам: увидят только модераторы группы. - Это действие не может быть отмененено - сообщения, отправленные и полученные в этом чате ранее чем выбранное, будут удалены - Получайте уведомления от упоминаний. + Это действие нельзя отменить - сообщения в этом чате, отправленные или полученные раньше чем выбрано, будут удалены. + Уведомления, когда Вас упомянули. Сообщения о нарушениях запрещены в этой группе. Пожаловаться на нарушение: увидят только модераторы группы. - Установить имя чата… + Имя чата… Улучшенная производительность групп - Приватные названия медиафайлов. + Конфиденциальные названия медиафайлов. Спам Сообщения о нарушениях Непрочитанные упоминания Да Упоминайте членов группы 👋 - Улучшенная приватность и безопасность + Улучшенная конфиденциальность и безопасность Ускорено удаление групп. Ускорена отправка сообщений. - Помогайте администраторам модерировать их группы. + Помогайте админам модерировать их группы. Организуйте чаты в списки Вы можете сообщить о нарушениях - Установите время исчезания сообщений в чатах. + Установите срок хранения сообщений в чатах. Вы можете упомянуть до %1$s пользователей в одном сообщении! Причина сообщения? Эта жалоба будет архивирована для вас. @@ -2392,7 +2387,7 @@ Ошибка чтения пароля базы данных сообщение о нарушении заархивировано %s Нарушение правил группы - Неприемлемое сообщение + Неприемлемый контент Другая причина Неприемлемый профиль %d сообщений о нарушениях @@ -2419,14 +2414,14 @@ Не пропустите важные сообщения. Ошибка сохранения настроек заархивированное сообщение о нарушении - архивировать + Архивировать Архивировать сообщение о нарушении? Пароль не может быть прочитан из Keystore. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. Пароль не может быть прочитан из Keystore, пожалуйста, введите его. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. модератор ожидает утверждения ожидает - Обновленные условия + Обновлённые условия Запретить жаловаться модераторам группы. Члены группы могут пожаловаться модераторам. Сообщения в этом чате никогда не будут удалены. @@ -2435,7 +2430,7 @@ Пожаловаться на профиль: увидят только модераторы группы. Сообщения о нарушениях Пожаловаться: увидят только модераторы группы. - Выключить уведомления для всех + Все без звука Использовать TCP-порт %1$s, когда порт не указан. Использовать TCP-порт 443 только для серверов по умолчанию. Все серверы @@ -2460,18 +2455,17 @@ модераторы Удалить членов группы? Принять - Используя SimpleX Chat, Вы согласны:\n- отправлять только законные сообщения в публичных группах.\n- уважать других пользователей – не отправлять спам. - Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. - Настроить операторов серверов + Вы обязуетесь:\n- Только законный контент в публичных группах\n- Уважать других пользователей - без спама + Операторы обязуются:\n- Быть независимыми\n- Минимизировать использование метаданных\n- Использовать проверенный и открытый исходный код Политика конфиденциальности и условия использования. все Принять Член группы хочет присоединиться. Принять? группа удалена - удален из группы + удалён из группы %d чата(ов) контакт не готов - контакт удален + контакт удалён не синхронизирован запрос на вступление отклонён Новый член группы хочет присоединиться. @@ -2479,27 +2473,27 @@ ожидает одобрения Отклонить Отклонить члена группы? - Ошибка при удалении чата + Ошибка при удалении чата с членом группы Полная ссылка - Ошибка при вступлении члена группы + Ошибка вступления члена группы Ссылка не поддерживается Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку. %d сообщений Вы можете найти Ваши жалобы в Чате с админами. Чат с админами Чат с членом группы - выключено + нет Одобрять членов группы Чаты с членами группы Приём членов в группу - Одобрять членов для вступления в группу. + Вручную одобрять членов для вступления в группу. Нет чатов с членами группы Принять как читателя Принять в группу Принять члена группы одобрен админами Жалоба отправлена модераторам - Вы вышли + Вы покинули группу нельзя отправлять %d чатов с членами группы контакт выключен @@ -2509,7 +2503,7 @@ Сохранить настройки вступления? Вы приняли этого члена группы рассмотрение - Установить вступление в группу + Приём членов в группу Удалить чат с членом группы? Удалить разговор принят %1$s @@ -2522,7 +2516,7 @@ Добавить сообщение О себе: Нельзя поменять профиль - end-to-end шифрованием.]]> + сквозным шифрованием.]]> только после того как Ваш запрос будет принят.]]> Чат с админами Общайтесь с членами группы до того как принять их. @@ -2548,8 +2542,8 @@ Таймаут конфиденциальной доставки Адрес будет коротким, и Ваш профиль будет добавлен в адрес. Фоновый таймаут протокола - Отклонить запрос на соединение - Может удалять сообщения и блокировать членов группы. + Отклонить запрос + Может удалять сообщения и блокировать членов. запрос отправлен Одобрять членов группы Отправить запрос на соединение? @@ -2560,19 +2554,19 @@ Обновить ссылку группы? Обновить Обновить адрес? - Цель: + Описание: Фоновый таймаут TCP-соединения Отправитель не будет уведомлён. Член группы удалён - невозможно принять запрос Чтобы использовать другой профиль после попытки соединения, удалите чат и используйте ссылку снова. Приветственное сообщение - О Вас: + О себе: Ваш профиль Описание слишком длинное Использовать профиль инкогнито 4 новых языков интерфейса Принять запрос на соединение - Бизнес контакт + Бизнес-контакт Каталонский, Индонезийский, Румынский и Вьетнамский - благодаря нашим пользователям! Создайте Ваш адрес Описание слишком длинное @@ -2592,7 +2586,7 @@ Обновите Ваш адрес Обновить ссылку группы Приветствуйте Ваши контакты 👋 - Ваш бизнес контакт + Ваш бизнес-контакт Ваш контакт Ваша группа Разрешить файлы и медиа, только если их разрешает Ваш контакт. @@ -2604,7 +2598,7 @@ Только Ваш контакт может отправлять файлы и медиа. Откройте чтобы использовать бот Запретить отправлять файлы и медиа. - Нажмите Соединиться, чтобы использовать бот. + Нажмите Соединиться, чтобы использовать бот Вы должны быть соединены, чтобы отправлять команды. Удалённые настройки Открыть очищенную ссылку @@ -2613,6 +2607,271 @@ Ошибка прочтения чата Хэш в адресе пересылающего сервера не соответствует сертификату: %1$s. Хэш в адресе сервера не соответствует сертификату: %1$s. - Ссылка SimpleX relay + Адрес релея SimpleX Хэш в адресе сервера назначения не соответствует сертификату: %1$s. + Удалить сообщения члена группы + Удалить сообщения члена группы? + Удалить сообщения + Сообщения члена группы будут удалены - это нельзя отменить! + нет подписки + Вы не подключены к серверу, через который Вы получали сообщения от этого контакта (нет подписки). + Удалить вместе с сообщениями + Все сообщения + Файлы + Фильтр + Изображения + Ссылки + Поиск файлов + Поиск изображений + Поиск ссылок + Поиск видео + Поиск голосовых сообщений + Видео + Голосовые сообщения + %1$d подписчик + %1$d подписчиков + Отменить создание канала? + Это ваша ссылка на канал %1$s! + канал + Канал + Канал + Полное имя канала: + Ссылка канала + Участники канала + Имя канала + профиль канала обновлён + Канал будет удалён для всех подписчиков - это нельзя отменить! + Канал будет удалён для Вас - это нельзя отменить! + Настроить релеи + Соединиться + соединен(а) + соединяется + Создать публичный канал + Создать публичный канал + Создать публичный канал (БЕТА) + Создание канала + %d событий канала + Удалить канал + Удалить канал? + Удалить релей + Редактировать профиль канала + Введите имя релея… + Ошибка добавления релея + Ошибка при создании канала + Ошибка при открытии канала + ошибка: %s + Ошибка при сохранении профиля канала + Неверный адрес релея! + Неверное имя релея! + приглашен(а) + Войти в канал + Покинуть канал + Выйти из канала? + Ссылка + %1$d/%2$d релеев активны + %1$d/%2$d релеев подключены + %1$d/%2$d релеев подключены, %3$d с ошибками + принят(а) + активный + Заблокировать подписчика для всех? + Опубликовать + Канал начнёт работу с %1$d из %2$d релеев. Продолжить? + Чат-релей + Чат-релеи + Чат-релеи + Чат-релеи + Чат-релеи пересылают сообщения в Ваших каналах. + Чат-релеи пересылают сообщения подписчикам каналов. + Проверьте адрес релея и попробуйте снова. + Проверьте имя релея и попробуйте снова. + удалено + Предпочтения канала + Ссылка канала + Профиль канала хранится на устройствах подписчиков и на чат-релеях. + Канал временно недоступен + Выключить + Включить + Ошибка + Бизнес-адрес + нельзя публиковать + Включите хотя бы один релей чатов для создания канала. + Новый чат-релей + Нет чат-релеев + Чат-релеи не включены. + Это адрес чат-релея, с ним нельзя соединиться. + %1$d/%2$d релеев активны, %3$d с ошибками + %1$d/%2$d релеев активны, %3$d с ошибками + %1$d/%2$d релеев активны, %3$d удалены + %1$d/%2$d релеев подключены, %3$d с ошибками + %1$d/%2$d релеев подключены, %3$d удалены + %1$d релеев не работает + %1$d релеев неактивно + %1$d релеев удалено + Нет активных релеев + У канала нет активных релеев. Попробуйте подключиться позже. + Включить картинки ссылок? + Каналы + удалил(а) канал + Адрес контакта + Все релеи удалены + неактивен + Подпись ссылки проверена. + Открыть канал + Открыть новый канал + Владельцы + ВЛАДЕЛЕЦ + релей + РЕЛЕЙ + Адрес релея + Адрес релея + Ошибка подключения релея + Ссылка релея + Результаты релея: + удалено оператором + Удалить подписчика + Удалить подписчика? + Сохранить и уведомить подписчиков канала + Сохранить профиль канала + Отправка картинки ссылки может раскрыть Ваш IP-адрес веб-сайту. Вы можете изменить это в настройках безопасности позже. + Отправьте ссылку через любой мессенджер - это безопасно. Попросите вставить её в SimpleX. + Для подключения к релею требуется авторизация, проверьте пароль. + Предупреждение сервера + Поделиться каналом… + Поделиться адресом релея + Поделиться в чате + ⚠️ Ошибка проверки подписи: %s. + ПОДПИСЧИК + Подписчики + Подписчик будет удалён из канала - это нельзя отменить! + Начните разговор + Нажмите Войти в канал + Нажмите, чтобы открыть + Вы можете поделиться ссылкой или QR-кодом - любой сможет вступить в канал. + Изменить настройки канала могут только владельцы канала. + Одноразовая ссылка + Вы родились без аккаунта. + Никто не отслеживал ваши разговоры. Никто не составлял карту ваших перемещений. Конфиденциальность не была функцией - это был образ жизни. + Потом мы вышли в интернет, и каждая платформа попросила частичку вас - ваше имя, ваш номер, ваших друзей. Мы смирились с тем, что за возможность общаться приходится отдавать информацию о том, с кем мы общаемся. Каждое поколение людей и технологий жило так - телефон, электронная почта, мессенджеры, социальные сети. Казалось, что другого пути нет. + Другой путь есть. Сеть без номеров телефонов. Без имён пользователей. Без аккаунтов. Без каких-либо идентификаторов пользователей. Сеть, которая соединяет людей и передаёт зашифрованные сообщения, не зная, кто с кем связан. + Не более надёжный замок на чужой двери. Не более вежливый хозяин, который уважает вашу частную жизнь, но всё равно ведёт учёт всех посетителей. Вы не гость. Вы у себя дома. Ни один король не войдёт в ваш дом - вы суверенны. + Ваши разговоры принадлежат вам, как это всегда было до интернета. Сеть - это не место, куда вы приходите. Это место, которое вы создаёте и которым владеете. И никто не может это у вас отнять, делаете ли вы его конфиденциальным или публичным. + Древнейшая человеческая свобода - говорить с другим человеком без слежки - построенная на инфраструктуре, которая не может её предать. + Потому что мы разрушили саму возможность узнать, кто вы. Чтобы вашу свободу невозможно было отнять. + Будь свободен в своей сети. + Запись голосовых сообщений не поддерживается на вашей платформе + не защищены сквозным шифрованием. Чат-релеи могут видеть эти сообщения.]]> + Дайте собеседнику Вашу ссылку + Соединитесь по ссылке или QR + Создайте Вашу ссылку + Пригласите конфиденциально + Ссылка для одного человека + Создайте Ваш публичный адрес + Ваш публичный адрес + Любой может связаться с Вами + Ваш канал + Ссылка группы + (от владельца) + (с подписью) + Ошибка при публикации канала + Вы подписчик + Новая одноразовая ссылка + Или покажите QR лично или через видеозвонок. + Используйте этот адрес в профиле социальных сетей, на сайте или в подписи email. + Или используйте этот QR - распечатайте или покажите онлайн. + Будь свободен\nв своей сети + Конфиденциальный и безопасный обмен сообщениями. + Первая сеть, в которой Вы владеете\nсвоими контактами и группами. + Начать + Зачем создан SimpleX. + Ваш профиль + На Вашем телефоне, не на серверах. + Без аккаунта. Без номера. Без email. Без ID.\nСамое безопасное шифрование. + Введите имя профиля... + Мигрировать + Ваша сеть + Серверы сети не могут знать,\nкто с кем общается + Настроить серверы + Настроить уведомления + Обязательства сети + Открыть внешнюю ссылку? + удалено (%1$d попыток) + Ошибка сообщения + Приложение удалило это сообщение после %1$d попыток его получить. + Если Вы присоединились к каналам или создали их, они перестанут работать навсегда. + Вы перестанете получать сообщения из этого канала. История чата сохранится. + обновлён профиль канала + ошибка + ОШИБКА СОЕДИНЕНИЯ + Чат с админами + Разрешить членам группы общаться с админами. + Запретить чаты с админами. + Члены группы могут общаться с админами. + Чаты с админами запрещены. + Чаты с админами в публичных каналах не имеют E2E шифрования - используйте только с доверенными чат-релеями. + Включить чаты с админами? + Включить + Сообщения о нарушениях + Разрешить отправку личных сообщений подписчикам. + Запретить отправку личных сообщений подписчикам. + Отправлять до 100 последних сообщений новым подписчикам. + Не отправлять историю новым подписчикам. + Подписчики могут отправлять исчезающие сообщения. + Подписчики могут отправлять личные сообщения. + Прямые сообщения между подписчиками запрещены. + Подписчики могут необратимо удалять отправленные сообщения. (24 часа) + Подписчики могут добавлять реакции на сообщения. + Подписчики могут отправлять голосовые сообщения. + Подписчики могут отправлять файлы и медиа. + Подписчики могут отправлять ссылки SimpleX. + Подписчики могут отправлять сообщения о нарушениях модераторам. + До 100 последних сообщений отправляется новым подписчикам. + История не отправляется новым подписчикам. + Разрешить подписчикам общаться с админами. + Подписчики могут общаться с админами. + Чаты с членами группы отключены + Публичные каналы - говорите свободно 🚀 + Надёжность: несколько релеев на каждый канал. + Владение: Вы можете запустить свои собственные релеи. + Безопасность: владельцы хранят ключи канала. + Конфиденциальность: для владельцев и подписчиков. + Проще пригласить друзей 👋 + Мы упростили подключение для новых пользователей. + Безопасные веб-ссылки + - включение картинок ссылок.\n- использовать SOCKS-прокси, если включен\n- защита от фишинга.\n- удаление трекинга ссылок. + Некоммерческое управление + Чтобы сохранить сеть SimpleX для всех. + Вы + Имя релея по умолчанию + Адрес релея по умолчанию + Ваше имя релея + Ваш адрес релея + Использовать релей + Тест релея + Использовать для новых каналов + Протестируйте релей, чтобы получить его имя.]]> + Тест релея не пройден! + Получить ссылку + Расшифровать ссылку + Ожидание ответа + Проверить + Ошибка теста на шаге %s. + ошибка + новый + Все релеи недоступны + Добавление релеев будет поддерживаться позже. + Ожидает, когда владелец канала добавит релеи. + через %1$s + Подписчики используют ссылку релея для подключения к каналу.\nАдрес релея был использован для настройки этого релея для канала. + Вы подключились к каналу через эту ссылку релея. + Соединение достигло лимита недоставленных сообщений + Ошибка сети + Ваш профиль %1$s будет отправлен чат-релеям и подписчикам канала.\nРелеи могут видеть сообщения канала. + ошибка + Не все релеи подключены + Подождать + Ваш канал + Разблокировать подписчика для всех? + Нижнее меню + Картинка ссылки будет загружена через SOCKS-прокси. DNS-запрос может быть локальным через Ваш резолвер. + Верхнее меню diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml new file mode 100644 index 0000000000..55344e5192 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index b4d854c3d1..c355d8d9fb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -376,7 +376,6 @@ วิธีใช้มาร์กดาวน์ สิ้นสุดลงแล้ว มันทำงานอย่างไร - วิธีการ SimpleX ทํางานอย่างไร กระจายอำนาจแล้ว การโทรเสียงแบบ encrypted จากต้นจนจบ การโทรวิดีแบบ encrypted จากต้นจนจบ @@ -909,7 +908,7 @@ แสดง: แสดงตัวเลือกสําหรับนักพัฒนาซอฟต์แวร์ แชร์ลิงก์ - แชร์ที่อยู่กับผู้ติดต่อ\? + แชร์ที่อยู่กับผู้ติดต่อ? แชร์กับผู้ติดต่อ บันทึกการตั้งค่า\? แสดง @@ -1043,7 +1042,7 @@ คุณได้รับเชิญให้เข้าร่วมกลุ่ม โปรไฟล์แบบสุ่มของคุณ ธีม - กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %1$s) + กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %1$s) SimpleX คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ โปรไฟล์ของคุณจะถูกส่งไปยังผู้ติดต่อที่ส่งลิงก์นี้มาให้คุณ @@ -1328,4 +1327,4 @@ ในการตอบกลับถึง ไม่มีประวัติ encryptionใช้ได้ - + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index ec8af9053e..0e9c54fb87 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -611,7 +611,6 @@ Gizle Konuşulan kişileri ve mesajları gizle Uygulamayı, son kullanılanlar kısmından gizle. - SimpleX nasıl çalışıyor bir görüntülü aramada karşıdakine karekodunu gösterebilir ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin.]]> bir görüntülü aramada karşıdakinin karekodunu okutabilirsin ya da konuştuğun kişi seninle bir katılım bağlantısı paylaşabilir.]]> Eğer yüz yüze görüşemiyorsanız bir görüntülü aramada karşıdakine karekodunu gösterebilir ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin. @@ -836,7 +835,7 @@ Mesaj tepkileri Tercihleriniz Mesaj tepkileri yasaklıdır. - Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız. + Bu bağlantıdan gelen mesajları almak için kullanılan sunucuya bağlısınız. Zaten %1$s e bağlısınız Doğrulanamadınız; lütfen tekrar deneyin. SimpleX Kilidini Ayarlar üzerinden açabilirsiniz. @@ -938,7 +937,7 @@ kapalı açık Kilit modunu değiştir - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor. + Bu bağlantıdan gelen mesajları almak için kullanılan sunucuya bağlanmayı dene. Lütfen doğru bağlantıyı kullandığınızı kontrol edin veya irtibat kişinizden size başka bir bağlantı göndermesini isteyin. SimpleX arka planda çalışır.]]> Periyodik bildirimler @@ -1023,7 +1022,7 @@ Sohbet profillerini parola ile koru! Daha az pil kullanımı Gizliliği korumak için, SimpleX her bir konuşma için farklı bir ID kullanır. - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). + Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). Alıcılar güncellemeleri siz yazdıkça görürler. Bilgilerinizi kullanarak giriş yapın Mesajlarda Markdown @@ -2055,7 +2054,6 @@ Sunucu istatistikleri sıfırlanacaktır - bu geri alınamaz! Erişilebilir uygulama araç çubukları Görünüm ayarlarından değiştirebilirsiniz. - Sohbet listesini değiştir: Sistem modu Erişilebilir sohbet araç çubuğu İçin bilgi gösteriliyor @@ -2270,7 +2268,6 @@ Arkaplan servisi yok Kabul Et SimpleX Chat\'i kullanarak şunları kabul etmiş olursunuz:\n- genel gruplarda sadece yasal içerik göndermeyi.\n- diğer kullanıcılara saygı göstermeyi - spam yapmamayı. - Sunucu operatörlerini yapılandırma Sohbetten çıkılsın mı? yönetici Bütün sunucular @@ -2509,4 +2506,7 @@ Komutlar gönderebilmek için bağlanmanış olmanız gereklidir. Üye silinmiş - isteği kabul edemeyecek Grup linkini güncelle + Abonelik yok + Bu bağlantıdan mesaj almak için kullanılan sunucuya bağlı değilsiniz (abonelik yok). + SimpleX Relay Linki diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index ddd54b717f..4e62631dbb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -124,7 +124,7 @@ помилка підключення Ви підключені до сервера для отримання повідомлень від цього контакту. - Спроба підключитися до сервера для отримання повідомлень від цього контакту (помилка: %1$s). + Спроба підключитися до сервера для отримання повідомлень від цього контакту (помилка: %1$s). видалено Спроба підключитися до сервера для отримання повідомлень від цього контакту. відзначено як видалено @@ -407,7 +407,6 @@ очікування підтвердження… Приватність перевизначена Ви вирішуєте, хто може під\'єднатися. - Як працює SimpleX зашифрований e2e аудіовиклик Відкрийте SimpleX Chat для прийняття виклику e2e зашифровано @@ -1887,7 +1886,6 @@ Нове повідомлення Створити Запросити - Перемикнути список чатів: Ви можете змінити це в налаштуваннях зовнішнього вигляду. Статус повідомлення Архівувати контакти, щоб поговорити пізніше. @@ -2376,7 +2374,6 @@ Приватні чати, групи та ваші контакти недоступні для операторів сервера. Прийняти Використовуючи SimpleX Chat, ви погоджуєтесь на:\n- надсилати тільки легальний контент у публічних групах.\n- поважати інших користувачів – без спаму. - Налаштувати операторів сервера Політика конфіденційності та умови використання Це посилання вимагає новішої версії додатку. Будь ласка, оновіть додаток або попросіть вашого контакту надіслати сумісне посилання. Повне посилання @@ -2512,4 +2509,23 @@ Ваш контакт Ваша група Ваш профіль + Дозволяйте файли та медіа лише за умови, що ваш контакт їх дозволяє. + Дозвольте своїм контактам надсилати файли та медіа. + Бот + Ви, і ваш контакт можете надсилати файли та медіа. + ЗАПИТИ НА ЗВ’ЯЗОК ВІД ГРУП + Застарілі опції + Помилка при відмітці як прочитане + Файли та медіа заборонені у цьому чаті. + Відбиток у адресі сервера переадресації не співпадає з сертифікатом: %1$s. + Відбиток у адресі сервера не співпадає з сертифікатом: %1$s. + Користувача видалено - не може прийняти запит. + Тільки ви можете надсилати файли та медіа. + Лише ваш контакт може надсилати файли та медіа. + Відкрити чисте посилання + Відкрити повне посилання + Відкрити для використання бота + Заборонити надсилання файлів і медіа. + Видалити відстеження посилань + запит на підключення до групи %1$s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index b71350ea50..235158585d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -880,7 +880,6 @@ Hồ sơ trò chuyện ẩn Cách sử dụng Cách thức hoạt động - Cách thức SimpleX hoạt động Cách làm Lỗi khởi động WebView. Hãy đảm bảo bạn đã cài đặt WebView và kiến trúc hỗ trợ của nó là arm64.\nLỗi: %s giờ @@ -1983,7 +1982,6 @@ Tin nhắn này đã bị xóa hoặc vẫn chưa được nhận. Mã QR này không phải là một đường dẫn! Đường dẫn này không phải là một đường dẫn kết nối hợp lệ! - Chuyển đổi danh sách trò chuyện: Thời gian chờ đã hết trong khi kết nối tới máy tính Để cho phép một ứng dụng di động kết nối tới máy tính, mở cổng này trong tường lửa của bạn, nếu bạn có bật nó lên Để bảo vệ sự riêng tư của bạn, SimpleX sử dụng các ID riêng biệt cho mỗi liên hệ bạn có. @@ -2034,7 +2032,7 @@ Bật Tổng không xác định - Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. Để xác minh mã hóa đầu cuối với liên hệ của bạn, so sánh (hoặc quét) mã trên các thiết bị của các bạn. Cách ly truyền tải @@ -2351,7 +2349,6 @@ Bằng việc sử dụng SimpleX Chat, bạn đồng ý:\n- chỉ gửi nội dung hợp pháp trong các nhóm công khai.\n- tôn trọng những người dùng khác - không gửi tin rác. Các cuộc trò chuyện riêng tư, nhóm và liên hệ của bạn không thể truy cập được đối với các bên vận hành máy chủ. Chấp nhận - Định cấu hình các bên vận hành máy chủ Đường dẫn này yêu cầu một phiên bản ứng dụng mới hơn. Vui lòng nâng cấp ứng dụng hoặc yêu cầu liên hệ của một gửi cho một đường dẫn tương thích. Đường dẫn kênh SimpleX Đường dẫn kết nối không được hỗ trợ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index d6686f358d..1392d7b42b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -575,7 +575,7 @@ 更新网络设置? 只有你可以不可逆地删除消息(你的联系人可以将它们标记为删除)。(24小时) 重新启动应用程序以创建新的聊天资料。 - 服务器需要授权才能创建队列,检查密码 + 服务器需要授权才能创建队列,检查密码。 测试在步骤 %s 失败。 你已经有一个显示名相同的聊天资料。请选择另一个名字。 已发送 @@ -589,8 +589,8 @@ 已删除 %1$s 你删除了 %1$s 你的个人资料将发送给你收到此链接的联系人。 - 正在尝试连接到用于从该联系人接收消息的服务器(错误:%1$s)。 - 你已连接到用于接收该联系人消息的服务器。 + 正在尝试连接到用于从该联系人接收消息的服务器(错误:%1$s)。 + 你已连接到用于接收该连接消息的服务器。 你分享了一次性链接 很可能此联系人已经删除了与你的联系。 资料图片占位符 @@ -609,7 +609,7 @@ 你当前的聊天数据库将被删除并替换为导入的数据库。 \n此操作无法撤消——你的个人资料、联系人、消息和文件将不可逆地丢失。 已邀请 %1$s 保存群资料 - 服务器地址中的证书指纹可能不正确 + 服务器地址指纹和证书不匹配。 请使用 %1$s 检查你的网络连接,然后重试。 多个聊天资料 数据库不能正常工作。点击了解更多 @@ -618,7 +618,6 @@ %d 小时 %d 月 %d 秒 - SimpleX 是如何工作的 确保 WebRTC ICE 服务器地址格式正确、每行分开且不重复。 确保 SMP 服务器地址格式正确、每行分开且不重复。 Markdown 帮助 @@ -777,7 +776,7 @@ 给我们发电子邮件 SMP 服务器 尚不支持发送文件 - 正在尝试连接到用于从该联系人接收消息的服务器。 + 尝试连接到用于从该连接接收消息的服务器。 尚不支持接收文件 未知消息格式 测试服务器 @@ -1004,7 +1003,7 @@ 视频已发送 要求接收视频 视频将在你的联系人完成上传后收到。 - 服务器需要授权来上传,检查密码 + 服务器需要授权来上传,检查密码。 上传文件 XFTP 服务器 你的 XFTP 服务器 @@ -1207,7 +1206,7 @@ 消息回应 该聊天禁用了消息回应。 如果你在打开应用程序时输入自毁密码: - 个人资料更新将被发送给你的联系人。 + 个人资料更新将发送给你的联系人。 记录更新于 禁止消息回应。 已收到于 @@ -1350,7 +1349,7 @@ 为所有人启用 需要重新协商加密 已关闭送达回执! - 请注意:消息和文件中继通过 SOCKS 代理连接。呼叫和发送链接预览使用直接连接。]]> + 请注意:消息和文件中继通过 SOCKS 代理连接。通话使用直接连接。]]> 加密本地文件 为存储的文件和媒体加密 全新桌面应用! @@ -1848,7 +1847,7 @@ 尚无直接连接,消息由管理员转发。 其他 SMP 服务器 其他 XFTP 服务器 - 扫描/粘贴链接 + 粘贴链接/扫描 显示百分比 不活跃 缩放 @@ -2012,7 +2011,6 @@ 连接和服务器状态。 最多同时删除 20 条消息 它保护你的 IP 地址和连接。 - 切换聊天列表: 你可以在“外观”设置中更改它。 保存并重新连接 TCP 连接 @@ -2208,12 +2206,12 @@ 离开聊天? 你将停止从这个聊天收到消息。聊天历史将被保留。 邀请加入聊天 - 将为你删除聊天 - 此操作无法撤销! + 将为你删除聊天 —— 此操作无法撤销! 删除聊天 删除聊天? 添加好友 添加团队成员 - 将为所有成员删除聊天 - 此操作无法撤销! + 将为所有成员删除聊天 —— 此操作无法撤销! 仅聊天所有人可更改首选项。 角色将被更改为 %s。聊天中的每个人都会收到通知。 成员之间的私信被禁止。 @@ -2362,9 +2360,8 @@ 将从聊天中移除这些成员 — 此操作无法撤销! 隐私政策和使用条款。 接受 - 使用 SimpleX Chat 代表您同意:\n- 在公开群中只发送合法内容\n- 尊重其他用户 – 没有垃圾信息。 - 服务器运营方无法访问私密聊天、群和你的联系人。 - 配置服务器运营方 + 您承诺:\n- 在公开群中只发送合法内容\n- 尊重其他用户—无垃圾信息 + 运营者承诺:\n- 独立\n- 最小化元数据使用\n- 运行经验证的开源代码 不支持的连接链接 SimpleX 频道链接 短链接 @@ -2409,7 +2406,7 @@ 和一名成员的一个聊天 无法发送消息 你离开了 - 删除和成员的聊天出错 + 删除聊天出错 你无法发送消息! 禁用了联系人 群被删除了 @@ -2518,6 +2515,274 @@ 打开干净链接 打开完整链接 删除链接跟踪 - SimpleX 中继链接 - 将和成员的聊天标记为已读时出错 + SimpleX 中继地址 + 标记为已读时出错 + 目标服务器地址的指纹和证书不匹配:%1$s。 + 转发服务器地址的指纹和证书不匹配:%1$s。 + 服务器地址证书和证书不匹配:%1$s。 + 无订阅 + 未连接到用于从该连接接收消息的服务器(无订阅)。 + 删除成员消息 + 删除成员消息吗? + 删除消息 + 成员消息将被删除 - 这无法撤销! + 移除并删除消息 + 所有消息 + 文件 + 筛选器 + 图片 + 链接 + 搜索文件 + 搜索图片 + 搜索链接 + 搜索视频 + 搜索语音消息 + 视频 + 语音消息 + 连接失败 + 失败 + 如果你加入了或创建了频道,它们会永远停止工作。 + 订阅者使用中继链接连接到频道。\n中继地址用于为频道设置这个中继。 + %1$d 个中继活跃,共 %2$d 个 + %1$d 个中继活跃,共 %2$d 个,%3$d 个失灵 + %1$d 个中继已连接,共 %2$d 个 + %1$d 个中继已连接,共 %2$d 个,%3$d 个出错 + %1$d 位订阅者 + %1$d 位订阅者 + 已接受 + 活跃 + 为所有人拦截订阅者? + 广播 + 取消创建频道? + 测试中继 来获取其名称。]]> + %1$s 频道的链接!]]> + 频道 + 频道 + 频道 + 频道链接 + 频道成员 + 频道名 + 将为所有订阅者删除频道 —— 此操作无法撤销! + 将为你删除频道 —— 此操作无法撤销! + 频道将开始用 %2$d 个中继中的 %1$d 个中继运作。要继续吗? + 聊天中继 + 聊天中继 + 聊天中继 + 聊天中继 + 聊天中继在你创建的频道中转发消息。 + 聊天中继转发消息给频道订阅者。 + 检查中继地址并重试。 + 检查中继名并重试。 + 配置中继 + 连接 + 已连接 + 正在连接 + 创建公开频道 + 创建公开频道 + 创建公开频道(测试版) + 正在创建频道 + 解码链接 + 删除频道 + 删除频道吗? + 已删除 + 删除中继 + 编辑频道简介 + 要创建频道至少启用一个聊天中继。 + 输入中继名… + 添加中继出错 + 创建频道出错 + 打开频道出错 + 失败 + 失败 + 获取链接 + 无效的中继地址! + 无效的中继名! + 已邀请 + 加入频道 + 离开频道 + 离开频道? + 链接 + + 新聊天中继 + 无聊天中继 + 未启用聊天中继。 + 不是所有中继均已连接 + 打开频道 + 打开新频道 + 所有者 + 所有者 + 预设中继地址 + 预设中继名 + 中继 + 中继 + 中继地址 + 中继地址 + 中继连接失败 + 中继链接 + 中继测试失败! + 删除订阅者 + 删除订阅者? + 服务器需要身份认证来连接到中继,检查密码。 + 服务器警告 + 分享中继地址 + 订阅者 + 订阅者 + 将从频道删除订阅者 —— 此操作无法撤销! + 轻触加入频道 + 测试在第 %s 步失败。 + 测试中继 + 这是聊天中继地址,无法用于连接。 + 为所有人解封订阅者? + 用于新频道 + 使用中继 + 验证 + 通过 %1$s + 你的平台不支持录音 + 等待 + 等待响应 + + 你是订阅者 + 可以分享链接或二维码 —— 任何人均可加入该频道。 + 你通过此中继链接连接至该频道。 + 你的频道 + 你的频道 + 你的个人资料 %1$s 将分享给频道中继和订阅者。中继可以访问频道消息。 + 你的中继地址 + 你的中继名 + 你会停止收到来自该频道的消息。聊天记录将被保留。 + 完整的频道名: + 频道资料存储在订阅者设备和聊天中继上。 + 频道资料已更新 + %d 个频道事件 + 删除了频道 + 被丢弃 (%1$d 次尝试) + 错误: %s + 保存频道资料出错 + 消息错误 + 保存并通知频道订阅者 + 保存频道资料 + 应用在尝试接收这条消息 %1$d 次后删除了它。 + 更新了频道资料 + %2$d 个中继中的 %1$d 个活跃, %3$d 个错误 + %2$d 个中继中的 %1$d 个活跃, %3$d 个被删除 + %2$d 个中继中的 %1$d 个已连接, %3$d 个失灵 + %2$d 个中继中的 %1$d 个已连接, %3$d 个被删除 + %1$d 个中继失灵 + %1$d 个中继不活跃 + 删除了 %1$d 个中继 + 目前不支持添加中继。 + 所有中继均失灵 + 删除了所有中继 + 无法广播 + 频道无活跃中继。请稍后尝试加入。 + 频道暂时不可用 + 不活跃 + 无活跃中继 + 被运营方删除 + 正等到频道所有者添加中继。 + 营业地址 + 频道链接 + 联系地址 + 分享频道出错 + (来自所有者) + 群链接 + 链接签名已验证。 + 一次性链接 + 分享频道… + 经聊天分享 + ⚠️ 签名验证失败:%s。 + (已签名) + 轻触打开 + 发送链接预览可能会将你的 IP 地址暴露给网站。你可以稍后在“隐私”设置中更改此设置。 + 连接达到了未送达消息的上限 + 禁用 + 启用 + 启用链接预览吗? + 错误 + 网络错误 + 中继结果: + 频道首选项 + 仅频道所有者可改变频道首选项。 + 用于单人进行连接的链接 + 频道 + 通过链接或二维码连接 + 创建链接 + 创建你的公开地址 + 邀请好友更简单 👋 + 给任何要和你联系的人 + 私下邀请某人 + 让某人和你连接 + 新建一次性链接 + 非盈利治理 + - 可选发送链接预览\n- 如启用则使用 SOCKS 代理\n- 防止超链接钓鱼\n- 删除链接跟踪。 + 面对面或通过视频通话展示二维码。 + 或使用此二维码 — 打印或在线展示。 + 所有权:你可以运行自己的中继。 + 隐私:对所有者和订阅者。 + 公开频道 — 畅所欲言 🚀 + 可靠性:一个频道众多中继。 + 安全的 web 链接 + 安全性:所有者持有频道密钥。 + 通过任何通讯应用发送链接 — 这是安全的。请求粘贴到 SimpleX 中。 + 和某人交谈 + 让 SimpleX 网络持续。 + 在社交媒体资料、网站或电子邮件签名中使用该地址。 + 我们让连接对新用户更简单。 + 你的公开地址 + 你生来就没有账户。 + 没有人追踪你的谈话内容。没有人绘制你去过的地方的地图。隐私从来都不是一项功能--而是一种生活方式。 + 然后我们转向线上,每个平台都要求你提供一些信息--你的姓名、电话号码、好友列表。我们接受了这样一个事实:与人交流的代价就是让别人知道我们在和谁交流。每一代人,每一代科技,都遵循着这样的模式--电话、电子邮件、即时通讯、社交媒体。这似乎是唯一可行的方式。 + 还有另一种方法。一个没有电话号码、没有用户名、没有账户、没有任何用户身份的网络。一个连接人们并传输加密信息的网络,而无需知道谁连接了。 + 别人家的门锁再好也比不上这里。房东再好也比不上这里,他既尊重你的隐私,又保留着所有访客的记录。你不是客人,你是家。没有国王能闯入--你是主人。 + 你的对话内容始终属于你,就像互联网出现之前一样。网络不是一个你访问的地方,而是一个你创建并拥有的地方。无论你将其设为私密还是公开,任何人都无法将其夺走。 + 人类最古老的自由--与他人交谈而不被监视--建立在不会背叛它的基础设施之上。 + 因为我们摧毁了知道你是谁的权力,因而您的权利永远不会被夺走。 + 在你的网络中自由畅行。 + 允许成员与管理员聊天。 + 允许发送私信给订阅者。 + 允许订阅者与管理员聊天。 + 在您的网络中\n自由驰骋 + 禁止与管理员聊天。 + 与管理员在公开频道中聊天没有端到端加密 — 请只在受信任聊天中继中使用。 + 禁止与成员聊天 + 与管理员聊天 + 禁止订阅者间发送私信。 + 不向新订阅者发送历史记录。 + 允许 + 允许和管理员聊天? + 输入个人资料名… + 开始 + 未发送历史记录给新订阅者。 + 成员可以和管理员聊天。 + 非端到端加密。聊天中继可以看到这些消息。]]> + 迁移 + 网络承诺 + 网络路由器无法\n知道谁和谁交谈 + 无账户。无手机号。无电子邮箱。无 ID。\n最安全的加密。 + 在您的手机上,不在服务器上。 + 打开外部链接? + 私密和安全的消息收发。 + 禁止和管理员聊天。 + 禁止发送私信给订阅者。 + 发送最多 100 条最近消息给新订阅者。 + 通知设置 + 路由器设置 + 订阅者举报 + 订阅者可以添加消息回应。 + 订阅者可以和管理员聊天。 + 订阅者可以不可逆地删除已发送的消息。(24 小时) + 订阅者可以向协管举报消息。 + 订阅者可以发送私信。 + 订阅者可以发送限时消息。 + 订阅者可以发送文件和媒体。 + 订阅者可发送 SimpleX 链接。 + 订阅者可发送语音消息。 + 首个您拥有\n您的联系人和群的网络。 + 已向新订阅者发送了最多 100 条最近的消息。 + 为何打造 SimpleX。 + 您的网络 + 您的个人资料 + 底部栏 + 将通过 SOCKS5 代理请求链接预览。DNS 查询仍可能通过你的 DNS 解析器在本地发生。 + 顶部栏 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index ee0c785e9f..9ec116058a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -238,7 +238,7 @@ 錯誤 連接中 你已連接到此聯絡人使用的伺服器以接收訊息。 - 嘗試連接至用於接收此聯絡人訊息的伺服器 (錯誤:%1$s)。 + 嘗試連接至用於接收此聯絡人訊息的伺服器 (錯誤:%1$s)。 正在嘗試連接到用於接收此聯絡人訊息伺服器。 已刪除 已標記為已刪除 @@ -847,7 +847,6 @@ 感謝用戶 - 使用 Weblate 的翻譯貢獻! 正在修改聯絡地址為 %s … 受加密的資料庫密碼會再次更新和儲存於金鑰庫。 - SimpleX 是怎樣運作 當發生: \n1. 訊息將在傳送至客戶端後兩天或在伺服器內三十天時過時。 \n2. 訊息解密失敗,因為你或你的聯絡人用了舊的資料庫備份 \n3. 連接被破壞。 只有客戶端裝置儲存個人檔案、聯絡人、群組,和訊息。 請放置你的密碼於安全的地方,如果你遺失了密碼,將不可能修改你的密碼。 @@ -1574,7 +1573,7 @@ 桌面設備 已連結桌面選項 已連結桌面 - 直接連線中 + 已請求連接 邀請 建立群組 修復群組成員不支援的問題 @@ -1677,7 +1676,7 @@ 連接中 錯誤 為群組停用回執? - 過往的成員 %1$s + 成員 %1$s 私密訊息路由 🚀 貼上封存連結 從桌面使用並掃描QR code。]]> @@ -1909,7 +1908,7 @@ 你分享了一個無效的檔案路徑。請將此問題報告給應用程式開發者。 如果沒有 Tor 或 VPN,你的 IP 位址將對以下 XFTP 中繼可見:\n%1$s。 檢視已崩潰 - 新增短連結 + 升級地址 接受了 %1$s 接受了你 新增團隊成員 @@ -2061,7 +2060,6 @@ 停用自動刪除訊息? 刪除或審查最多 200 條訊息。 於網路和伺服器設定中啟用 Flux 以獲得更好的元資料隱私。 - 配置伺服器營運者 使用條款 更好的隱私和安全性 你可以再試一次。 @@ -2125,4 +2123,75 @@ 簡化的匿名模式 點擊以連接 從桌面使用 + 開啓新聊天 + 接受聯絡請求 + 接受聯絡請求 + 機械人 + 圖片 + 影片 + 檔案 + 連結 + 過濾器 + %d 個舉報 + 已棄用的選項 + 無訂閱 + 刪除訊息 + 搜尋圖片 + 搜尋影片 + 搜尋檔案 + 搜尋連結 + 語音訊息 + 所有訊息 + 重複加入請求? + 點擊以掃描 + 顯示內部錯誤 + 標準端對端加密 + 驗證資料庫密碼 + 顯示訊息狀態 + 設定預設主題 + 安全地接收檔案 + 臨時性檔案錯誤 + 重設所有統計 + 重設所有統計? + 有更新可用:%s + 略過此版本 + 更新下載已取消 + 儲存並重新連接 + 重設所有提示 + 自動升級應用程式 + 選擇聊天個人檔案 + 使用隨機憑證 + 儲存代理時發生錯誤 + 轉發訊息時發生錯誤 + 轉發 %1$s 條訊息? + 正在轉發 %1$s 條訊息 + 正在儲存 %1$s 條訊息 + 儲存伺服器時發生錯誤 + 接受條款時發生錯誤 + 公開地分享地址 + 用於社交媒體 + 用於訊息 + 用於私密路由 + 用於檔案 + 更新伺服器時發生錯誤 + 伺服器營運者已變更。 + 增加伺服器時發生錯誤 + 檢視已更新的條款 + 通知和電量 + 邀請加入聊天 + 使用 %s 開啟 + 沒有未讀聊天 + 找不到聊天 + 開啟以加入 + 開啟以連接 + 開啟以使用機械人 + 開啟以接受 + 搜尋或貼上 SimpleX 連結 + 你的每個訊息最多可以提及 %1$s 位成員! + 已透過代理傳送 + 伺服器統計資料將被重設—此操作無法撤銷! + 你沒有連接至這些伺服器。已使用私密路由將訊息傳送至這些伺服器。 + 不能在兩部裝置上使用同一資料庫。]]> + 警告:不支援在多個裝置上同時聊天,否則會導致訊息傳送失敗 + 或匯入封存檔案 diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_channel.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_channel.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_channel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_channel_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_channel_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_channel_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_profile.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_profile.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_profile_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_profile_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_profile_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/intro.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/intro.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/intro.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/intro_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/intro_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/intro_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/network_commitments.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/network_commitments.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/network_commitments.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/network_commitments_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/network_commitments_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/network_commitments_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_network.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_network.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_network.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_network_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_network_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_network_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_profile.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_profile.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_profile_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_profile_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/your_profile_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ChatItemsMergerTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ChatItemsMergerTest.kt index 18b17b25a9..0d5ec24bff 100644 --- a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ChatItemsMergerTest.kt +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ChatItemsMergerTest.kt @@ -18,8 +18,7 @@ class ChatItemsMergerTest { val chatState1 = ActiveChatState(splits = splits1) val removed1 = listOf(oldItems[1]) val newItems1 = oldItems - removed1 - val recalc1 = recalculateChatStatePositions(chatState1) - recalc1.removed(removed1.map { Triple(it.id, oldItems.indexOf(removed1[0]), it.isRcvNew) }, newItems1) + chatState1.itemsRemoved(removed1.map { Triple(it.id, oldItems.indexOf(removed1[0]), it.isRcvNew) }, newItems1) assertEquals(1, splits1.value.size) assertEquals(124L, splits1.value.first()) @@ -27,8 +26,7 @@ class ChatItemsMergerTest { val chatState2 = ActiveChatState(splits = splits2) val removed2 = listOf(oldItems[1], oldItems[2]) val newItems2 = oldItems - removed2 - val recalc2 = recalculateChatStatePositions(chatState2) - recalc2.removed(removed2.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed2[index]), it.isRcvNew) }, newItems2) + chatState2.itemsRemoved(removed2.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed2[index]), it.isRcvNew) }, newItems2) assertEquals(1, splits2.value.size) assertEquals(125L, splits2.value.first()) @@ -36,14 +34,12 @@ class ChatItemsMergerTest { val chatState3 = ActiveChatState(splits = splits3) val removed3 = listOf(oldItems[1], oldItems[2], oldItems[3]) val newItems3 = oldItems - removed3 - val recalc3 = recalculateChatStatePositions(chatState3) - recalc3.removed(removed3.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed3[index]), it.isRcvNew) }, newItems3) + chatState3.itemsRemoved(removed3.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed3[index]), it.isRcvNew) }, newItems3) assertEquals(0, splits3.value.size) val splits4 = MutableStateFlow(listOf(123L)) val chatState4 = ActiveChatState(splits = splits4) - val recalc4 = recalculateChatStatePositions(chatState4) - recalc4.cleared() + chatState4.clear() assertEquals(0, splits4.value.size) } diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ProviderForGalleryTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ProviderForGalleryTest.kt new file mode 100644 index 0000000000..f9311ea6a9 --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ProviderForGalleryTest.kt @@ -0,0 +1,67 @@ +package chat.simplex.app + +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.providerForGallery +import kotlinx.datetime.Clock +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +// Regression for PR #6869: scrollToStart() must not rewrite initialChatId. +class ProviderForGalleryTest { + + // Synthetic items pass canShowMedia only when chatModel.connectedToRemote() is true. + @BeforeTest + fun connectChatModelToRemote() { + chatModel.currentRemoteHost.value = RemoteHostInfo( + remoteHostId = 0L, + hostDeviceName = "", + storePath = "", + bindAddress_ = null, + bindPort_ = null, + sessionState = null, + ) + } + + @AfterTest + fun resetChatModel() { + chatModel.currentRemoteHost.value = null + } + + @Test + fun testScrollToStartPreservesAnchor() { + val items = listOf(imageItem(1L), imageItem(2L), imageItem(3L)) + var scrolledTo: Int? = null + val provider = providerForGallery(items, cItemId = 3L) { scrolledTo = it } + + provider.currentPageChanged(provider.initialIndex - 1) + provider.scrollToStart() + provider.onDismiss(0) + + assertEquals(1, scrolledTo) + } + + // Pins the onDismiss early-return contract that testScrollToStartPreservesAnchor + // relies on to read the anchor back through the scrollTo callback. + @Test + fun testOnDismissOnActiveItemDoesNotScroll() { + val items = listOf(imageItem(1L), imageItem(2L), imageItem(3L)) + var scrolledTo: Int? = null + val provider = providerForGallery(items, cItemId = 3L) { scrolledTo = it } + + provider.onDismiss(provider.initialIndex) + + assertEquals(null, scrolledTo) + } + + private fun imageItem(id: Long): ChatItem = + ChatItem( + chatDir = CIDirection.DirectRcv(), + meta = CIMeta.getSample(id, Clock.System.now(), text = ""), + content = CIContent.RcvMsgContent(MsgContent.MCImage(text = "", image = "")), + reactions = emptyList(), + file = CIFile.getSample(fileId = id, fileName = "img-$id.jpg", filePath = "img-$id.jpg"), + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 136a883035..ba8901793f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -23,6 +23,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* +import java.awt.Frame import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener import java.io.File @@ -31,8 +32,11 @@ import kotlin.system.exitProcess val simplexWindowState = SimplexWindowState() fun showApp() { - val closedByError = mutableStateOf(true) - while (closedByError.value) { + // Probe SystemTray off the EDT — the lazy's first read would otherwise block the + // EDT during composition; JDK-8322750's GNOME detection forks a subprocess. + trayIsAvailable + while (true) { + val closedByError = mutableStateOf(false) application(exitProcessOnExit = false) { CompositionLocalProvider( LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window -> @@ -43,8 +47,9 @@ fun showApp() { shareText = true ) Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString()) - window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + // Must precede dispatchEvent — handleCloseRequest reads this flag. closedByError.value = true + window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) includeMoreFailedComposables() // If the left side of screen has open modal, it's probably caused the crash if (ModalManager.start.hasModalsOpen()) { @@ -73,9 +78,11 @@ fun showApp() { } } ) { + SimplexTray() AppWindow(closedByError) } } + if (!closedByError.value) break } exitProcess(0) } @@ -115,7 +122,7 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState) { simplexWindowState.windowState = windowState // Reload all strings in all @Composable's after language change at runtime if (remember { ChatController.appPrefs.appLanguage.state }.value != "") { - Window(state = windowState, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { closedByError.value = false; exitApplication() }, onKeyEvent = { + Window(state = windowState, visible = simplexWindowState.windowVisible.value, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { handleCloseRequest(closedByError) }, onKeyEvent = { if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) { simplexWindowState.backstack.lastOrNull()?.invoke() != null } else { @@ -224,6 +231,41 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState) { } } +// Not invoked for macOS Cmd+Q — that goes through AWT's default QuitHandler and +// exits the process directly. Intentional: Cmd+Q is canonical "always quit" on macOS. +private fun ApplicationScope.handleCloseRequest(closedByError: MutableState) { + // Crash dispatch — bypass user-facing policy and exit; outer loop will restart. + if (closedByError.value) { + exitApplication() + return + } + val pref = ChatController.appPrefs.closeBehavior + when (pref.get()) { + CloseBehavior.Quit -> exitApplication() + CloseBehavior.MinimizeToTray -> if (trayIsAvailable && singleInstanceLock) { + simplexWindowState.windowVisible.value = false + } else exitApplication() + CloseBehavior.Ask -> if (trayIsAvailable && singleInstanceLock) { + requestCloseBehavior() + } else { + // Tray unavailable — Minimize is not a real option; remember Quit and exit. + pref.set(CloseBehavior.Quit) + exitApplication() + } + } +} + +fun showWindow() { + simplexWindowState.windowVisible.value = true + simplexWindowState.window?.apply { + // Clear ICONIFIED so a minimized window un-minimizes; preserves MAXIMIZED_BOTH + // when set. toFront() alone does not un-minimize on any AWT platform. + extendedState = extendedState and Frame.ICONIFIED.inv() + toFront() + requestFocus() + } +} + class SimplexWindowState { lateinit var windowState: WindowState val backstack = mutableStateListOf<() -> Unit>() @@ -232,6 +274,7 @@ class SimplexWindowState { val saveDialog = DialogState() val toasts = mutableStateListOf>() var windowFocused = mutableStateOf(true) + val windowVisible = mutableStateOf(true) var window: ComposeWindow? = null } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt new file mode 100644 index 0000000000..9f75e481f4 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt @@ -0,0 +1,121 @@ +package chat.simplex.common + +import SectionItemView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.window.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.CloseBehavior +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.ui.theme.isInDarkTheme +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.awt.AWTException +import java.awt.SystemTray +import java.awt.TrayIcon +import java.awt.image.BufferedImage + +// Probed once at startup. False on stock GNOME ≥ JDK 21.0.3 per JDK-8322750, and +// also when SystemTray.add() fails despite isSupported() returning true (an older +// JDK pattern Compose-MP does not catch). When false: the Appearance toggle is +// hidden, the first-close dialog is skipped (Ask migrates silently to Quit), and +// the close handler treats MinimizeToTray as Quit. +val trayIsAvailable: Boolean by lazy { + if (!SystemTray.isSupported()) return@lazy false + try { + val tray = SystemTray.getSystemTray() + val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) + tray.add(probe) + tray.remove(probe) + true + } catch (e: AWTException) { + Log.w(TAG, "SystemTray probe failed: ${e.stackTraceToString()}") + false + } catch (e: SecurityException) { + Log.w(TAG, "SystemTray probe denied: ${e.stackTraceToString()}") + false + } +} + +@Composable +fun ApplicationScope.SimplexTray() { + if (!trayIsAvailable) return + if (remember { appPrefs.closeBehavior.state }.value != CloseBehavior.MinimizeToTray) return + // Sum of per-profile unread (UserInfo.unreadCount, the same field UserPicker renders + // per row). Skip muted profiles unless they're the active one. + val unread by remember { + derivedStateOf { + ChatModel.users.sumOf { + if (!it.user.showNtfs && !it.user.activeUser) 0 else it.unreadCount + } + } + } + val iconRes = if (unread > 0) { + if (isInDarkTheme()) MR.images.ic_simplex_tray_dot_light else MR.images.ic_simplex_tray_dot + } else { + if (isInDarkTheme()) MR.images.ic_simplex_tray_light else MR.images.ic_simplex + } + val tooltip = + if (unread > 0) stringResource(MR.strings.tray_tooltip_unread, unread) + else stringResource(MR.strings.tray_tooltip) + Tray( + icon = painterResource(iconRes), + tooltip = tooltip, + onAction = ::showWindow, + menu = { + Item(stringResource(MR.strings.tray_show), onClick = ::showWindow) + Separator() + Item(stringResource(MR.strings.tray_quit), onClick = { exitApplication() }) + } + ) +} + +// Renders in the main app window via AlertManager (same surface as e.g. the link +// previews confirmation). Lambdas close over the calling ApplicationScope; if the +// app crashes while the dialog is open, the crash handler's alert replaces it, so +// stale closures never get clicked. +fun ApplicationScope.requestCloseBehavior() { + val pref = appPrefs.closeBehavior + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.close_behavior_dialog_title), + text = AnnotatedString(generalGetString(MR.strings.close_behavior_dialog_text)), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + pref.set(CloseBehavior.Quit) + exitApplication() + }) { + Text( + stringResource(MR.strings.close_behavior_dialog_close), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = Color.Red + ) + } + SectionItemView({ + AlertManager.shared.hideAlert() + pref.set(CloseBehavior.MinimizeToTray) + simplexWindowState.windowVisible.value = false + }) { + Text( + stringResource(MR.strings.close_behavior_dialog_minimize), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.primary + ) + } + } + } + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt new file mode 100644 index 0000000000..19cb7aea91 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt @@ -0,0 +1,133 @@ +package chat.simplex.common + +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.dataDir +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.nio.file.* +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.READ +import java.nio.file.StandardOpenOption.WRITE +import javax.swing.SwingUtilities +import kotlin.concurrent.thread + +private var lockHandle: FileLock? = null +private var watcher: WatchService? = null + +private val lockPath get() = dataDir.resolve("simplex.started").toPath() +private val showPath get() = dataDir.resolve("simplex.show").toPath() + +var singleInstanceLock = false + private set + +private sealed interface LockResult { + class Acquired(val lock: FileLock) : LockResult + object Taken : LockResult + object Failed : LockResult +} + +fun acquireSingleInstance(): Boolean { + dataDir.mkdirs() + when (val result = tryAcquireLock()) { + is LockResult.Acquired -> { + lockHandle = result.lock + singleInstanceLock = true + deleteShowFile() + startShowFileWatcher() + return true + } + LockResult.Failed -> { + return true + } + LockResult.Taken -> { + // Ensure the signal file exists (createShowFile is a no-op if it does) + // and wait up to 1s for the primary's watcher to consume it. If still + // there after the wait, the primary is hung — let the user decide. + createShowFile() + val deadline = System.currentTimeMillis() + 1000 + while (Files.exists(showPath) && System.currentTimeMillis() < deadline) { + try { Thread.sleep(50) } catch (_: InterruptedException) { break } + } + if (!Files.exists(showPath)) return false + val start = showSingleInstanceAlert() + if (start) deleteShowFile() + return start + } + } +} + +private fun tryAcquireLock(): LockResult { + val channel = try { + FileChannel.open(lockPath, READ, WRITE, CREATE) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot open lock file: ${e.message}") + return LockResult.Failed + } + return try { + val lock = channel.tryLock(0L, 1L, false) + if (lock != null) { + LockResult.Acquired(lock) + } else { + channel.close() + LockResult.Taken + } + } catch (_: OverlappingFileLockException) { + Log.w(TAG, "single-instance: overlapping lock in same JVM") + LockResult.Failed + } catch (e: IOException) { + Log.w(TAG, "single-instance: tryLock failed: ${e.message}") + channel.close(); LockResult.Failed + } +} + +private fun deleteShowFile() { + try { Files.deleteIfExists(showPath) } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete show file: ${e.message}") + } +} + +private fun createShowFile() { + try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) { + // Another duplicate already signalled; primary will pick it up. + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot create show file: ${e.message}") + } +} + +private fun showSingleInstanceAlert(): Boolean { + val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title) + val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding) + val result = javax.swing.JOptionPane.showConfirmDialog( + null, message, title, + javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.WARNING_MESSAGE + ) + return result == javax.swing.JOptionPane.YES_OPTION +} + +private fun startShowFileWatcher() { + if (watcher != null) return + val ws = try { + dataDir.toPath().fileSystem.newWatchService() + } catch (e: IOException) { + Log.w(TAG, "single-instance: WatchService failed: ${e.message}") + return + } + dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE) + watcher = ws + thread(name = "simplex-single-instance", isDaemon = true) { + while (true) { + val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread } + for (event in key.pollEvents()) { + if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") { + deleteShowFile() + SwingUtilities.invokeLater { showWindow() } + } + } + if (!key.reset()) return@thread + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt index 2a1a26df95..e4866c845d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt @@ -3,11 +3,12 @@ package chat.simplex.common import chat.simplex.common.model.json import chat.simplex.common.platform.appPreferences import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.ui.theme.DEFAULT_WINDOW_WIDTH import kotlinx.serialization.* @Serializable data class WindowPositionSize( - val width: Int = 1366, + val width: Int = DEFAULT_WINDOW_WIDTH.value.toInt(), val height: Int = 768, val x: Int = 0, val y: Int = 0, diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index ee00e1649f..0f830e7b60 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -21,9 +21,14 @@ import kotlin.math.sqrt private fun errorBitmap(): ImageBitmap = ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap() +private val base64BitmapCache = Collections.synchronizedMap(object : LinkedHashMap(200, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry): Boolean = size > 200 +}) + private const val MAX_IMAGE_DIMENSION = 4320 actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { + base64BitmapCache[base64ImageString]?.let { return it } val imageString = base64ImageString .removePrefix("data:image/png;base64,") .removePrefix("data:image/jpg;base64,") @@ -40,7 +45,9 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } val image = reader.read(0) reader.dispose() - image.toComposeImageBitmap() + image.toComposeImageBitmap().also { + base64BitmapCache[base64ImageString] = it + } } catch (e: Throwable) { Log.e(TAG, "base64ToBitmap error: $e") errorBitmap() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 41964b7d18..557dabd2e4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -86,8 +86,8 @@ actual fun PlatformTextField( // Different padding here is for a text that is considered RTL with non-RTL locale set globally. // In this case padding from right side should be bigger val startEndPadding = if (cs.message.text.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp - val startPadding = if (isRtlByCharacters && isLtrGlobally) startEndPadding else 0.dp - val endPadding = if (isRtlByCharacters && isLtrGlobally) 0.dp else startEndPadding + val startPadding = 0.dp + val endPadding = startEndPadding val padding = PaddingValues(startPadding, 12.dp, endPadding, 0.dp) var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message.text, selection = cs.message.selection)) } val textFieldValue = textFieldValueState.copy(text = cs.message.text, selection = cs.message.selection) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 9f34891b37..8d26f2f085 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -5,6 +5,7 @@ import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* +import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.component.AudioPlayerComponent @@ -12,20 +13,79 @@ import java.io.File import java.util.* import kotlin.math.max +internal val vlcFactory: MediaPlayerFactory by lazy { MediaPlayerFactory() } +// No hardware acceleration - more secure for previews +internal val vlcPreviewFactory: MediaPlayerFactory by lazy { MediaPlayerFactory("--avcodec-hw=none") } + actual class RecorderNative: RecorderInterface { + private var player: MediaPlayer? = null + private var progressJob: Job? = null + private var filePath: String? = null + private var recStartedAt: Long? = null + override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { - /*LALAL*/ - return "" + VideoPlayerHolder.stopAll() + AudioPlayer.stop() + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir) + fileToSave.deleteOnExit() + val path = fileToSave.absolutePath + filePath = path + val mrl = when { + desktopPlatform.isMac() -> "qtsound://" + desktopPlatform.isLinux() -> "pulse://" + desktopPlatform.isWindows() -> "dshow://" + else -> { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.voice_recording_not_supported)) + return "" + } + } + val sout = ":sout=#transcode{vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000}:std{access=file,mux=mp4,dst=$path}" + val options = mutableListOf(sout, ":sout-avcodec-strict=-2") + if (desktopPlatform.isWindows()) { + options.add(":dshow-vdev=none") + options.add(":dshow-adev=") + } + RecorderInterface.stopRecording = { stop() } + progressJob = CoroutineScope(Dispatchers.Default).launch { + // Shared factory init may take a few seconds on first VLC use — progress shows 0 until recording starts + val p = vlcFactory.mediaPlayers().newMediaPlayer() + player = p + p.media().play(mrl, *options.toTypedArray()) + recStartedAt = System.currentTimeMillis() + while (isActive) { + val ms = progress() + onProgressUpdate(ms, false) + if (ms != null && ms >= MAX_VOICE_MILLIS_FOR_SENDING) { + stop() + break + } + delay(50) + } + }.apply { + invokeOnCompletion { onProgressUpdate(realDuration(path), true) } + } + return path } override fun stop(): Int { - /*LALAL*/ - return 0 + val path = filePath ?: return 0 + RecorderInterface.stopRecording = null + runCatching { player?.controls()?.stop() } + runCatching { player?.release() } + runBlocking { progressJob?.cancelAndJoin() } + progressJob = null + filePath = null + player = null + return (realDuration(path) ?: 0).also { recStartedAt = null } } + + private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() } + + private fun realDuration(path: String): Int? = AudioPlayer.duration(path) ?: progress() } actual object AudioPlayer: AudioPlayerInterface { - private val player by lazy { AudioPlayerComponent().mediaPlayer() } + private val player by lazy { AudioPlayerComponent(vlcFactory).mediaPlayer() } override val currentlyPlaying: MutableState = mutableStateOf(null) private var progressJob: Job? = null @@ -170,7 +230,7 @@ actual object AudioPlayer: AudioPlayerInterface { override fun duration(unencryptedFilePath: String): Int? { var res: Int? = null try { - val helperPlayer = AudioPlayerComponent().mediaPlayer() + val helperPlayer = AudioPlayerComponent(vlcFactory).mediaPlayer() helperPlayer.media().startPaused(unencryptedFilePath) res = helperPlayer.duration helperPlayer.stop() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 50eeaee604..90c80d3b2a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -10,6 +10,7 @@ import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent +import uk.co.caprica.vlcj.player.component.MediaPlayerSpecs import java.awt.Component import java.awt.image.BufferedImage import java.io.File @@ -32,7 +33,7 @@ actual class VideoPlayer actual constructor( override val duration: MutableState = mutableStateOf(defaultDuration) override val preview: MutableState = mutableStateOf(defaultPreview) - val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } } + val mediaPlayerComponent by lazy { getOrCreatePlayer() } val player by lazy { mediaPlayerComponent.mediaPlayer() } init { @@ -207,9 +208,9 @@ actual class VideoPlayer actual constructor( private fun initializeMediaPlayerComponent(): Component { return if (desktopPlatform.isMac()) { - CallbackMediaPlayerComponent() + CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) }) } else { - EmbeddedMediaPlayerComponent() + EmbeddedMediaPlayerComponent(MediaPlayerSpecs.embeddedMediaPlayerSpec().apply { withFactory(vlcFactory) }) } } @@ -224,9 +225,9 @@ actual class VideoPlayer actual constructor( player.media().startPaused(uri.toFile().absolutePath) val start = System.currentTimeMillis() var snap: BufferedImage? = null - while (snap == null && start + 5000 > System.currentTimeMillis()) { + while (snap == null && start + 1500 > System.currentTimeMillis()) { snap = player.snapshots()?.get() - delay(10) + delay(50) } val orientation = player.media().info().videoTracks().firstOrNull()?.orientation() if (orientation == null) { @@ -264,7 +265,9 @@ actual class VideoPlayer actual constructor( mediaPlayer().events().addMediaPlayerEventListener(object: MediaPlayerEventAdapter() { override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) { playerThread.execute { - mediaPlayer?.audio()?.setVolume(100) + // Do not call setVolume here: on Windows VLCJ routes it through WASAPI ISimpleAudioVolume, + // which resets SimpleX Chat's per-app volume in the Windows Volume Mixer on every playback + // (VLCJ issue #985). A fresh VLCJ MediaPlayer already defaults to volume 100, so this was redundant. mediaPlayer?.audio()?.isMute = false } } @@ -277,7 +280,7 @@ actual class VideoPlayer actual constructor( private fun putPlayer(player: Component) = playersPool.add(player) - private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent() + private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcPreviewFactory) }) private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 9be10a584b..20fe6a48a3 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -17,12 +17,14 @@ import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse import org.nanohttpd.protocols.http.response.Status import org.nanohttpd.protocols.websockets.* import java.io.IOException +import java.net.BindException import java.net.URI private const val SERVER_HOST = "localhost" private const val SERVER_PORT = 50395 val connections = ArrayList() +// Spec: spec/services/calls.md#ActiveCallView @Composable actual fun ActiveCallView() { val scope = rememberCoroutineScope() @@ -156,17 +158,18 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( if (call != null) withBGApi { chatModel.callManager.endCall(call) } } val server = remember { - try { - uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") - } catch (e: Exception) { - Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.unable_to_open_browser_title), - text = generalGetString(MR.strings.unable_to_open_browser_desc) - ) - endCall() + startServer(onResponse).apply { + try { + uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") + } catch (e: Exception) { + Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.unable_to_open_browser_title), + text = generalGetString(MR.strings.unable_to_open_browser_desc) + ) + endCall() + } } - startServer(onResponse) } fun processCommand(cmd: WCallCommand) { val apiCall = WVAPICall(command = cmd) @@ -205,8 +208,8 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } -fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { - val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { +fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { + val server = object: NanoWSD(SERVER_HOST, port) { override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) fun resourcesToResponse(path: String): Response { @@ -230,7 +233,14 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { } } } - server.start(60_000_000) + try { + server.start(60_000_000) + } catch (e: BindException) { + if (port == 0) throw e + Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") + server.stop() + return startServer(onResponse, port = 0) + } return server } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 38054cb873..b4a24e3572 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import chat.simplex.common.model.CIFile import chat.simplex.common.platform.* @@ -17,7 +18,7 @@ actual fun SimpleAndAnimatedImageView( ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) { // LALAL make it animated too - ImageView(imageBitmap.toAwtImage().toPainter()) { + ImageView(BitmapPainter(imageBitmap)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> ImageFullScreenView(imageProvider, close) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index d541a5780e..8d69607c62 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.delay import java.io.ByteArrayInputStream import java.io.File import java.net.URI +import java.util.* import javax.imageio.ImageIO import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -128,6 +129,14 @@ actual fun getAppFileUri(fileName: String): URI { } } +private val loadedImageCache = Collections.synchronizedMap(object : LinkedHashMap>(30, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry>): Boolean = size > 30 +}) + +actual fun clearImageCaches() { + loadedImageCache.clear() +} + actual suspend fun getLoadedImage(file: CIFile?): Pair? { var filePath = getLoadedFilePath(file) if (chatModel.connectedToRemote() && filePath == null) { @@ -135,10 +144,10 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? filePath = getLoadedFilePath(file) } return if (filePath != null) { - try { + loadedImageCache[filePath] ?: try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() val bitmap = getBitmapFromByteArray(data, false) - if (bitmap != null) bitmap to data else null + if (bitmap != null) (bitmap to data).also { loadedImageCache[filePath] = it } else null } catch (e: Exception) { Log.e(TAG, "Unable to read crypto file: " + e.stackTraceToString()) null diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt index c270bddb73..66be736fca 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced import SectionSpacer +import SectionTextFooter import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -18,7 +19,9 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.CloseBehavior import chat.simplex.common.model.SharedPreference +import chat.simplex.common.trayIsAvailable import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* @@ -65,6 +68,11 @@ fun AppearanceScope.AppearanceLayout( SectionDividerSpaced() ThemesSection(systemDarkTheme) + if (trayIsAvailable) { + SectionDividerSpaced() + MinimizeToTraySection() + } + SectionDividerSpaced() AppToolbarsSection() @@ -84,6 +92,21 @@ fun AppearanceScope.AppearanceLayout( } } +@Composable +private fun MinimizeToTraySection() { + val pref = remember { appPrefs.closeBehavior.state } + val on = pref.value == CloseBehavior.MinimizeToTray + SectionView { + PreferenceToggle( + stringResource(MR.strings.appearance_minimize_to_tray), + checked = on, + ) { checked -> + appPrefs.closeBehavior.set(if (checked) CloseBehavior.MinimizeToTray else CloseBehavior.Quit) + } + } + SectionTextFooter(stringResource(MR.strings.appearance_minimize_to_tray_desc)) +} + @Composable fun DensityScaleSection() { val localDensityScale = remember { mutableStateOf(appPrefs.densityScale.get()) } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt new file mode 100644 index 0000000000..1b495c1774 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt @@ -0,0 +1,56 @@ +package chat.simplex.app + +import java.nio.channels.FileChannel +import java.nio.channels.OverlappingFileLockException +import java.nio.file.Files +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.READ +import java.nio.file.StandardOpenOption.WRITE +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class SingleInstanceTest { + @Test + fun overlappingLockOnSameRegionThrowsWithinOneJvm() = withTempDir { dir -> + val lockPath = dir.resolve("simplex.started") + val first = FileChannel.open(lockPath, READ, WRITE, CREATE) + val firstLock = first.tryLock(0L, 1L, false) + assertNotNull(firstLock, "first acquirer must get the lock") + + val second = FileChannel.open(lockPath, READ, WRITE, CREATE) + assertFailsWith { + second.tryLock(0L, 1L, false) + } + second.close() + firstLock.release() + first.close() + } + + @Test + fun releasedLockCanBeReacquired() = withTempDir { dir -> + val lockPath = dir.resolve("simplex.started") + val first = FileChannel.open(lockPath, READ, WRITE, CREATE) + val firstLock = first.tryLock(0L, 1L, false) + assertNotNull(firstLock) + firstLock.release() + first.close() + + val second = FileChannel.open(lockPath, READ, WRITE, CREATE) + val secondLock = second.tryLock(0L, 1L, false) + assertNotNull(secondLock, "after release, a fresh acquirer must succeed") + secondLock.release() + second.close() + } + + private fun withTempDir(block: (java.nio.file.Path) -> Unit) { + val tmp = Files.createTempDirectory("simplex-singleinstance-test") + try { + block(tmp) + } finally { + Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach { + try { Files.delete(it) } catch (_: java.io.IOException) {} + } + } + } +} diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 60ff535e88..8f072539e8 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -40,6 +40,7 @@ compose { } mainClass = "chat.simplex.desktop.MainKt" nativeDistributions { + copyright = "(c) 2020-2026 SimpleX Chat" // For debugging via VisualVM if (debugJava) { modules("jdk.zipfs", "jdk.unsupported", "jdk.management.agent") @@ -72,6 +73,12 @@ compose { iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.icns")) appCategory = "public.app-category.social-networking" bundleID = "chat.simplex.app" + infoPlist { + extraKeysRawXml = """ + NSMicrophoneUsageDescription + SimpleX needs microphone access to record voice messages + """ + } val identity = rootProject.extra["desktop.mac.signing.identity"] as String? val keychain = rootProject.extra["desktop.mac.signing.keychain"] as String? val appleId = rootProject.extra["desktop.mac.notarization.apple_id"] as String? diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index 0e8a452e08..338660b746 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.* +import chat.simplex.common.acquireSingleInstance import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.size import chat.simplex.common.platform.* @@ -19,6 +20,7 @@ import kotlinx.coroutines.* import java.io.File fun main() { + if (!acquireSingleInstance()) return // Disable hardware acceleration //System.setProperty("skiko.renderApi", "SOFTWARE") initHaskell() diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 395cde00fe..3d4bf66913 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.4.11 -android.version_code=336 +android.version_name=6.5.3 +android.version_code=351 android.bundle=false -desktop.version_name=6.4.11 -desktop.version_code=132 +desktop.version_name=6.5.3 +desktop.version_code=144 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/local.properties.example b/apps/multiplatform/local.properties.example index 8fa9a47963..9aa560d839 100644 --- a/apps/multiplatform/local.properties.example +++ b/apps/multiplatform/local.properties.example @@ -3,6 +3,8 @@ enable_debuggable=true application_id.suffix=.debug app.name=SimpleX Debug +#simplex.assets.dir=path/to/assets + #desktop.mac.signing.identity=SimpleX Chat Ltd #desktop.mac.signing.keychain=/path/to/simplex.keychain #desktop.mac.notarization.apple_id=example@example.com diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md new file mode 100644 index 0000000000..173def8ae7 --- /dev/null +++ b/apps/multiplatform/product/README.md @@ -0,0 +1,396 @@ +# SimpleX Chat Android & Desktop -- Product Overview + +> SimpleX Chat multiplatform product specification (Android + Desktop). Bidirectional code links: product docs reference source files, source files reference product docs. +> +> **Related spec:** [spec/README.md](../spec/README.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Vision](#vision) +3. [Target Users](#target-users) +4. [Capability Map](#capability-map) +5. [Navigation Map](#navigation-map) +6. [Related Specifications](#related-specifications) + +## Executive Summary + +SimpleX Chat is the first messaging platform with no user identifiers of any kind -- not even random numbers. It provides end-to-end encrypted messaging (with optional post-quantum cryptography), audio/video calls, file sharing, and group communication through a fully decentralized architecture where users control their own SMP relay servers. + +The Android and Desktop apps share a single **Kotlin Multiplatform + Compose Multiplatform** codebase. Common UI and business logic lives in a shared `common/` module, while platform-specific behavior (notifications, audio, video playback, file system access, call management) is abstracted through the Kotlin `expect`/`actual` pattern and a runtime `PlatformInterface` delegate. The Haskell core library is loaded via **JNI** (`external fun` declarations in `Core.kt`), exposing the full SimpleX Chat API (message send/receive, encryption, migration, file handling) through native FFI. + +Key platform differences: + +- **Android** uses a 2-column layout (`AndroidScreen`): chat list slides to chat view. Background messaging is handled by `SimplexService` (foreground service) + `MessagesFetcherWorker` (WorkManager periodic fetch). Calls use a dedicated `CallService` + `CallActivity`. +- **Desktop** uses a 3-column layout (`DesktopScreen`): chat list (start) | chat view (center) | detail panel (`ModalManager.end`). It includes `AppUpdater` for in-app update checking, `StoreWindowState` for window geometry persistence, and VLC-based video playback. Calls use browser-based WebRTC rendered inline. + +--- + +## Vision + +SimpleX Chat is the first messaging platform that has no user identifiers -- not even random numbers. It uses double-ratchet end-to-end encryption with optional post-quantum cryptography. The system is fully decentralized with user-controlled SMP relay servers. + +The protocol design ensures that no server or network observer can determine who communicates with whom. Each conversation uses separate unidirectional messaging queues on potentially different servers, and there is no shared identifier between the sender and receiver queues. + +--- + +## Target Users + +- **Privacy-conscious individuals** wanting secure messaging without phone-number or email-based identity +- **Groups and communities** needing encrypted group communication with role-based access control +- **Users avoiding identity linkage** who want to communicate without any persistent user identifier +- **Organizations** needing self-hosted messaging infrastructure with full control over relay servers +- **Desktop users** wanting a native desktop client with the same privacy guarantees as the mobile app + +--- + +## Capability Map + +All source paths below are relative to `apps/multiplatform/`. The common source root is `common/src/commonMain/kotlin/chat/simplex/common/`. + +### 1. Messaging + +Core message composition, delivery, and interaction features. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Text with markdown | Rich text formatting with SimpleX markdown syntax | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Images | Compressed inline images with full-screen gallery | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt` | +| Video | Video message recording and playback | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt` | +| Voice messages | Audio recording and playback (5min / 510KB limit) | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt` | +| File sharing | Files up to 1GB via XFTP protocol | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt` | +| Link previews | OpenGraph metadata extraction and display | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt` | +| Message reactions | Emoji reactions on sent/received messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt` | +| Message editing | Edit previously sent messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Message deletion | Broadcast delete (for recipient) or internal-only delete | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt` | +| Timed messages | Self-destructing messages with configurable TTL | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt` | +| Quoted replies | Reply to specific messages with quote context | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt` | +| Forwarding | Forward messages between chats | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Search | Full-text search within conversations | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt` | +| Message reports | Report messages to group moderators | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt` | +| Send message bar | Composable message input with attachments, voice, send button | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt` | + +### 2. Contacts + +Establishing, managing, and verifying contacts. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Add via SimpleX address | Connect using a SimpleX contact address | `common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt` | +| Add via QR code | Scan QR code to establish connection | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt` | +| Contact requests | Accept or reject incoming contact requests | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt` | +| Local aliases | Set private display names for contacts | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` | +| Contact verification | Compare security codes out-of-band | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt` | +| Blocking | Block contacts from sending messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` | +| Incognito mode | Per-contact random profile generation | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt` | +| Bot detection | Identify automated/bot contacts | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Contact list | Dedicated contact browsing view | `common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt` | + +### 3. Groups + +Multi-party encrypted conversations with role-based management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Create groups | Create new group with initial members | `common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt` | +| Invite members | Invite by individual contact or link | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt` | +| Member roles | Owner, admin, moderator, member, observer | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Member admission | Queue-based admission with review workflow | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt` | +| Group links | Shareable invite links for groups | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt` | +| Business chat mode | Structured business communication groups | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt` | +| Content moderation | Member reports and moderator actions | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt` | +| Group preferences | Configure group-level feature settings | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt` | +| Member direct contacts | Establish direct chats from group membership | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt` | +| Group mentions | @-mention members in group messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt` | +| Welcome message | Custom welcome message for new group members | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt` | +| Group profile | Edit group name, image, description | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt` | +| Member support chat | Scoped support threads between members and admins | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt` | + +### 4. Calling + +End-to-end encrypted audio and video communication. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| E2E encrypted calls | Audio/video calls via WebRTC with E2E encryption | `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt` | +| Call manager | Call state machine and lifecycle management | `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` | +| Call history | Call events displayed as chat items | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt` | +| Incoming call view | Dedicated UI for incoming call notifications | `common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt` | +| Android CallService | Foreground service for active calls on Android | `android/src/main/java/chat/simplex/app/CallService.kt` | +| Android CallActivity | Dedicated Activity for call UI on Android | `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` | +| Desktop inline calls | Browser-based WebRTC rendered inline in desktop window | `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt` | + +### 5. Privacy & Security + +Encryption, authentication, and privacy controls. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| E2E encryption | Double-ratchet encryption for all messages | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Post-quantum encryption | Optional PQ key exchange for direct chats | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Local authentication | Biometric (fingerprint/face) or app passcode lock | `common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt` | +| Passcode entry | Custom numeric/alphanumeric passcode UI | `common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt` | +| Hidden profiles | Password-protected profiles invisible in UI | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt` | +| Database encryption | AES encryption of local SQLite database | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt` | +| Screen privacy | Blur/hide app content when in app switcher | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt` | +| Encrypted file storage | Local files encrypted at rest | `common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt` | +| Delivery receipts control | Toggle delivery/read receipts per contact/group | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt` | +| App lock | Automatic lock on background/timeout with configurable delay | `common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt` | + +### 6. User Management + +Multiple profiles and identity management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Multiple profiles | Multiple user profiles within one app | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt` | +| Active user switching | Switch between profiles via user picker | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt` | +| Incognito contacts | Per-contact random identities | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt` | +| Profile sharing | Share profile via contact address link | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt` | +| User muting | Mute notifications for specific profiles | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt` | +| User profile editing | Edit display name and profile image | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt` | + +### 7. Network + +Server configuration, proxy support, and connectivity. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Custom SMP servers | Configure personal SMP relay servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt` | +| Custom XFTP servers | Configure personal XFTP file servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt` | +| Tor/onion support | Route traffic through Tor .onion addresses | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| SOCKS5 proxy | Route connections through SOCKS5 proxy | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| Custom ICE servers | Configure WebRTC ICE/TURN servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt` | +| Network timeouts | Configure connection timeout parameters | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| Server operators | Configure and manage SMP/XFTP server operators | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt` | +| Server status | View aggregate server connectivity status | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt` | +| Network & servers hub | Central network configuration entry point | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt` | + +### 8. Customization + +Visual appearance and UI preferences. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Themes | Light, dark, SimpleX, black, and custom themes | `common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt` | +| Wallpapers | Preset and custom chat wallpapers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt` | +| Chat bubble styling | Customize message bubble appearance | `common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt` | +| One-handed UI mode | Compact layout for single-hand use (Android) | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt` | +| Language selection | In-app language override | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt` | +| Theme mode editor | Interactive theme color and mode customization | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt` | + +### 9. Data Management + +Import, export, encryption, and storage management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Export/import profiles | Full database export and import | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt` | +| Database encryption | Encrypt/decrypt local database with passphrase | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt` | +| Local file encryption | Encrypt stored media and attachments | `common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt` | +| Database error handling | Recovery UI for database migration failures | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt` | +| Device-to-device migration | Migrate full profile between devices | `common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt` | +| Receive migration | Accept incoming device migration transfer | `common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt` | +| Database utilities | Key storage, password management, helper functions | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | + +### 10. Desktop Features + +Desktop-specific functionality not present on Android. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| 3-column layout | Start (chat list) / center (chat) / end (detail) panels | `common/src/commonMain/kotlin/chat/simplex/common/App.kt` (`DesktopScreen`) | +| ModalManager.end | Third-column detail panel for settings/info views | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt` (`ModalManager`) | +| App update checker | In-app notification for available updates | `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | +| Window state persistence | Save/restore window position and dimensions | `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | +| VLC video playback | Desktop video playback via VLC native libraries | `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | +| Desktop app entry | Main function, Haskell init, VLC loading | `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | +| Desktop notification manager | Platform-native desktop notifications | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt` | +| Connect mobile device | Pair desktop with a mobile device for remote access | `common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt` | +| Desktop platform abstraction | Desktop-specific PlatformInterface implementation | `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt` | +| Desktop app shell | Compose Desktop window, theming, lifecycle | `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | + +--- + +## Navigation Map + +### Android Navigation (2-column slide) + +``` +Onboarding + views/onboarding/SimpleXInfo.kt + -> SimpleXInfo -> CreateFirstProfile -> SetupDatabasePassphrase + -> ChooseServerOperators -> SetNotificationsMode + -> ChatListView (home) + +ChatListView (home) + views/chatlist/ChatListView.kt + -> ChatView .................. (tap conversation row, slides in) + -> NewChatSheet .............. (+ FAB button) + -> SettingsView .............. (gear icon) + -> UserPicker ................ (avatar tap) + -> TagListView ............... (tag filter bar) + -> ServersSummaryView ........ (server status indicator) + -> ShareListView ............. (share intent from external apps) + -> ChatHelpView .............. (empty state help) + +ChatView + views/chat/ChatView.kt + -> ChatInfoView .............. (contact name tap, direct chat) + -> GroupChatInfoView ......... (group name tap, group chat) + -> ActiveCallView ............ (call button, launches CallActivity) + -> ComposeView ............... (message input area) + -> ChatItemInfoView .......... (long press -> info) + -> MemberSupportChatView ..... (member support thread) + -> ScanCodeView .............. (scan QR) + -> CommandsMenuView .......... (/ commands) + +ChatInfoView + views/chat/ChatInfoView.kt + -> ContactPreferences ........ (preferences) + -> VerifyCodeView ............ (verify security code) + +GroupChatInfoView + views/chat/group/GroupChatInfoView.kt + -> GroupProfileView .......... (edit profile) + -> AddGroupMembersView ....... (invite members) + -> GroupLinkView ............. (manage group link) + -> MemberAdmission ........... (admission settings) + -> GroupPreferences .......... (group feature settings) + -> GroupMemberInfoView ....... (tap member) + -> WelcomeMessageView ........ (welcome message) + -> GroupReportsView .......... (view reports) + +NewChatSheet + views/newchat/NewChatSheet.kt + -> NewChatView ............... (QR scanner / paste link) + -> AddGroupView .............. (create group) + -> UserAddressView ........... (create SimpleX address) + +SettingsView + views/usersettings/SettingsView.kt + -> AppearanceView ............ (themes, wallpapers, UI) + -> NetworkAndServers ......... (SMP/XFTP/proxy config) + -> PrivacySettings ........... (privacy toggles) + -> NotificationsSettingsView . (notification mode) + -> DatabaseView .............. (export/import/encrypt) + -> CallSettings .............. (call preferences) + -> VersionInfoView ........... (about/version) + -> DeveloperView ............. (developer options) + -> HelpView .................. (help & support) + +UserPicker + views/chatlist/UserPicker.kt + -> UserProfilesView .......... (manage all profiles) + -> UserAddressView ........... (SimpleX address) + -> Preferences ............... (user preferences) + -> SettingsView .............. (app settings) + -> ConnectDesktopView ........ (pair with desktop) +``` + +### Desktop Navigation (3-column panels) + +``` ++---------------------------+----------------------------------+----------------------------+ +| START PANEL | CENTER PANEL | END PANEL | +| (DEFAULT_START_MODAL_ | (flexible width, min | (DEFAULT_END_MODAL_ | +| WIDTH) | DEFAULT_MIN_CENTER_MODAL_ | WIDTH) | +| | WIDTH) | | ++---------------------------+----------------------------------+----------------------------+ +| | | | +| ChatListView | ChatView | ChatInfoView | +| - chat rows | - message list | GroupChatInfoView | +| - search | - ComposeView | GroupMemberInfoView | +| - tag filters | - media viewer | ContactPreferences | +| - server status | | GroupPreferences | +| | OR (when no chat selected): | GroupProfileView | +| UserPicker (overlay) | "No selected chat" | AddGroupMembersView | +| - profile switcher | | MemberAdmission | +| - quick settings | OR (when modal open): | VerifyCodeView | +| | ModalManager.center content | SettingsView subtabs | +| ModalManager.start | (settings, new chat, etc.) | | +| - secondary modals | | ModalManager.end | +| | | - detail modals | ++---------------------------+----------------------------------+----------------------------+ + +ModalManager Placement (Desktop): + - ModalManager.start -> left panel overlay (settings subviews) + - ModalManager.center -> center panel (replaces chat, used when chatId is null) + - ModalManager.end -> right panel (detail/info views) + - ModalManager.fullscreen -> full window overlay (onboarding, auth, call) + +On Android, all ModalManager instances (start/center/end/fullscreen) collapse to a +single shared ModalManager that presents modals as full-screen overlays. + +Desktop-only navigation targets: + ConnectMobileView ......... (pair with mobile device) + AppUpdater notice ......... (update available notification) + Floating terminal ......... (developer console) + ActiveCallView ............ (inline WebRTC call, not separate Activity) +``` + +--- + +## Platform Abstraction + +The codebase uses two mechanisms for platform-specific behavior: + +### 1. `expect`/`actual` Declarations + +Kotlin Multiplatform `expect` declarations in `common/src/commonMain/kotlin/chat/simplex/common/platform/` with corresponding `actual` implementations in: +- `common/src/androidMain/kotlin/chat/simplex/common/platform/*.android.kt` +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/*.desktop.kt` + +Key `expect`/`actual` abstractions: `appPlatform`, `BackHandler`, `VideoPlayer`, `AudioPlayer`, `RecorderNative`, `NtfManager`, `showToast`, `getKeyboardState`, `PlatformTextField`, image processing, file sharing, and more. + +### 2. Runtime `PlatformInterface` + +Defined in `common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt`, this interface provides platform-specific callbacks that cannot use `expect`/`actual` (because `android/` module code cannot be called from `common/androidMain/`). The `platform` variable is reassigned at app startup: +- **Android:** `SimplexApp` sets `platform` to an implementation with `CallService`, notification channels, orientation locking, status bar theming, and PiP support. +- **Desktop:** `Main.kt` sets `platform` to an implementation with `desktopShowAppUpdateNotice()`. + +### 3. Haskell Core (JNI/FFI) + +Native FFI bindings are declared in `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` as `external fun` declarations. These include: `chatMigrateInit`, `chatSendCmdRetry`, `chatRecvMsg`, `chatParseMarkdown`, `chatPasswordHash`, `chatWriteFile`, `chatReadFile`, `chatEncryptFile`, `chatDecryptFile`, and more. The native library (`libapp-lib`) is loaded at startup from platform-specific resource directories. + +--- + +## Background Messaging (Android) + +Android has no equivalent to iOS NSE (Notification Service Extension). Instead, it uses: + +- **`SimplexService`** (`android/src/main/java/chat/simplex/app/SimplexService.kt`) -- A foreground service that keeps the Haskell core running to receive messages in real-time. Displays a persistent notification while active. +- **`MessagesFetcherWorker`** (`android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt`) -- A WorkManager-based periodic task that wakes the app at configurable intervals to fetch messages when the foreground service is not running (battery-optimized mode). +- **Notification modes:** Instant (foreground service always running), Periodic (WorkManager fetch every N minutes), Off. + +--- + +## Related Specifications + +### Product Layer (this directory) + +- [concepts.md](concepts.md) -- Feature concept index with bidirectional code links +- [glossary.md](glossary.md) -- Terminology definitions +- [rules.md](rules.md) -- Business rules and constraints +- [gaps.md](gaps.md) -- Known documentation gaps +- Views: [chat-list](views/chat-list.md), [chat](views/chat.md), [new-chat](views/new-chat.md), [settings](views/settings.md), [call](views/call.md), [contact-info](views/contact-info.md), [group-info](views/group-info.md), [onboarding](views/onboarding.md), [user-profiles](views/user-profiles.md) +- Flows: [messaging](flows/messaging.md), [calling](flows/calling.md), [onboarding](flows/onboarding.md), [group-lifecycle](flows/group-lifecycle.md), [connection](flows/connection.md), [file-transfer](flows/file-transfer.md) + +### Spec Layer + +- [spec/README.md](../spec/README.md) -- Technical specification overview +- [spec/architecture.md](../spec/architecture.md) -- JNI bridge, startup, lifecycle +- [spec/state.md](../spec/state.md) -- ChatModel, ChatsContext, Chat, AppPreferences +- [spec/api.md](../spec/api.md) -- Command/response protocol (CC, CR, ChatError) +- [spec/database.md](../spec/database.md) -- Migration, encryption, export/import +- Client: [navigation](../spec/client/navigation.md), [chat-list](../spec/client/chat-list.md), [chat-view](../spec/client/chat-view.md), [compose](../spec/client/compose.md) +- Services: [calls](../spec/services/calls.md), [theme](../spec/services/theme.md), [files](../spec/services/files.md), [notifications](../spec/services/notifications.md) + +### Source Entry Points + +- Haskell core: `../../src/Simplex/Chat/Controller.hs`, `../../src/Simplex/Chat/Types.hs` +- Kotlin model: `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` +- Kotlin API bridge: `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` +- Kotlin FFI: `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` +- Android entry: `android/src/main/java/chat/simplex/app/SimplexApp.kt`, `MainActivity.kt` +- Desktop entry: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md new file mode 100644 index 0000000000..5d707cf832 --- /dev/null +++ b/apps/multiplatform/product/concepts.md @@ -0,0 +1,121 @@ +# SimpleX Chat Android & Desktop -- Concept Index + +> SimpleX Chat multiplatform concept index. Maps every product concept to its documentation and source code with bidirectional links. +> +> **Related spec:** [spec/README.md](../spec/README.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Feature Concepts](#section-1-feature-concepts) +2. [Entity Index](#section-2-entity-index) + +## Executive Summary + +This document provides a structured mapping between product-level concepts, their documentation, and their implementation in both the Kotlin multiplatform layer and the Haskell core library. All Kotlin source paths are relative to `apps/multiplatform/`. Haskell paths use `../../src/` prefix (relative to `apps/multiplatform/`). The common source root abbreviation used below is `common/src/commonMain/kotlin/chat/simplex/common/`. + +--- + +## Section 1: Feature Concepts + +| # | Concept | Product Docs | Spec Docs | Source Files (Kotlin) | Source Files (Haskell) | +|---|---------|-------------|-----------|----------------------|----------------------| +| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt` | `Controller.hs` (`APIGetChats`) | +| PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` | +| PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) | +| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) | +| PC5 | Message Reactions | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/ChatItemView.kt` (ChatItemReactions composable) | `Controller.hs` (`APIChatItemReaction`) | +| PC6 | Message Editing | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `ChatItemInfoView.kt` | `Controller.hs` (`APIUpdateChatItem`) | +| PC7 | Message Deletion | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/MarkedDeletedItemView.kt`, `DeletedItemView.kt` | `Controller.hs` (`APIDeleteChatItem`) | +| PC8 | Timed Messages | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/CIChatFeatureView.kt` | `Types/Preferences.hs` (`TimedMessagesPreference`) | +| PC9 | Voice Messages | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/item/CIVoiceView.kt`, `ComposeVoiceView.kt`, `platform/RecAndPlay.kt` | `Protocol.hs` (`MCVoice`) | +| PC10 | File Transfer | [README.md](README.md) (Messaging, Data Management) | [spec/services/files.md](../spec/services/files.md) | `common/.../views/chat/item/CIFileView.kt`, `platform/Files.kt` | `Files.hs`, `Store/Files.hs` | +| PC11 | Link Previews | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/helpers/LinkPreviews.kt` | `Protocol.hs` (`MCLink`) | +| PC12 | Contact Connection | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/newchat/NewChatView.kt`, `QRCode.kt`, `QRCodeScanner.kt`, `ConnectPlan.kt` | `Controller.hs` (`APIConnect`, `APIAddContact`) | +| PC13 | Contact Verification | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/chat/VerifyCodeView.kt` | `Controller.hs` (`APIVerifyContact`) | +| PC14 | Group Management | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/newchat/AddGroupView.kt`, `group/GroupChatInfoView.kt`, `group/GroupProfileView.kt` | `Controller.hs` (`APINewGroup`), `Store/Groups.hs` | +| PC15 | Group Links | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/GroupLinkView.kt` | `Controller.hs` (`APICreateGroupLink`) | +| PC16 | Member Roles | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../model/ChatModel.kt`, `group/GroupMemberInfoView.kt` | `Types/Shared.hs` (`GroupMemberRole`) | +| PC17 | Audio/Video Calls | [README.md](README.md) (Calling) | [spec/services/calls.md](../spec/services/calls.md) | `common/.../views/call/CallView.kt`, `CallManager.kt`, `WebRTC.kt`, `android/.../CallService.kt`, `android/.../views/call/CallActivity.kt` | `Call.hs` (`RcvCallInvitation`, `CallType`) | +| PC18 | Notifications | [README.md](README.md) (Background Messaging) | [spec/services/notifications.md](../spec/services/notifications.md) | `common/.../platform/NtfManager.kt`, `Notifications.kt`, `android/.../SimplexService.kt`, `android/.../MessagesFetcherWorker.kt`, `common/.../views/usersettings/NotificationsSettingsView.kt` | `Controller.hs` | +| PC19 | User Profiles | [README.md](README.md) (User Management) | [spec/state.md](../spec/state.md) | `common/.../views/usersettings/UserProfilesView.kt`, `UserProfileView.kt`, `views/chatlist/UserPicker.kt` | `Types.hs` (`User`), `Store/Profiles.hs` | +| PC20 | Incognito Mode | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/IncognitoView.kt` | `ProfileGenerator.hs`, `Types.hs` | +| PC21 | Hidden Profiles | [README.md](README.md) (Privacy & Security) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/HiddenProfileView.kt` | `Controller.hs` (`APIHideUser`, `APIUnhideUser`) | +| PC22 | Local Authentication | [README.md](README.md) (Privacy & Security) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/localauth/LocalAuthView.kt`, `PasscodeView.kt`, `SetAppPasscodeView.kt`, `PasswordEntry.kt`, `AppLock.kt` | N/A (client-only) | +| PC23 | Database Encryption | [README.md](README.md) (Data Management) | [spec/database.md](../spec/database.md) | `common/.../views/database/DatabaseEncryptionView.kt`, `DatabaseView.kt`, `views/helpers/DatabaseUtils.kt` | `Controller.hs` (`APIExportArchive`) | +| PC24 | Theme System | [README.md](README.md) (Customization) | [spec/services/theme.md](../spec/services/theme.md) | `common/.../ui/theme/ThemeManager.kt`, `Theme.kt`, `Color.kt`, `Type.kt`, `Shape.kt` | `Types/UITheme.hs` | +| PC25 | Network Configuration | [README.md](README.md) (Network) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/usersettings/networkAndServers/NetworkAndServers.kt`, `ProtocolServersView.kt`, `AdvancedNetworkSettings.kt`, `OperatorView.kt` | `Controller.hs` (`APISetNetworkConfig`) | +| PC26 | Device Migration | [README.md](README.md) (Data Management) | [spec/database.md](../spec/database.md) | `common/.../views/migration/MigrateFromDevice.kt`, `MigrateToDevice.kt` | `Archive.hs` | +| PC27 | Remote Desktop | [README.md](README.md) (Desktop Features) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/remote/ConnectDesktopView.kt`, `ConnectMobileView.kt` | `Remote.hs`, `Remote/Types.hs` | +| PC28 | Chat Tags | [README.md](README.md) (Navigation Map) | [spec/state.md](../spec/state.md) | `common/.../views/chatlist/TagListView.kt`, `ChatListView.kt` | `Types.hs` (`ChatTag`), `Controller.hs` | +| PC29 | User Address | [README.md](README.md) (Contacts, User Management) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/UserAddressView.kt`, `UserAddressLearnMore.kt` | `Controller.hs` (`APICreateMyAddress`) | +| PC30 | Member Support Chat | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/MemberSupportView.kt`, `MemberSupportChatView.kt`, `MemberAdmission.kt` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | +| PC31 | Channels (Relays) | [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/state.md](../spec/state.md) | `common/.../model/ChatModel.kt` (`RelayStatus` incl. `RsRejected`, `GroupRelay`, `GroupMemberRole.Relay`, `GroupMemberStatus.MemRejected`), `common/.../views/chat/group/ChannelRelaysView.kt`, `GroupMemberInfoView.kt` (rejected-status row), `common/.../views/newchat/AddChannelView.kt` (`RelayStatusIndicator` rejected branch), `common/.../views/chat/group/AddGroupRelayView.kt` | `Controller.hs` (`APIAddGroupRelays`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | + +**Legend for abbreviated paths:** +- `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/` +- `android/.../` expands to `android/src/main/java/chat/simplex/app/` +- Haskell files are in `../../src/Simplex/Chat/` (relative to `apps/multiplatform/`) + +--- + +## Section 2: Entity Index + +Core data entities, their storage, and the operations that manage their lifecycle. + +| Entity | DB Table (Haskell) | Created By | Read By | Mutated By | Deleted By | +|--------|-------------------|------------|---------|------------|------------| +| **User** | `users` | `CreateActiveUser` in `Controller.hs` | `ListUsers`, `APISetActiveUser` in `Controller.hs` | `APISetActiveUser`, `APIHideUser`, `APIUnhideUser`, `APIMuteUser`, `APIUpdateProfile` in `Controller.hs` | `APIDeleteUser` in `Controller.hs`; `Store/Profiles.hs` | +| **Contact** | `contacts`, `contact_profiles` | `APIAddContact`, `APIConnect` in `Controller.hs` | `APIGetChat` in `Controller.hs`; `Store/Direct.hs` (`getContact`) | `APISetContactAlias`, `APISetConnectionAlias` in `Controller.hs`; `Store/Direct.hs` | `APIDeleteChat` in `Controller.hs`; `Store/Direct.hs` (`deleteContact`) | +| **GroupInfo** | `groups`, `group_profiles` | `APINewGroup` in `Controller.hs`; `Store/Groups.hs` (`createNewGroup`) | `APIGetChat`, `APIGroupInfo` in `Controller.hs`; `Store/Groups.hs` | `APIUpdateGroupProfile` in `Controller.hs`; `Store/Groups.hs` (`updateGroupProfile`) | `APIDeleteChat` in `Controller.hs`; `Store/Groups.hs` (`deleteGroup`) | +| **GroupMember** | `group_members`, `contact_profiles` | `APIAddMember`, `APIJoinGroup` in `Controller.hs`; `Store/Groups.hs` (`createNewGroupMember`) | `APIListMembers` in `Controller.hs`; `Store/Groups.hs` (`getGroupMembers`) | `APIMembersRole` in `Controller.hs`; `Store/Groups.hs` (`updateGroupMemberRole`) | `APIRemoveMembers` in `Controller.hs`; `Store/Groups.hs` (`deleteGroupMember`) | +| **ChatItem** | `chat_items`, `chat_item_versions` | `APISendMessages` in `Controller.hs`; `Store/Messages.hs` (`createNewChatItem`) | `APIGetChat`, `APIGetChatItems` in `Controller.hs`; `Store/Messages.hs` (`getChatItems`) | `APIUpdateChatItem`, `APIChatItemReaction` in `Controller.hs`; `Store/Messages.hs` (`updateChatItem`) | `APIDeleteChatItem` in `Controller.hs`; `Store/Messages.hs` (`deleteChatItem`) | +| **Connection** | `connections` | `createConnection` via SMP agent; `Store/Connections.hs` | `Store/Connections.hs` (`getConnectionEntity`) | `Store/Connections.hs` (`updateConnectionStatus`) | `Store/Connections.hs` (`deleteConnection`) | +| **FileTransfer** | `files`, `snd_files`, `rcv_files`, `xftp_file_descriptions` | `APISendMessages` (with file), `ReceiveFile` in `Controller.hs`; `Store/Files.hs` | `Store/Files.hs` (`getFileTransfer`) | `Store/Files.hs` (`updateFileStatus`, `updateFileProgress`) | `Store/Files.hs` (`deleteFileTransfer`) | +| **GroupLink** | `user_contact_links` | `APICreateGroupLink` in `Controller.hs`; `Store/Groups.hs` | `APIGetGroupLink` in `Controller.hs`; `Store/Groups.hs` | N/A (recreated on change) | `APIDeleteGroupLink` in `Controller.hs`; `Store/Groups.hs` | +| **ChatTag** | `chat_tags`, `chat_tags_chats` | `APICreateChatTag` in `Controller.hs` | `APIGetChats` in `Controller.hs` | `APIUpdateChatTag`, `APISetChatTags` in `Controller.hs` | `APIDeleteChatTag` in `Controller.hs` | +| **RcvCallInvitation** | In-memory (not persisted) | Received via `XCallInv` message in `Library/Subscriber.hs`; stored in `ChatModel.activeCallInvitation` | `CallManager.kt`, `IncomingCallAlertView.kt` | Updated on call accept/reject in `CallManager.kt` | Removed on call end/reject; `Controller.hs` | + +--- + +## Platform-Specific Source Index + +Key files that exist only on one platform, grouped by concern. + +### Android-Only + +| File | Purpose | +|------|---------| +| `android/src/main/java/chat/simplex/app/SimplexApp.kt` | Application subclass, PlatformInterface setup, Haskell init | +| `android/src/main/java/chat/simplex/app/MainActivity.kt` | Main Activity, deep link handling, lifecycle | +| `android/src/main/java/chat/simplex/app/SimplexService.kt` | Foreground service for persistent messaging | +| `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` | WorkManager periodic message fetch | +| `android/src/main/java/chat/simplex/app/CallService.kt` | Foreground service for active calls | +| `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` | Dedicated Activity for call UI | +| `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` | Android notification channels and manager | +| `common/src/androidMain/kotlin/chat/simplex/common/platform/*.android.kt` | All `actual` implementations for Android | + +### Desktop-Only + +| File | Purpose | +|------|---------| +| `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | JVM entry point, Haskell/VLC init, PlatformInterface setup | +| `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Compose Desktop window creation and lifecycle | +| `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | Window position/size persistence | +| `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | In-app update checker | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt` | VLC-based video detection | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | VLC video player implementation | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt` | Desktop platform detection (Linux/macOS/Windows) | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/*.desktop.kt` | All `actual` implementations for Desktop | + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Haskell core controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell store layer: `../../src/Simplex/Chat/Store/` (`Direct.hs`, `Groups.hs`, `Messages.hs`, `Files.hs`, `Profiles.hs`, `Connections.hs`) +- Kotlin model: `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` +- Kotlin API bridge: `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` +- Kotlin FFI layer: `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` +- Platform abstraction: `common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt` (`PlatformInterface`) diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md new file mode 100644 index 0000000000..fae7f42031 --- /dev/null +++ b/apps/multiplatform/product/flows/calling.md @@ -0,0 +1,220 @@ +# Calling Flow + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Overview + +SimpleX Chat supports audio and video calls using WebRTC, with signaling delivered over the existing SMP messaging channels. Calls are end-to-end encrypted with an additional shared key layer on top of WebRTC's SRTP encryption. + +The architecture differs by platform: +- **Android**: Calls run in a dedicated `CallActivity` (separate from `MainActivity`) with a `WebView` hosting the WebRTC JavaScript. A foreground `CallService` keeps the process alive and shows a persistent notification. +- **Desktop**: Calls open the system browser pointed at a local NanoHTTPD/NanoWSD embedded server on `localhost:50395`, which serves the WebRTC HTML/JS and communicates with the app via WebSocket. + +Both platforms share a common signaling flow through the Haskell core API. + +## Prerequisites + +- Both parties must have an established direct contact connection. +- Microphone permission is required; camera permission is required for video calls. +- On Android, the `CallOnLockScreen` preference controls lock-screen call behavior: `DISABLE`, `SHOW`, or `ACCEPT`. + +--- + +## 1. Outgoing Call (Caller Side) + +### 1.1 Initiate Call + +1. User taps the audio or video call button in `ChatView`. +2. `startChatCall(remoteHostId, chatInfo, media)` is called (in `ChatView.kt`). +3. A `Call` object is created with `callState = CallState.WaitCapabilities`: + ```kotlin + Call( + remoteHostId = remoteHostId, + contact = contact, + callUUID = null, + callState = CallState.WaitCapabilities, + initialCallType = media, // Audio or Video + userProfile = profile, + androidCallState = platform.androidCreateActiveCallState() + ) + ``` +4. `ChatModel.activeCall` is set and `ChatModel.showCallView` is set to `true`. +5. A `WCallCommand.Capabilities(media)` command is added to `ChatModel.callCommand`. + +### 1.2 WebRTC Capabilities Response + +1. The WebRTC engine (WebView on Android, browser on Desktop) receives the `Capabilities` command. +2. It responds with `WCallResponse.Capabilities(capabilities)` containing encryption support info. +3. The app calls `ChatController.apiSendCallInvitation(rh, contact, callType)` to send the invitation via SMP. +4. Call state transitions to `CallState.InvitationSent`. +5. A connecting sound starts playing via `CallSoundsPlayer.startConnectingCallSound`. + +### 1.3 Offer Exchange + +1. When the callee accepts, the WebRTC engine generates an offer. +2. `WCallResponse.Offer(offer, iceCandidates, capabilities)` is received. +3. `ChatController.apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` sends it. +4. Call state transitions to `CallState.OfferSent`. + +### 1.4 Answer and Connection + +1. The callee's answer arrives via SMP as a chat event. +2. The app dispatches `WCallCommand.Answer(answer, iceCandidates)` to the WebRTC engine. +3. Call state transitions to `CallState.Negotiated`, then to `CallState.Connected` once the ICE connection succeeds. +4. `Call.connectedAt` is set to the current timestamp. + +--- + +## 2. Incoming Call (Callee Side) + +### 2.1 Receive Invitation + +1. An incoming call event arrives from the core as `CR.CallInvitation`. +2. `CallManager.reportNewIncomingCall(invitation)` is called. +3. A `RcvCallInvitation` is stored in `ChatModel.callInvitations` keyed by contact ID. +4. If the invitation is recent (within 3 minutes), a system notification is shown and `ChatModel.activeCallInvitation` is set. +5. On Android, `CallActivity` may be launched on the lock screen if `callOnLockScreen` is `SHOW` or `ACCEPT`. + +### 2.2 Accept Call + +1. User taps "Accept" on the `IncomingCallAlertView` or lock-screen alert. +2. `CallManager.acceptIncomingCall(invitation)` is called. +3. If another call is active, it is ended first (with `switchingCall` flag set). +4. A new `Call` is created with `callState = CallState.InvitationAccepted`. +5. ICE servers are loaded from preferences (`getIceServers()`). +6. `WCallCommand.Start(media, aesKey, iceServers, relay)` is dispatched to the WebRTC engine. +7. The call invitation is removed from `callInvitations` and the notification is cancelled. + +### 2.3 Reject Call + +1. User taps "Reject" or the invitation times out. +2. `CallManager.endCall(invitation)` is called. +3. `ChatController.apiRejectCall(rh, contact)` notifies the caller. +4. The invitation is removed from `callInvitations`. + +--- + +## 3. Call State Machine + +``` +Outgoing: WaitCapabilities -> InvitationSent -> OfferSent -> AnswerReceived -> Negotiated -> Connected -> Ended +Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ended +``` + +| State | Description | +|-------|-------------| +| `WaitCapabilities` | Querying local WebRTC capabilities | +| `InvitationSent` | Caller sent invitation via SMP | +| `InvitationAccepted` | Callee accepted, starting WebRTC | +| `OfferSent` | Caller sent SDP offer | +| `OfferReceived` | Callee received SDP offer | +| `AnswerReceived` | Caller received SDP answer | +| `Negotiated` | ICE negotiation complete | +| `Connected` | Media flowing | +| `Ended` | Call terminated | + +--- + +## 4. Ending a Call + +1. User taps the end-call button, or the remote side ends the call. +2. `CallManager.endCall(call)` is called. +3. `ChatController.apiEndCall(rh, contact)` notifies the remote side via SMP. +4. `ChatModel.showCallView` is set to `false`. +5. `ChatModel.activeCall` is set to `null`. +6. On Android, `CallService` is stopped and the `WebView` is destroyed. +7. On Desktop, `WCallCommand.End` is sent to the browser via WebSocket, and the NanoWSD server is stopped. + +--- + +## 5. Android-Specific: CallActivity and CallService + +### 5.1 CallActivity + +- `CallActivity` is a separate `ComponentActivity` (not `MainActivity`). +- It is launched via `platform.androidStartCallActivity(acceptCall, remoteHostId, chatId)`. +- It hosts `ActiveCallView` with a `WebView` for WebRTC. +- Supports lock-screen display: `setShowWhenLocked(true)` and `setTurnScreenOn(true)`. +- Supports Picture-in-Picture (PiP) mode for video calls. + - On Android 12+, PiP auto-enters when the user navigates away. + - On older versions, PiP is entered via `enterPictureInPictureMode()` on `onUserLeaveHint`. + - PiP layout switches to `LayoutType.RemoteVideo` to show only the remote video feed. +- The activity finishes itself when both `invitation == null` and (`!showCallView || call == null`) and `!switchingCall`. + +### 5.2 CallService + +- `CallService` is a foreground `Service` that keeps the process alive during calls. +- Started via `CallService.startService()` which calls `ContextCompat.startForegroundService`. +- Acquires a partial `WakeLock` to prevent CPU sleep. +- Shows a persistent notification with: + - Contact name and call type (audio/video). + - An "End Call" action button. + - A chronometer showing call duration (from `connectedAt`). +- The notification taps open `CallActivity`. +- Foreground service type includes `MICROPHONE`, `CAMERA` (if video), and `MEDIA_PLAYBACK`. + +--- + +## 6. Desktop-Specific: Browser-Based WebRTC + +### 6.1 NanoWSD Embedded Server + +1. When a call starts, `startServer(onResponse)` creates a `NanoWSD` server on `localhost:50395`. +2. The server serves static WebRTC HTML/JS from bundled resources at `/assets/www/desktop/call.html`. +3. The system browser is opened to `http://localhost:50395/simplex/call/`. + +### 6.2 WebSocket Communication + +1. The browser page connects back via WebSocket to the same `localhost:50395` server. +2. Commands from the app to the browser are serialized as `WVAPICall(corrId, command)` JSON. +3. Responses from the browser arrive as `WVAPIMessage(corrId, resp, command)` JSON. +4. The `WebRTCController` composable manages the command queue: + - Collects commands from `ChatModel.callCommand` (a `SnapshotStateList`). + - Sends them to the browser via the WebSocket connection. + - Processes responses through the same `WCallResponse` handling as Android. +5. On dispose, `WCallCommand.End` is sent, the server is stopped, and connections are cleared. + +--- + +## 7. Common Signaling API + +| API Function | Purpose | +|-------------|---------| +| `apiSendCallInvitation(rh, contact, callType)` | Send call invitation via SMP | +| `apiRejectCall(rh, contact)` | Reject incoming call | +| `apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` | Send SDP offer | +| `apiSendCallAnswer(rh, contact, rtcSession, rtcIceCandidates)` | Send SDP answer | +| `apiSendCallExtraInfo(rh, contact, rtcIceCandidates)` | Send additional ICE candidates | +| `apiEndCall(rh, contact)` | End active call | +| `apiCallStatus(rh, contact, status)` | Report WebRTC connection status | + +--- + +## 8. In-Call Media Controls + +During an active call, the user can toggle media sources via `WCallCommand.Media(source, enable)`: + +| Source | Control | +|--------|---------| +| `CallMediaSource.Mic` | Mute/unmute microphone | +| `CallMediaSource.Camera` | Enable/disable camera | +| `CallMediaSource.ScreenAudio` | Screen share audio | +| `CallMediaSource.ScreenVideo` | Screen share video | + +Camera switching (front/back) is done via `WCallCommand.Camera(VideoCamera.User / VideoCamera.Environment)`. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `Call` | `views/call/WebRTC.kt` | Active call state: contact, callState, media sources, encryption | +| `CallState` | `views/call/WebRTC.kt` | Enum: WaitCapabilities through Ended | +| `RcvCallInvitation` | `views/call/WebRTC.kt` | Incoming call invitation with contact, callType, sharedKey | +| `CallManager` | `views/call/CallManager.kt` | Manages call lifecycle: accept, end, report | +| `WCallCommand` | `views/call/WebRTC.kt` | Commands to WebRTC engine: Capabilities, Start, Offer, Answer, Ice, Media, Camera, End | +| `WCallResponse` | `views/call/WebRTC.kt` | Responses from WebRTC: Capabilities, Offer, Answer, Ice, Connection, Connected, End | +| `CallActivity` | `android/.../views/call/CallActivity.kt` | Android Activity hosting the call UI and WebView | +| `CallService` | `android/.../CallService.kt` | Android foreground Service for call persistence | +| `NanoWSD` | `desktopMain/.../views/call/CallView.desktop.kt` | Desktop embedded HTTP+WebSocket server | diff --git a/apps/multiplatform/product/flows/connection.md b/apps/multiplatform/product/flows/connection.md new file mode 100644 index 0000000000..1b1123b535 --- /dev/null +++ b/apps/multiplatform/product/flows/connection.md @@ -0,0 +1,233 @@ +# Connection Flow + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/api.md](../../spec/api.md) + +## Overview + +Establishing a contact connection in SimpleX Chat follows an invitation-link model. One party creates a connection link (one-time invitation or long-term address), shares it out-of-band, and the other party connects via that link. The process uses SMP queues for the handshake, with no central server involved in identity management. + +Connections support incognito mode, where a random profile is used per-connection instead of the user's real profile. + +## Prerequisites + +- Chat is initialized and running. +- An active user profile exists. +- For connecting: a valid SimpleX connection link (invitation or address). + +--- + +## 1. Creating a Connection Link (Inviter Side) + +### 1.1 One-Time Invitation Link + +1. User navigates to "New Chat" and selects "Add Contact" (or uses the "+" action). +2. `ChatController.apiAddContact(rh, incognito)` is called: + +```kotlin +suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> +``` + +3. Internally, `CC.APIAddContact(userId, incognito)` is sent to the core. +4. The core creates a new SMP queue pair and returns: + - `CR.Invitation` with `connLinkInvitation: CreatedConnLink` and `connection: PendingContactConnection`. +5. The `CreatedConnLink` contains the invitation URI (long form and short link). +6. The link is displayed as a QR code in `NewChatView` and can be copied or shared. +7. A `PendingContactConnection` appears in the chat list while waiting. + +### 1.2 Long-Term Contact Address + +1. User goes to Settings and creates a SimpleX address. +2. This creates a persistent address link that multiple people can use. +3. Incoming connection requests from the address require explicit acceptance (see section 4). + +--- + +## 2. Connecting via Link (Connector Side) + +### 2.1 Preview the Connection Plan + +Before connecting, the link is analyzed: + +```kotlin +suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState): Pair? +``` + +1. User pastes or scans a link. +2. `apiConnectPlan` sends `CC.APIConnectPlan(userId, connLink)` to the core. +3. The core resolves short links, validates the link, and returns a `ConnectionPlan`: + +```kotlin +sealed class ConnectionPlan { + class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() + class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() + class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() + class Error(val chatError: ChatError): ConnectionPlan() +} +``` + +4. For `InvitationLinkPlan`: + - `Ok`: Fresh invitation, safe to connect. + - `OwnLink`: User's own link, alert shown. + - `Connecting(contact_)`: Already connecting to this contact. + - `Known(contact)`: Already connected, existing contact shown. + +5. For `ContactAddressPlan`: + - `Ok`: Fresh address, safe to connect. + - `OwnLink`: User's own address. + - `ConnectingConfirmReconnect`: Was connecting, offer to retry. + - `ConnectingProhibit(contact)`: Connection in progress, cannot duplicate. + - `Known(contact)`: Already a contact. + - `ContactViaAddress(contact)`: Contact already exists via this address. + +6. For `GroupLinkPlan`: + - `Ok`: Fresh group link, safe to join. + - `OwnLink(groupInfo)`: User's own group. + - `ConnectingConfirmReconnect`: Was connecting, offer to retry. + - `ConnectingProhibit(groupInfo_)`: Connection in progress. + - `Known(groupInfo)`: Already a member. + +### 2.2 High-Level Connect Flow: planAndConnect + +The `planAndConnect` function in `ConnectPlan.kt` orchestrates the full connect experience: + +```kotlin +suspend fun planAndConnect( + rhId: Long?, + shortOrFullLink: String, + close: (() -> Unit)?, + cleanup: (() -> Unit)? = null, + filterKnownContact: ((Contact) -> Unit)? = null, + filterKnownGroup: ((GroupInfo) -> Unit)? = null, +): CompletableDeferred +``` + +1. A progress indicator is shown. +2. `apiConnectPlan` is called to analyze the link. +3. Based on the plan type, the appropriate UI is shown: + - For `Ok` plans: proceed to `apiConnect`. + - For `Known`: navigate to the existing contact/group. + - For `OwnLink`: show alert. + - For `Connecting`: show reconnect confirmation or prohibit. +4. Returns a `CompletableDeferred` indicating success. + +### 2.3 Execute Connection + +```kotlin +suspend fun apiConnect(rh: Long?, incognito: Boolean, connLink: CreatedConnLink): PendingContactConnection? +``` + +1. `CC.APIConnect(userId, incognito, connLink)` is sent to the core. +2. The core initiates the SMP handshake: + - For invitation links: `CR.SentConfirmation` is returned. + - For contact addresses: `CR.SentInvitation` is returned. +3. A `PendingContactConnection` is returned and appears in the chat list. +4. The connect progress indicator is shown via `ConnectProgressManager`. + +--- + +## 3. Connection Handshake Completion + +### 3.1 For Invitation Links + +1. After the connector sends confirmation, the inviter's core receives it. +2. Both sides complete the SMP handshake automatically. +3. A `CR.ContactConnected` event is received on both sides. +4. The `PendingContactConnection` in the chat list is replaced by a full `Contact`. +5. Both parties can now exchange messages. + +### 3.2 For Contact Addresses + +1. The connector's confirmation arrives as a `ContactRequest` on the address owner's side. +2. The address owner must explicitly accept or reject (see section 4). +3. Once accepted, the handshake completes and `CR.ContactConnected` fires. + +--- + +## 4. Contact Request Acceptance + +### 4.1 Accept a Contact Request + +```kotlin +suspend fun apiAcceptContactRequest(rh: Long?, incognito: Boolean, contactReqId: Long): Contact? +``` + +1. The address owner sees a contact request notification in the chat list. +2. User taps to open and selects "Accept". +3. `CC.ApiAcceptContact(incognito, contactReqId)` is sent to the core. +4. The core responds with `CR.AcceptingContactRequest` and a `Contact` object. +5. The SMP handshake continues; once complete, `CR.ContactConnected` fires. +6. The `incognito` flag determines whether the real profile or a random profile is shared. + +### 4.2 Reject a Contact Request + +```kotlin +suspend fun apiRejectContactRequest(rh: Long?, contactReqId: Long): Contact? +``` + +1. User selects "Reject" on the contact request. +2. `CC.ApiRejectContact(contactReqId)` is sent to the core. +3. The core responds with `CR.ContactRequestRejected`. +4. The contact request is removed from the chat list. +5. The connector's side eventually times out or receives an error. + +--- + +## 5. Incognito Mode + +### 5.1 Per-Connection Incognito + +1. The `incognito` parameter is available on both `apiAddContact` and `apiConnect`. +2. When `incognito = true`: + - A random display name is generated for this connection. + - The real user profile is not shared with the contact. + - The incognito profile is stored per-connection in the database. +3. The global incognito toggle is in `AppPreferences.incognito`. +4. Incognito status is visible in the chat info view. + +### 5.2 Accept with Incognito + +1. When accepting a contact request with `incognito = true`, a random profile is used. +2. The accepted contact only sees the random profile. +3. The user can have some contacts with real profile and others with incognito profiles. + +--- + +## 6. Connection Progress and UI + +### 6.1 ConnectProgressManager + +```kotlin +object ConnectProgressManager { + fun startConnectProgress(text: String, onCancel: (() -> Unit)? = null) + fun stopConnectProgress() + fun cancelConnectProgress() +} +``` + +1. When a connection is initiated, `startConnectProgress` is called. +2. After a 1-second delay, a progress indicator appears if the operation is still in progress. +3. On completion (success or failure), `stopConnectProgress` is called. +4. The user can cancel via `cancelConnectProgress`. + +### 6.2 Pending Connection States + +While connecting, the chat list shows a `PendingContactConnection` with status: +- Waiting for the other party to scan/use the link. +- Connecting (handshake in progress). +- Connected (transitions to a full Contact chat). + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `CreatedConnLink` | `model/SimpleXAPI.kt` | Connection link with full URI and short link | +| `PendingContactConnection` | `model/ChatModel.kt` | In-progress connection shown in chat list | +| `ConnectionPlan` | `model/SimpleXAPI.kt` | Sealed class: InvitationLink, ContactAddress, GroupLink, Error | +| `InvitationLinkPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, Connecting, Known | +| `ContactAddressPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, ConnectingConfirmReconnect, ConnectingProhibit, Known | +| `GroupLinkPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, ConnectingConfirmReconnect, ConnectingProhibit, Known | +| `ConnectProgressManager` | `model/ChatModel.kt` | Manages connect progress indicator with timeout | +| `Contact` | `model/ChatModel.kt` | Established contact with profile, connection status | +| `ContactRequest` | `model/ChatModel.kt` | Pending inbound contact request | diff --git a/apps/multiplatform/product/flows/file-transfer.md b/apps/multiplatform/product/flows/file-transfer.md new file mode 100644 index 0000000000..edbb565c07 --- /dev/null +++ b/apps/multiplatform/product/flows/file-transfer.md @@ -0,0 +1,252 @@ +# File Transfer Flow + +> **Related spec:** [spec/services/files.md](../../spec/services/files.md) + +## Overview + +SimpleX Chat transfers files using two protocols based on file size: inline delivery through SMP messages for small files, and XFTP (SimpleX File Transfer Protocol) for larger files. All locally stored files can be AES-encrypted via CryptoFile. The system supports automatic receiving of small media, manual download for larger files, and cancellation at any stage. + +## Prerequisites + +- An active chat connection (direct contact or group). +- Sufficient storage space on the device. +- For XFTP: network connectivity to XFTP relay servers. + +--- + +## 1. File Size Thresholds and Constants + +| Constant | Value | Purpose | +|----------|-------|---------| +| `MAX_IMAGE_SIZE` | 261,120 bytes (255 KB) | Maximum inline image thumbnail size (base64 in message body) | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 bytes (510 KB) | Auto-receive threshold for images | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 bytes (510 KB) | Auto-receive threshold for voice messages | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 bytes (1023 KB) | Auto-receive threshold for video thumbnails | +| `MAX_FILE_SIZE_SMP` | 8,000,000 bytes (~7.6 MB) | Maximum file size for SMP inline transfer | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 bytes (1 GB) | Maximum file size for XFTP transfer | +| `MAX_FILE_SIZE_LOCAL` | `Long.MAX_VALUE` | No limit for local files | + +These constants are defined in `views/helpers/Utils.kt`. + +The core decides the transfer protocol: +- Files within the SMP inline threshold are embedded directly in SMP messages. +- Files exceeding the inline threshold (up to 1 GB) use XFTP with chunked, encrypted upload/download through relay servers. + +--- + +## 2. CryptoFile Encryption + +### 2.1 Overview + +When `privacyEncryptLocalFiles` is enabled (default: `true`), files stored on device are AES-GCM encrypted. The encryption/decryption is performed via JNI calls to the Haskell core. + +### 2.2 Key Types + +```kotlin +// model/ChatModel.kt +@Serializable +data class CryptoFileArgs( + val fileKey: String, // AES-256 key (base64) + val fileNonce: String // GCM nonce (base64) +) + +@Serializable +data class CryptoFile { + val filePath: String + val cryptoArgs: CryptoFileArgs? // null for unencrypted files +} +``` + +### 2.3 Write (Encrypt) + +```kotlin +fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs +``` + +1. `ChatController.getChatCtrl()` obtains the active controller handle. +2. Data is placed in a `DirectByteBuffer`. +3. `chatWriteFile(ctrl, path, buffer)` is called via JNI. +4. The core generates a random AES key and nonce, encrypts the data, writes to `path`. +5. Returns `CryptoFileArgs(fileKey, fileNonce)` needed for decryption. +6. On error, throws an exception with the error message. + +### 2.4 Read (Decrypt) + +```kotlin +fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray +``` + +1. `chatReadFile(path, cryptoArgs.fileKey, cryptoArgs.fileNonce)` is called via JNI. +2. Returns a two-element array: `[status: Int, data: ByteArray]`. +3. If `status == 0`, the decrypted data is returned. +4. Otherwise, an exception is thrown with the error message. + +### 2.5 File-to-File Encryption + +```kotlin +fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs +``` + +Encrypts a plaintext file at `fromPath` to an encrypted file at `toPath`. Used when saving user-selected files to the app's encrypted storage. + +### 2.6 File-to-File Decryption + +```kotlin +fun decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) +``` + +Decrypts an encrypted file at `fromPath` to plaintext at `toPath`. Used when exporting/sharing files. + +--- + +## 3. Sending Files + +### 3.1 Attach and Send via ComposeView + +1. User attaches a file via the file picker. +2. File size is validated: `fileSize <= MAX_FILE_SIZE_XFTP` (1 GB). +3. If valid, `ComposeState.preview` is set to `ComposePreview.FilePreview(fileName, uri)`. +4. If too large, an alert is shown with the maximum supported size. +5. On send, the file is copied to the app files directory. +6. If `privacyEncryptLocalFiles` is enabled, the file is encrypted via `encryptCryptoFile`, producing a `CryptoFile` with `cryptoArgs`. +7. A `ComposedMessage` is created with: + - `fileSource`: the `CryptoFile` (path + optional cryptoArgs). + - `msgContent`: `MsgContent.MCFile(text)` for generic files, `MsgContent.MCImage(text, thumbnail)` for images, `MsgContent.MCVideo(text, thumbnail, duration)` for videos, or `MsgContent.MCVoice(text, duration)` for voice. +8. `ChatController.apiSendMessages(...)` dispatches the message. +9. The core determines the transfer protocol and begins the upload. + +### 3.2 Standalone File Upload (XFTP) + +For uploading files outside of a chat message context: + +```kotlin +suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair +``` + +1. `CC.ApiUploadStandaloneFile(userId, file)` is sent to the core. +2. On success, `CR.SndStandaloneFileCreated` returns a `FileTransferMeta`. +3. The meta contains a file description URI that can be shared for download. + +### 3.3 Upload Progress + +1. The core emits `SndFileProgressXFTP` events periodically during upload. +2. `CIFileStatus` on the chat item transitions through: + - `SndStored` (queued) + - `SndTransfer(sndProgress, sndTotal)` (uploading) + - `SndComplete` (upload finished, link sent) +3. The UI updates the progress indicator on the file attachment. + +--- + +## 4. Receiving Files + +### 4.1 Auto-Receive + +When `privacyAcceptImages` is enabled (default: `true`), small media files are auto-received: + +1. On receiving a message with a file attachment, the auto-receive logic checks: + - `MCImage` files with `fileSize <= MAX_IMAGE_SIZE_AUTO_RCV` (510 KB) + - `MCVideo` files with `fileSize <= MAX_VIDEO_SIZE_AUTO_RCV` (1023 KB) + - `MCVoice` files with `fileSize <= MAX_VOICE_SIZE_AUTO_RCV` (510 KB) and not already accepted +2. If criteria are met, `receiveFile` is called automatically. + +### 4.2 Manual Receive + +For files that are not auto-received: + +1. The chat item shows a download button with file size info. +2. File size is validated: `fileSizeValid(file)` checks `file.fileSize <= getMaxFileSize(file.fileProtocol)`. +3. User taps the download button. +4. `ChatController.receiveFile(rhId, user, fileId, userApprovedRelays, auto)` is called: + +```kotlin +suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, userApprovedRelays: Boolean = false, auto: Boolean = false) +``` + +5. This delegates to `receiveFiles` which handles relay approval: + +```kotlin +suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, userApprovedRelays: Boolean = false, auto: Boolean = false) +``` + +6. For each file, `CC.ReceiveFile(fileId, userApprovedRelays, encrypted, inline)` is sent to the core. +7. If the file requires unapproved XFTP relays, the user is prompted to approve them. +8. Relay approval errors (`FileError.Auth` with `SMP AUTH` and `PROXY BROKER`) trigger relay approval alerts. +9. Other errors are collected and shown after all files are processed. + +### 4.3 Batch Receive + +Multiple files can be received at once: + +```kotlin +suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, ...) +``` + +1. Iterates through all `fileIds`. +2. Files needing relay approval are batched and prompted once. +3. After approval, those files are retried with `userApprovedRelays = true`. +4. Errors for individual files are aggregated. + +### 4.4 Download Progress + +1. The core emits `RcvFileProgressXFTP` events during download. +2. `CIFileStatus` transitions through: + - `RcvAccepted` (download initiated) + - `RcvTransfer(rcvProgress, rcvTotal)` (downloading) + - `RcvComplete` (download finished) +3. On completion, if the file is encrypted, it remains encrypted on disk with `cryptoArgs` stored in the database. +4. When the user opens/views the file, `readCryptoFile` or `decryptCryptoFile` is called on demand. + +--- + +## 5. Cancelling a File Transfer + +### 5.1 Cancel via API + +```kotlin +suspend fun cancelFile(rh: Long?, user: User, fileId: Long) +``` + +1. `apiCancelFile(rh, fileId)` sends `CC.CancelFile(fileId)` to the core. +2. The core cancels any in-progress upload or download. +3. On success, the chat item is updated via `chatItemSimpleUpdate`. +4. `cleanupFile(chatItem)` removes any partial local files. + +### 5.2 Cancel via UI + +1. User long-presses a file message and selects "Cancel". +2. `cancelFileAlertDialog(fileId, cancelFile, cancelAction)` shows a confirmation dialog. +3. `CancelAction` provides the appropriate alert text based on direction (sending/receiving). +4. On confirmation, `cancelFile` is called. + +### 5.3 Compose Cancel + +Before sending, user can cancel the file attachment: + +1. User taps the "X" on the file preview in the compose area. +2. `ComposeState.preview` is reset to `ComposePreview.NoPreview`. +3. No API call is needed since the file was not yet sent. + +--- + +## 6. File Cleanup + +1. Files pending deletion are tracked in `ChatModel.filesToDelete`. +2. When a chat item with a file is deleted, the file path is added to `filesToDelete`. +3. The actual file deletion happens asynchronously. +4. Encrypted files require no special cleanup beyond deleting the encrypted file; the key exists only in the database record. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `CryptoFile` | `model/ChatModel.kt` | File reference with path and optional encryption args | +| `CryptoFileArgs` | `model/ChatModel.kt` | AES key + nonce for encrypted files | +| `WriteFileResult` | `model/CryptoFile.kt` | Result of `writeCryptoFile`: success with args or error | +| `CIFile` | `model/ChatModel.kt` | Chat item file metadata: fileId, fileName, fileSize, fileStatus, fileProtocol | +| `CIFileStatus` | `model/ChatModel.kt` | File transfer status: SndStored, SndTransfer, SndComplete, RcvInvitation, RcvAccepted, RcvTransfer, RcvComplete, etc. | +| `FileProtocol` | `model/ChatModel.kt` | Transfer protocol: XFTP, SMP, LOCAL | +| `FileTransferMeta` | `model/ChatModel.kt` | Metadata for standalone XFTP uploads | +| `ComposePreview.FilePreview` | `views/chat/ComposeView.kt` | Compose state for file attachment | diff --git a/apps/multiplatform/product/flows/group-lifecycle.md b/apps/multiplatform/product/flows/group-lifecycle.md new file mode 100644 index 0000000000..60311f7b47 --- /dev/null +++ b/apps/multiplatform/product/flows/group-lifecycle.md @@ -0,0 +1,283 @@ +# Group Lifecycle Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Overview + +Groups in SimpleX Chat are decentralized: there is no central group server. The group owner's device coordinates membership, and messages are delivered via pairwise SMP connections between members. Groups support roles, invitation links, member admission review, blocking, and profile updates. + +## Prerequisites + +- Chat is initialized and running. +- An active user profile exists. +- For creating a group: no special requirements. +- For joining: a group invitation link or a direct invitation from an existing member. + +--- + +## 1. Creating a Group + +### 1.1 Create Group + +1. User navigates to "New Chat" and selects "Create Group". +2. The `AddGroupView` collects a group profile: display name, full name, optional image, and optional description. +3. `ChatController.apiNewGroup(rh, incognito, groupProfile)` is called: + +```kotlin +suspend fun apiNewGroup(rh: Long?, incognito: Boolean, groupProfile: GroupProfile): GroupInfo? +``` + +4. `CC.ApiNewGroup(userId, incognito, groupProfile)` is sent to the core. +5. The core creates the group and returns `CR.GroupCreated` with a `GroupInfo` object. +6. The creating user is automatically assigned the `Owner` role. +7. The new group appears in the chat list. +8. If `incognito = true`, a random profile is used for the user within this group. + +### 1.2 Update Group Profile + +```kotlin +suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? +``` + +1. Owner or Admin navigates to group info and edits the profile. +2. `CC.ApiUpdateGroupProfile(groupId, groupProfile)` is sent to the core. +3. On success, `CR.GroupUpdated` returns the updated `GroupInfo` with `toGroup`. +4. The chat model is updated via `chatModel.chatsContext.updateGroup(rh, groupInfo)`. +5. Profile changes are propagated to all connected members. + +--- + +## 2. Adding Members + +### 2.1 Invite a Contact + +1. Owner or Admin opens group info and taps "Add Members". +2. `AddGroupMembersView` displays the user's contacts eligible for invitation. +3. A role is selected for the invitee (default: `Member`). +4. `ChatController.apiAddMember(rh, groupId, contactId, memberRole)` is called: + +```kotlin +suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? +``` + +5. `CC.ApiAddMember(groupId, contactId, memberRole)` is sent to the core. +6. The core sends a group invitation to the contact via their direct SMP connection. +7. `CR.SentGroupInvitation` returns a `GroupMember` in `Invited` status. +8. The member list updates to show the pending invitation. + +### 2.2 Invitee Joins + +1. The invited contact receives a group invitation event. +2. A group invitation chat item appears in their chat list. +3. The invitee taps "Join" to accept. +4. `ChatController.apiJoinGroup(rh, groupId)` is called. +5. `CC.ApiJoinGroup(groupId)` is sent to the core. +6. `CR.UserAcceptedGroupSent` confirms the join request was sent. +7. The owner's/admin's device processes the join and establishes pairwise connections with existing members. +8. `CR.MemberConnected` events fire as connections to each member are established. + +--- + +## 3. Member Roles + +### 3.1 Role Hierarchy + +```kotlin +enum class GroupMemberRole(val memberRole: String) { + Observer("observer"), // Can only read messages + Author("author"), // Can send messages but limited + Member("member"), // Standard member + Moderator("moderator"), // Can moderate content + Admin("admin"), // Can manage members + Owner("owner") // Full control, can delete group +} +``` + +Selectable roles for assignment: `Observer`, `Member`, `Moderator`, `Admin`, `Owner`. + +### 3.2 Change Member Role + +```kotlin +suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole): List +``` + +1. Owner or Admin navigates to member info in `GroupMemberInfoView`. +2. Selects a new role from the role picker. +3. `CC.ApiMembersRole(groupId, memberIds, memberRole)` is sent to the core. +4. The core responds with `CR.MembersRoleUser` returning updated `GroupMember` objects. +5. The change is propagated to all group members. +6. Supports batch role changes (multiple `memberIds`). + +--- + +## 4. Removing and Blocking Members + +### 4.1 Remove Members + +```kotlin +suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean): Pair>? +``` + +1. Owner or Admin selects a member and taps "Remove". +2. `CC.ApiRemoveMembers(groupId, memberIds, withMessages)` is sent. +3. If `withMessages = true`, the removed member's messages are also deleted from all members. +4. `CR.UserDeletedMembers` returns the updated `GroupInfo` and removed `GroupMember` list. +5. The removed member receives a notification and loses access to the group. + +### 4.2 Block Members for All + +```kotlin +suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List, blocked: Boolean): List +``` + +1. Owner, Admin, or Moderator selects a member and taps "Block for all". +2. `CC.ApiBlockMembersForAll(groupId, memberIds, blocked)` is sent. +3. `blocked = true` blocks; `blocked = false` unblocks. +4. `CR.MembersBlockedForAllUser` returns the updated member list. +5. Blocked members' messages are hidden from all group members. +6. The blocked member can still see the group but their messages are not delivered. + +--- + +## 5. Group Links + +### 5.1 Create Group Link + +```kotlin +suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? +``` + +1. Owner or Admin navigates to group info and taps "Create Group Link". +2. `CC.APICreateGroupLink(groupId, memberRole)` is sent. +3. A default role for joiners is specified (default: `Member`). +4. `CR.GroupLinkCreated` returns a `GroupLink` containing the link URI. +5. The link is displayed in `GroupLinkView` as a QR code and copyable text. + +### 5.2 Update Group Link Role + +```kotlin +suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? +``` + +1. Owner or Admin changes the default role for new members joining via link. +2. `CC.APIGroupLinkMemberRole(groupId, memberRole)` is sent. +3. `CR.CRGroupLink` returns the updated link with the new default role. + +### 5.3 Get Group Link + +```kotlin +suspend fun apiGetGroupLink(rh: Long?, groupId: Long): GroupLink? +``` + +1. Retrieves the existing group link for display. +2. `CC.APIGetGroupLink(groupId)` is sent. +3. Returns `null` if no link exists. + +### 5.4 Delete Group Link + +```kotlin +suspend fun apiDeleteGroupLink(rh: Long?, groupId: Long): Boolean +``` + +1. Owner or Admin navigates to group link settings and taps "Delete Link". +2. `CC.APIDeleteGroupLink(groupId)` is sent. +3. `CR.GroupLinkDeleted` confirms deletion. +4. The link becomes invalid; anyone with the old link can no longer join. + +--- + +## 6. Member Admission Workflow + +### 6.1 Admission Configuration + +Group owners can require review of new members before they are fully admitted: + +```kotlin +data class GroupMemberAdmission( + val review: MemberCriteria? = null +) + +enum class MemberCriteria { + All // All joining members require review +} +``` + +1. Owner opens group info and navigates to "Member Admission" (`MemberAdmissionView`). +2. The `review` field is set to `MemberCriteria.All` to require review of all new members. +3. The admission configuration is saved by updating the group profile: + - `groupProfile.copy(memberAdmission = admission)` is passed to `apiUpdateGroup`. +4. Changes are tracked with unsaved-changes detection (save/discard prompt on navigation). + +### 6.2 Accept a Pending Member + +```kotlin +suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): Pair? +``` + +1. When admission review is enabled, new members joining via link arrive in a pending state. +2. Owner or Admin sees pending members in the member support chat / member list. +3. User selects "Accept" and optionally adjusts the role. +4. `CC.ApiAcceptMember(groupId, groupMemberId, memberRole)` is sent. +5. `CR.MemberAccepted` returns the updated `GroupInfo` and accepted `GroupMember`. +6. The member is now fully connected and can participate in the group. + +### 6.3 Reject a Pending Member + +1. Owner or Admin selects "Reject" on a pending member. +2. The member is removed via `apiRemoveMembers`. +3. The rejected member receives a removal notification. + +--- + +## 7. Leaving a Group + +```kotlin +suspend fun apiLeaveGroup(rh: Long?, groupId: Long): GroupInfo? +``` + +1. User navigates to group info and taps "Leave Group". +2. A confirmation dialog is shown. +3. `CC.ApiLeaveGroup(groupId)` is sent to the core. +4. `CR.LeftMemberUser` returns the updated `GroupInfo`. +5. The user's membership status changes and they can no longer send or receive messages. +6. The group remains in the chat list in a "left" state, and can be deleted locally. + +--- + +## 8. Listing Members + +```kotlin +suspend fun apiListMembers(rh: Long?, groupId: Long): List +``` + +1. When opening group info or the member list, `apiListMembers` is called. +2. `CC.ApiListMembers(groupId)` is sent to the core. +3. `CR.GroupMembers` returns the member list. +4. `ChatModel.groupMembers` and `ChatModel.groupMembersIndexes` are updated. +5. `ChatModel.membersLoaded` is set to `true`. + +--- + +## 9. Group Chat Scope (Support Channels) + +Groups support scoped conversations for member support: + +- `GroupChatScope` parameter on message APIs allows sending messages within a specific scope (e.g., member support chat). +- `MemberSupportChatView` and `MemberSupportView` provide UI for admin-to-member private conversations within the group context. +- `GroupReportsView` shows moderation reports scoped to the group. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `GroupInfo` | `model/ChatModel.kt` | Group metadata: groupId, groupProfile, membership, fullGroupPreferences | +| `GroupProfile` | `model/ChatModel.kt` | Group display info: displayName, fullName, description, image, memberAdmission | +| `GroupMember` | `model/ChatModel.kt` | Member info: groupMemberId, memberRole, memberStatus, memberProfile | +| `GroupMemberRole` | `model/ChatModel.kt` | Enum: Observer, Author, Member, Moderator, Admin, Owner | +| `GroupMemberAdmission` | `model/ChatModel.kt` | Admission settings: review criteria | +| `MemberCriteria` | `model/ChatModel.kt` | Enum: All (require review for all) | +| `GroupLink` | `model/SimpleXAPI.kt` | Group link: connLinkContact, acceptMemberRole, userContactLinkId, shortLinkDataSet, shortLinkLargeDataSet, groupLinkId | +| `GroupChatScope` | `model/ChatModel.kt` | Scoped conversation within a group | +| `ConnectionPlan.GroupLink` | `model/SimpleXAPI.kt` | Plan result when connecting via a group link | diff --git a/apps/multiplatform/product/flows/messaging.md b/apps/multiplatform/product/flows/messaging.md new file mode 100644 index 0000000000..771eae1c4e --- /dev/null +++ b/apps/multiplatform/product/flows/messaging.md @@ -0,0 +1,195 @@ +# Messaging Flow + +> **Related spec:** [spec/client/compose.md](../../spec/client/compose.md) | [spec/api.md](../../spec/api.md) + +## Overview + +Messaging is the core interaction in SimpleX Chat. Users compose and send text, images, video, voice notes, files, and link previews. Messages can be replied to, edited, deleted, forwarded, and reacted to with emoji. Special modes include timed (disappearing) messages, live messages (real-time typing), and message reports for moderation. + +All message operations flow through the Haskell core via `ChatController.apiSendMessages`, with responses updating `ChatModel` and triggering Compose UI recomposition. + +## Prerequisites + +- Chat is initialized and running (`ChatModel.chatRunning == true`). +- An active user exists (`ChatModel.currentUser != null`). +- A chat is open (`ChatModel.chatId != null`) with an established connection. + +--- + +## 1. Sending a Text Message + +### 1.1 Compose and Send + +1. User types in the compose field. `ComposeState.message` is updated as a `ComposeMessage(text, selection)`. +2. The compose area tracks context via `ComposeContextItem`: `NoContextItem` for a fresh message, `QuotedItem` for a reply, `EditingItem` for an edit, `ForwardingItems` for forwarding, or `ReportedItem` for a report. +3. User taps the send button. The `ComposeView` builds a `ComposedMessage`: + ```kotlin + class ComposedMessage( + val fileSource: CryptoFile?, + val quotedItemId: Long?, + val msgContent: MsgContent, + val mentions: Map + ) + ``` +4. For plain text, `msgContent` is `MsgContent.MCText(text)`. +5. `ChatController.apiSendMessages(rh, type, id, scope, live, ttl, composedMessages)` is called. +6. The core command `CC.ApiSendMessages` is dispatched via `sendCmd`. +7. On success, the response `CR.NewChatItems` returns a list of `AChatItem`. +8. `ChatModel` is updated and the chat item list recomposes to show the new message. +9. `ComposeState` is reset to its default. + +### 1.2 Link Preview + +1. As the user types, the text is parsed for URLs. +2. If `privacyLinkPreviews` preference is enabled and a URL is detected, a `LinkPreview` is fetched asynchronously. +3. The compose preview is set to `ComposePreview.CLinkPreview(linkPreview)`. +4. When sent, the `msgContent` is `MsgContent.MCLink(text, preview)`. + +--- + +## 2. Sending Media (Image, Video, Voice) + +### 2.1 Image + +1. User picks or captures an image. +2. The image is resized (max inline data size `MAX_IMAGE_SIZE` = 255 KB for the preview thumbnail). +3. The full-size file is saved to the app files directory. +4. If local file encryption is enabled (`privacyEncryptLocalFiles`), the file is encrypted via `encryptCryptoFile`, producing a `CryptoFile` with `CryptoFileArgs(fileKey, fileNonce)`. +5. Compose preview becomes `ComposePreview.MediaPreview(images, content)`. +6. On send, `msgContent` is `MsgContent.MCImage(text, imageBase64)` and `fileSource` is the `CryptoFile`. +7. The core handles inline delivery (for small files) or XFTP upload (for larger files). + +### 2.2 Video + +1. User picks or records a video. +2. A thumbnail image is extracted and resized. +3. The video file is saved and optionally encrypted. +4. On send, `msgContent` is `MsgContent.MCVideo(text, image, duration)`. + +### 2.3 Voice Message + +1. User records a voice note. Recording state is tracked via `RecordingState` (NotStarted, Started, Finished). +2. The compose preview becomes `ComposePreview.VoicePreview(voice, durationMs, finished)`. +3. On send, `msgContent` is `MsgContent.MCVoice(text, durationSeconds)`. +4. A file attachment carries the actual audio data. + +--- + +## 3. Sending Files + +1. User picks a file via the file chooser. +2. File size is validated against `MAX_FILE_SIZE_XFTP` (1 GB). +3. Compose preview becomes `ComposePreview.FilePreview(fileName, uri)`. +4. On send, `msgContent` is `MsgContent.MCFile(text)` and the `fileSource` is populated. +5. Delivery via inline (small files under SMP threshold) or XFTP (large files) is determined by the core. + +--- + +## 4. Receiving Messages + +1. The `ChatController` receiver loop calls `chatRecvMsgWait` on the Haskell core. +2. Incoming messages arrive as `CR.NewChatItems` events. +3. `ChatModel` chat items list is updated, triggering recomposition. +4. For media messages, images below `MAX_IMAGE_SIZE_AUTO_RCV` (510 KB), videos below `MAX_VIDEO_SIZE_AUTO_RCV` (1023 KB), and voice notes below `MAX_VOICE_SIZE_AUTO_RCV` (510 KB) are auto-received if `privacyAcceptImages` is enabled. +5. Larger files require manual download initiation (see File Transfer Flow). + +--- + +## 5. Editing a Message + +1. User long-presses a sent message and selects "Edit". +2. `ComposeContextItem` becomes `EditingItem(chatItem)`. +3. The original text populates the compose field. +4. On send, `ChatController.apiUpdateChatItem(rh, type, id, scope, itemId, updatedMessage, live)` is called. +5. `updatedMessage` is an `UpdatedMessage(msgContent, mentions)`. +6. The core responds with `CR.ChatItemUpdated` or `CR.ChatItemNotChanged`. +7. The chat item in `ChatModel` is updated in place. + +--- + +## 6. Deleting a Message + +1. User long-presses a message and selects "Delete". +2. A delete mode is chosen: `CIDeleteMode.cidmBroadcast` (delete for everyone), `CIDeleteMode.cidmInternal` (delete for self), or `CIDeleteMode.cidmInternalMark` (mark as deleted internally). +3. `ChatController.apiDeleteChatItems(rh, type, id, scope, itemIds, mode)` is called. +4. The core responds with `CR.ChatItemsDeleted`, returning a list of `ChatItemDeletion`. +5. For group chats by moderators, `apiDeleteMemberChatItems(rh, groupId, itemIds)` is used. +6. Deleted items are either removed from the UI or replaced with a "deleted" marker. + +--- + +## 7. Reacting to a Message + +1. User long-presses a message and selects an emoji reaction. +2. `ChatController.apiChatItemReaction(rh, type, id, scope, itemId, add, reaction)` is called. +3. `reaction` is a `MsgReaction` (typically emoji). +4. `add = true` to add, `add = false` to remove a reaction. +5. The core responds with `CR.ChatItemReaction`, and the chat item's reaction list is updated. +6. In groups, `apiGetReactionMembers` can be called to see who reacted. + +--- + +## 8. Replying to a Message + +1. User swipes or long-presses a message and selects "Reply". +2. `ComposeContextItem` becomes `QuotedItem(chatItem)`. +3. The quoted item preview is shown above the compose field. +4. On send, the `ComposedMessage.quotedItemId` is set to the quoted item's ID. +5. The sent message renders with the quoted content inline. + +--- + +## 9. Forwarding Messages + +1. User selects one or more messages and taps "Forward". +2. `ChatController.apiPlanForwardChatItems(rh, fromChatType, fromChatId, fromScope, chatItemIds)` is called first to get a `CR.ForwardPlan` with forwardable/non-forwardable item categorization. +3. `ComposeContextItem` becomes `ForwardingItems(chatItems, fromChatInfo)`. +4. User picks a destination chat. +5. `ChatController.apiForwardChatItems(rh, toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl)` is called. +6. New chat items are created in the destination chat. + +--- + +## 10. Timed (Disappearing) Messages + +1. Timed messages are enabled per-chat via chat feature preferences. +2. When composing, a TTL (time-to-live) in seconds is passed as the `ttl` parameter to `apiSendMessages`. +3. The core attaches the TTL to the message metadata. +4. After the TTL expires, the message is automatically deleted on both sides. +5. The UI shows a countdown indicator on timed messages via `CIMetaView`. + +--- + +## 11. Live Messages + +1. User enables live message mode (long-press on send button if `liveMessageAlertShown` preference allows). +2. `ComposeState.liveMessage` is set to a `LiveMessage(chatItem, typedMsg, sentMsg, sent)`. +3. As the user types, `apiSendMessages` is called with `live = true` for the initial send, then `apiUpdateChatItem` with `live = true` for subsequent updates. +4. The recipient sees the message content updating in real-time. +5. When the user finalizes (taps send), a final `apiUpdateChatItem` with `live = false` is sent. + +--- + +## 12. Message Reports + +1. User long-presses a message and selects "Report". +2. `ComposeContextItem` becomes `ReportedItem(chatItem, reason)` where `reason` is a `ReportReason`. +3. On send, `msgContent` is `MsgContent.MCReport(text, reason)`. +4. The report is sent to group owners/admins for moderation review. +5. Group admins see reports in the `GroupReportsView`. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `ComposeState` | `views/chat/ComposeView.kt` | Tracks compose field state | +| `ComposePreview` | `views/chat/ComposeView.kt` | Preview type: NoPreview, CLinkPreview, MediaPreview, VoicePreview, FilePreview | +| `ComposeContextItem` | `views/chat/ComposeView.kt` | Context: NoContextItem, QuotedItem, EditingItem, ForwardingItems, ReportedItem | +| `ComposedMessage` | `model/SimpleXAPI.kt` | Wire format for sending: fileSource, quotedItemId, msgContent, mentions | +| `UpdatedMessage` | `model/SimpleXAPI.kt` | Wire format for editing: msgContent, mentions | +| `MsgContent` | `model/ChatModel.kt` | Sealed class: MCText, MCLink, MCImage, MCVideo, MCVoice, MCFile, MCReport, MCChat, MCUnknown | +| `LiveMessage` | `views/chat/ComposeView.kt` | Tracks live message state | +| `MsgReaction` | `model/ChatModel.kt` | Emoji reaction type | +| `ChatItemDeletion` | `model/ChatModel.kt` | Deletion result with old/new item | diff --git a/apps/multiplatform/product/flows/onboarding.md b/apps/multiplatform/product/flows/onboarding.md new file mode 100644 index 0000000000..b6b3e835a5 --- /dev/null +++ b/apps/multiplatform/product/flows/onboarding.md @@ -0,0 +1,205 @@ +# Onboarding Flow + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/architecture.md](../../spec/architecture.md) + +## Overview + +Onboarding is the first-run experience that initializes the Haskell chat core, creates the local database, sets up the user profile, configures server operators, and (on Android) selects the notification mode. The flow is tracked by the `OnboardingStage` enum persisted in `AppPreferences.onboardingStage`. + +The initialization path differs slightly between Android and Desktop, but both converge on the common `chatMigrateInit` JNI call and shared `ChatController` logic. + +## Prerequisites + +- Fresh install or database reset. +- On Android: `SimplexApp.onCreate()` has been called. +- On Desktop: `main()` has been called. + +--- + +## 1. Platform Initialization + +### 1.1 Android: SimplexApp.onCreate() + +1. `SimplexApp.onCreate()` is called by the Android framework. +2. `AppContextProvider.initialize(this)` sets the application context. +3. Phoenix process detection: if this is a restart process, return early. +4. A global error handler is registered. +5. `initHaskell(packageName)` loads the native `libapp-lib.so` and calls `initHS()` to initialize the Haskell runtime. +6. `initMultiplatform()` sets up: + - `androidAppContext` reference. + - `ntfManager` (notification manager bridge to Android `NtfManager`). + - `platform` interface implementation with Android-specific callbacks for services, notifications, call management, and UI configuration. +7. `reconfigureBroadcastReceivers()` ensures notification-related receivers match saved preferences. +8. `runMigrations()` performs any pending app-level data migrations. +9. Temp directory is cleaned and recreated. +10. If a migration state exists (`chatModel.migrationState.value != null`), onboarding is forced to `Step1_SimpleXInfo`. +11. Otherwise, if authentication keys are available, `initChatControllerOnStart()` is called. + +### 1.2 Desktop: Main.kt main() + +1. `initHaskell()` loads native libraries: + - On Linux/macOS: `libapp-lib.so` / `libapp-lib.dylib`. + - On Windows: `libcrypto-3-x64.dll`, `libsimplex.dll`, `libapp-lib.dll` plus VLC libraries. +2. `initHS()` initializes the Haskell runtime. +3. `platform` interface is set with Desktop-specific callbacks (app update notice). +4. `runMigrations()` performs pending app-level data migrations. +5. `setupUpdateChecker()` configures the desktop update channel. +6. `initApp()` initializes common app state. +7. Temp directory is cleaned and recreated. +8. `showApp()` launches the Compose Desktop window, which renders the `AppView`. + +--- + +## 2. Database Initialization (chatMigrateInit) + +### 2.1 initChatController + +1. `initChatController(useKey, confirmMigrations, startChat)` is called (from `Core.kt`). +2. If `ctrlInitInProgress` is already true, return (prevents double initialization). +3. The database key is resolved: + - From `useKey` parameter if provided. + - Otherwise from `DatabaseUtils.useDatabaseKey()` which reads from the keystore. +4. Migration confirmation mode is determined: + - `MigrationConfirmation.YesUp` (auto-confirm forward migrations) by default. + - `MigrationConfirmation.Error` if developer tools + confirm upgrades are enabled. +5. `chatMigrateInit(dbPath, dbKey, confirm)` is called via JNI. This: + - Opens (or creates) the SQLite database at `dbAbsolutePrefixPath`. + - Runs all pending schema migrations. + - Returns a `ChatCtrl` handle (Long) and a `DBMigrationResult`. +6. On `DBMigrationResult.OK`: + - The `ChatCtrl` is stored globally. + - `ChatModel.chatDbStatus` is set. + - App file paths are configured via `apiSetAppFilePaths`. + - `apiGetActiveUser` checks for an existing user. +7. If an active user exists, `startChat(user)` is called. +8. If no user exists, `startChatWithoutUser()` is called and onboarding begins at `Step1_SimpleXInfo`. + +### 2.2 Error Handling + +- `DBMigrationResult.ErrorNotADatabase`: Wrong passphrase or corrupted DB. User is prompted. +- `DBMigrationResult.ErrorMigration`: Migration failed. Details shown to user. +- `DBMigrationResult.ErrorKeyNotSet`: Encryption key missing. +- `DBMigrationResult.InvalidConfirmation`: Migrations need manual confirmation (developer mode). +- On any error, `ChatModel.chatDbStatus` is set and the UI shows the appropriate database error screen. + +--- + +## 3. Onboarding Stages + +The onboarding flow is controlled by `OnboardingStage`, persisted in `AppPreferences.onboardingStage`: + +```kotlin +enum class OnboardingStage { + Step1_SimpleXInfo, + Step2_CreateProfile, + LinkAMobile, + Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, + Step3_CreateSimpleXAddress, + Step4_SetNotificationsMode, + OnboardingComplete +} +``` + +### 3.1 Step1_SimpleXInfo + +1. The `SimpleXInfo` screen is shown. +2. Explains what SimpleX Chat is: privacy, no user identifiers, decentralized. +3. User taps "Create your profile" to proceed. +4. On Desktop, a "Link a Mobile" option is also available. + +### 3.2 Step2_CreateProfile + +1. The `CreateProfile` screen is shown. +2. User enters a display name (validated via `chatValidName` JNI) and optional full name. +3. On submit, `ChatController.apiCreateActiveUser(rh, profile)` is called: + ```kotlin + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? + ``` +4. The core command `CC.CreateActiveUser(p, pastTimestamp)` creates the user in the database. +5. On success, `CR.ActiveUser` returns the new `User` object. +6. `ChatModel.currentUser` is set. +7. If the chat is not yet running, `startChat(user)` is called: + - `apiSetNetworkConfig` configures network settings. + - `apiStartChat` starts the message receiver. + - `startReceiver()` begins polling for incoming messages. +8. Onboarding advances to `Step3_ChooseServerOperators`. + +### 3.3 LinkAMobile (Desktop Only) + +1. Available as an alternative to creating a profile on Desktop. +2. Shows a QR code for linking with a mobile device. +3. The desktop acts as a remote host controlled by the mobile app. + +### 3.4 Step2_5_SetupDatabasePassphrase (Desktop Only) + +1. On Desktop, after profile creation, the user is optionally prompted to set a database passphrase. +2. If skipped, a random passphrase is used (`desktopOnboardingRandomPassword` flag). +3. `ChatModel.desktopOnboardingRandomPassword` tracks this state. + +### 3.5 Step3_ChooseServerOperators + +1. The `ChooseServerOperators` screen is shown. +2. User selects which preset server operators to use for messaging and file transfer. +3. Server operator conditions may need to be accepted. +4. The selection is saved via the server configuration APIs. + +### 3.6 Step3_CreateSimpleXAddress + +1. User is prompted to create a SimpleX address for receiving contact requests. +2. This calls the address creation API. +3. Can be skipped. + +### 3.7 Step4_SetNotificationsMode (Android Only) + +1. The `SetNotificationsMode` screen is shown. +2. Three modes are available: + - `NotificationsMode.SERVICE`: Persistent background service (instant notifications). + - `NotificationsMode.PERIODIC`: Periodic background work (delayed notifications). + - `NotificationsMode.OFF`: No background processing (manual check only). +3. On selection, `appPrefs.notificationsMode` is set. +4. On Desktop, this step is skipped entirely. + +### 3.8 OnboardingComplete + +1. `appPrefs.onboardingStage` is set to `OnboardingComplete`. +2. The chat list view (`ChatListView`) is shown. +3. On Android, `SimplexService.showBackgroundServiceNoticeIfNeeded()` may show additional setup prompts. +4. On Android with `NotificationsMode.SERVICE`, `SimplexService.start()` is called. + +--- + +## 4. startChat Flow + +After the user is created and onboarding progresses, `ChatController.startChat(user)` orchestrates the final setup: + +1. `apiSetNetworkConfig(getNetCfg())` applies network configuration. +2. `apiCheckChatRunning()` checks if the core is already running. +3. `listUsers(null)` loads all user profiles into `ChatModel.users`. +4. If chat is not running: + - `ChatModel.currentUser` is set. + - `apiStartChat()` starts the core's message processing. + - `startReceiver()` begins the message receive loop. + - `setLocalDeviceName` sets the device name for remote access. +5. `apiGetChats` loads the chat list. +6. `chatModel.chatsContext.updateChats(chats)` populates the UI. +7. User address and chat item TTL are loaded. +8. `appPrefs.chatLastStart` is updated. +9. `ChatModel.chatRunning` is set to `true`. +10. `platform.androidChatInitializedAndStarted()` is called for Android-specific post-start tasks. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `OnboardingStage` | `views/onboarding/OnboardingView.kt` | Enum tracking onboarding progress | +| `SimplexApp` | `android/.../SimplexApp.kt` | Android Application class, entry point | +| `Main.kt` | `desktop/.../Main.kt` | Desktop entry point | +| `ChatController` | `model/SimpleXAPI.kt` | Core API controller, manages chat lifecycle | +| `ChatModel` | `model/ChatModel.kt` | Global observable state | +| `DBMigrationResult` | `views/helpers/DatabaseUtils.kt` | Database migration outcome | +| `chatMigrateInit` | `platform/Core.kt` | JNI function: initialize DB and run migrations | +| `initChatController` | `platform/Core.kt` | High-level initialization orchestrator | +| `AppPreferences` | `model/SimpleXAPI.kt` | Persistent user preferences | diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md new file mode 100644 index 0000000000..25535d8003 --- /dev/null +++ b/apps/multiplatform/product/gaps.md @@ -0,0 +1,290 @@ +# Known Gaps & Recommendations -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This document catalogs known gaps in the multiplatform codebase (Android and Desktop) with severity, impact, and recommendations. + +--- + +## Table of Contents + +1. [UI: Error Feedback](#gap-01-ui-error-feedback) +2. [UI: Loading States](#gap-02-ui-loading-states) +3. [Security: Database Passphrase Not Enforced](#gap-03-security-database-passphrase-not-enforced) +4. [Security: No Forward Secrecy Indicator](#gap-04-security-no-forward-secrecy-indicator) +5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified) +6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented) +7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) + +--- + +## GAP-01: UI Error Feedback + +**Severity:** Medium +**Category:** UI / UX +**Platforms:** Android, Desktop + +### Description + +Many API calls through `ChatController.sendCmd()` return `API.Error` responses that are logged but not surfaced to the user. The general pattern is: + +```kotlin +val r = sendCmd(rh, cmd) +if (r is API.Result && r.res is CR.ExpectedResponse) return r.res.value +Log.e(TAG, "someFunction bad response: ${r.responseType} ${r.details}") +return null +``` + +When the call fails, the caller receives `null` and either silently does nothing or shows a generic error. The specific `ChatError` details (which may contain actionable information like quota exceeded, server unreachable, or store errors) are lost to the user. + +### Affected Locations + +- `SimpleXAPI.kt` -- `getAgentSubsTotal()`, `getAgentServersSummary()`, and dozens of similar `api*` functions +- Throughout the codebase wherever `sendCmd` results are pattern-matched + +### Impact + +Users experience silent failures with no indication of what went wrong. This is particularly problematic for: +- Connection attempts that fail due to network issues +- File transfer failures +- Group operations that fail due to role permissions +- Server configuration errors + +### Recommendation + +1. Introduce a structured error-handling utility that maps `ChatError` subtypes to user-visible messages, similar to how `retryableNetworkErrorAlert` already handles a subset of `AgentErrorType.BROKER` errors. +2. At minimum, surface a dismissible snackbar/toast with a summary when an API call fails unexpectedly. +3. For critical operations (send message, join group, create connection), show a dialog with retry/cancel options (the `sendCmdWithRetry` pattern already exists for some cases -- extend it). + +--- + +## GAP-02: UI Loading States + +**Severity:** Low-Medium +**Category:** UI / UX +**Platforms:** Android, Desktop + +### Description + +Several long-running operations lack loading indicators, leaving the user uncertain whether the action is in progress. The `ComposeState.inProgress` flag and `progressByTimeout` mechanism exist for the compose area, and `ConnectProgressManager` handles connection progress, but many other flows have no visual feedback. + +### Affected Locations + +- Group member list loading (`ChatModel.membersLoaded` exists but is not always checked before displaying stale data) +- Server configuration validation (`ApiValidateServers` can take several seconds with no indicator) +- Database export/import (`ApiExportArchive`, `ApiImportArchive`) +- Profile switching (`changeActiveUser_` acquires `changingActiveUserMutex` but the UI may appear frozen) + +### Impact + +Users may tap actions multiple times, causing duplicate requests, or assume the app is frozen and force-quit during a long operation like database export. + +### Recommendation + +1. Introduce a centralized `ProgressOverlay` composable that can be shown/hidden via a `ChatModel` flag. +2. Wrap all operations that acquire `changingActiveUserMutex` or take > 1 second with a visible loading state. +3. Use `ChatModel.switchingUsersAndHosts` (which already exists) more consistently as a gate for showing a blocking progress indicator. + +--- + +## GAP-03: Security: Database Passphrase Not Enforced + +**Severity:** High +**Category:** Security +**Platforms:** Android, Desktop + +### Description + +When the app is first installed, a random database passphrase is generated and stored in encrypted preferences. The user is never required to set a custom passphrase. The `initialRandomDBPassphrase` flag tracks this state, and a setup prompt exists in onboarding (`SetupDatabasePassphrase`), but the user can skip it. + +On Android, the encrypted passphrase is stored via the Android Keystore, which provides hardware-backed security. On Desktop, the `Cryptor` is a **placeholder** (see GAP-07), meaning the passphrase is stored in plaintext. + +### Affected Locations + +- `SimpleXAPI.kt` -- `AppPreferences.storeDBPassphrase`, `AppPreferences.initialRandomDBPassphrase`, `AppPreferences.encryptedDBPassphrase` +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt` + +### Impact + +- Users who skip passphrase setup rely entirely on device security. If the device is compromised, the database can be decrypted using the stored passphrase. +- On Desktop, the passphrase is effectively stored in plaintext (see GAP-07), meaning anyone with filesystem access can read the database. + +### Recommendation + +1. Consider making passphrase setup mandatory during onboarding (or at least prominently warn users who skip it). +2. On Desktop, implement proper key storage (GAP-07) before any passphrase enforcement is meaningful. +3. Add a periodic reminder for users who still have `initialRandomDBPassphrase == true`. + +--- + +## GAP-04: Security: No Forward Secrecy Indicator + +**Severity:** Medium +**Category:** Security / UI +**Platforms:** Android, Desktop + +### Description + +The double-ratchet algorithm provides forward secrecy per message, and PQ key exchange provides resistance to quantum attacks. The `Connection` type tracks `pqSupport`, `pqEncryption`, `pqSndEnabled`, and `pqRcvEnabled`. However, the UI does not prominently display the current forward secrecy state or PQ encryption status for a given conversation. + +### Affected Locations + +- `ChatModel.kt` -- `Connection.pqSupport`, `Connection.pqEncryption`, `Connection.pqSndEnabled`, `Connection.pqRcvEnabled` +- Contact info views, group member info views + +### Impact + +Users cannot easily verify whether their conversations are using PQ-enhanced encryption. Security-conscious users have no visual indicator of the ratchet state or whether PQ key exchange was successful. + +### Recommendation + +1. Add a security badge/icon in the chat header or contact info screen showing: + - Whether PQ key exchange is active (both peers support it) + - Whether the connection has been verified (security code comparison) + - The ratchet state (in-sync vs. needs re-sync) +2. The `connectionCode` field on `Connection` can be used to show verification status. +3. The `Call.encryptionStatus` pattern (used in call views) could be adapted for the chat view. + +--- + +## GAP-05: Documentation: Haskell Store Layer Not Fully Specified + +**Severity:** Medium +**Category:** Documentation / Architecture +**Platforms:** Android, Desktop + +### Description + +The Kotlin client communicates with the Haskell core via a text-based command protocol (`CC.cmdString` -> FFI -> Haskell). The Haskell store layer (SQLite operations, migration logic, and the exact semantics of `StoreError` variants) is not documented from the Kotlin side. The `ChatErrorStore` error type wraps a `StoreError` whose variants are defined in Haskell and deserialized by the Kotlin client, but the conditions under which each error occurs are not specified. + +### Affected Locations + +- `SimpleXAPI.kt:6986` -- `ChatErrorStore(storeError: StoreError)` +- `SimpleXAPI.kt` -- `StoreError` sealed class (deserialized from Haskell responses) +- `SimpleXAPI.kt` -- `ChatErrorDatabase(databaseError: DatabaseError)` for migration errors + +### Impact + +- Developers cannot predict which `StoreError` will occur for a given operation without reading the Haskell source. +- Error handling in the Kotlin layer is necessarily generic since the error semantics are not specified. +- Migration failures (`ChatErrorDatabase`) are particularly opaque. + +### Recommendation + +1. Create a specification document mapping each `CC` command to its possible `StoreError` / `DatabaseError` responses. +2. Document the database migration versioning scheme and the conditions under which `confirmDBUpgrades` is triggered. +3. Add inline documentation to the `StoreError` sealed class variants explaining their trigger conditions. + +--- + +## GAP-06: Desktop: Recording Not Implemented + +**Severity:** High +**Category:** Feature / Platform +**Platform:** Desktop only + +### Description + +The `RecorderNative` class on Desktop is a placeholder. Both `start()` and `stop()` are stubbed with `/*LALAL*/` comments and return dummy values (empty string and 0, respectively). Users cannot record voice messages on Desktop. + +```kotlin +// common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +actual class RecorderNative: RecorderInterface { + override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { + /*LALAL*/ + return "" + } + + override fun stop(): Int { + /*LALAL*/ + return 0 + } +} +``` + +Audio playback IS implemented on Desktop (via VLC/`vlcj` library), so received voice messages can be played. Only recording is missing. + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt:15-25` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt` -- `RecorderInterface` + +### Impact + +Desktop users cannot send voice messages. The record button either does nothing or produces a zero-length file. + +### Recommendation + +1. Implement `RecorderNative` using a JVM audio capture library (e.g., `javax.sound.sampled`, or integrate with the existing `vlcj` dependency for capture). +2. The output format should match the mobile app's voice message format (likely Opus in an OGG container) for cross-platform compatibility. +3. Until implemented, the record button should be hidden or disabled on Desktop with a tooltip explaining the limitation. + +### Additional Desktop LALAL Placeholders + +Several other Desktop features are also marked with `LALAL` placeholders: +- **QR Code Scanner** (`QRCodeScanner.desktop.kt:12`) -- scanning QR codes is not implemented on Desktop +- **Animated Drawables** (`Utils.desktop.kt:179`) -- animated image support (e.g., GIF in-line rendering) is not implemented +- **Animated Chat Images** (`CIImageView.desktop.kt:19`) -- animated image rendering in chat items +- **isImage detection** (`Images.desktop.kt:168`) -- image type detection (implemented but marked as incomplete) + +--- + +## GAP-07: Desktop: Cryptor Not Implemented + +**Severity:** Critical +**Category:** Security / Platform +**Platform:** Desktop only + +### Description + +The `CryptorInterface` implementation on Desktop is a non-functional placeholder. All three methods are stubbed: + +```kotlin +// common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt +actual val cryptor: CryptorInterface = object : CryptorInterface { + override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? { + return String(data) // LALAL + } + + override fun encryptText(text: String, alias: String): Pair { + return text.toByteArray() to text.toByteArray() // LALAL + } + + override fun deleteKey(alias: String) { + // LALAL + } +} +``` + +- `decryptData` returns the data as-is (no decryption) +- `encryptText` returns the plaintext as both "encrypted data" and "IV" +- `deleteKey` is a no-op + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` -- `CryptorInterface` +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` -- uses `cryptor` for passphrase encryption + +### Impact + +**This is a critical security gap.** On Desktop: +- The database passphrase is stored **in plaintext** in the preferences file. Anyone with read access to the user's home directory can extract the passphrase and decrypt the database. +- The self-destruct passphrase is similarly stored in plaintext. +- The app passphrase (for local authentication) provides no real protection. +- Key deletion is a no-op, so "deleting" a key has no effect. + +This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self-Destruct Profile) on the Desktop platform. + +### Recommendation + +1. **Priority: Critical.** Implement proper key storage on Desktop using one of: + - **OS Keychain integration:** macOS Keychain, Windows Credential Manager, Linux Secret Service (via `libsecret`/GNOME Keyring/KWallet) + - **Java Cryptography Architecture (JCA)** with a PKCS#12 keystore file protected by a master password + - **Bouncy Castle** library for platform-independent key management +2. Until a real implementation exists, display a prominent warning to Desktop users that their database passphrase is not securely stored. +3. Consider requiring the user to enter their passphrase on each app launch (do not store it) as an interim measure. + +### Related + +- GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop. +- The `testCrypto()` function referenced in `AppCommon.desktop.kt:39` is commented out with a `// LALAL` marker, suggesting crypto testing was planned but never completed. diff --git a/apps/multiplatform/product/glossary.md b/apps/multiplatform/product/glossary.md new file mode 100644 index 0000000000..10203d8a2a --- /dev/null +++ b/apps/multiplatform/product/glossary.md @@ -0,0 +1,561 @@ +# Domain Term Glossary -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This glossary is self-contained and covers the Android and Desktop (Kotlin/Compose Multiplatform) codebase only. + +--- + +## Table of Contents + +1. [Protocols & Cryptography](#1-protocols--cryptography) +2. [Core Data Types](#2-core-data-types) +3. [Commands & Events](#3-commands--events) +4. [Connection & Identity](#4-connection--identity) +5. [Messaging Features](#5-messaging-features) +6. [Calling & Media](#6-calling--media) +7. [Notifications & Background](#7-notifications--background) +8. [Application Architecture](#8-application-architecture) +9. [Configuration & Preferences](#9-configuration--preferences) + +--- + +## 1. Protocols & Cryptography + +### SMP (SimpleX Messaging Protocol) +The core message-relay protocol. Clients send and receive messages through SMP relay servers without exposing sender/receiver identity correlation. The protocol uses unidirectional queues -- each contact pair maintains separate send and receive queues on potentially different servers. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `SMPErrorType`, `SMPProxyMode`, `SMPProxyFallback`, `SMPWebPortServers` + +### XFTP (SimpleX File Transfer Protocol) +Protocol for transferring files through relay servers. Files are chunked, encrypted, and uploaded to XFTP relays. Recipients download chunks and reassemble locally. Supports inline transfer for small files. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `CC.ApiUploadStandaloneFile`, `CC.ApiDownloadStandaloneFile`, `CC.ApiStandaloneFileInfo` + +### E2E Encryption (End-to-End Encryption) +All messages are encrypted end-to-end. The app never transmits plaintext to relay servers. Encryption keys are negotiated during connection establishment using X3DH-like key agreement and then maintained via the double-ratchet algorithm. + +### Double Ratchet +The core key-management algorithm. After initial key agreement, each message derives a new symmetric key, providing forward secrecy per message. Ratchet state can be re-synchronized via `APISyncContactRatchet` / `APISyncGroupMemberRatchet` commands. + +*See:* `SimpleXAPI.kt` -- `CC.APISyncContactRatchet(contactId, force)`, `CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)`, `CR.ContactRatchetSync`, `CR.GroupMemberRatchetSync` + +### PQ (Post-Quantum) +Post-quantum key exchange support. Connections track PQ state via `Connection.pqSupport`, `Connection.pqEncryption`, `Connection.pqSndEnabled`, and `Connection.pqRcvEnabled` fields. When both peers support PQ, the key exchange incorporates a post-quantum KEM to resist future quantum attacks. + +*See:* `ChatModel.kt` -- `Connection.pqSupport`, `Connection.pqEncryption`; `SimpleXAPI.kt` -- `SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED` (legacy, no longer used) + +### SMP Proxy / Private Routing +Messages can be sent through an intermediate SMP proxy relay to hide the sender's IP from the destination relay. Controlled by `SMPProxyMode` (Always, Unknown, Unprotected, Never) and `SMPProxyFallback` (Allow, AllowProtected, Prohibit). + +*See:* `SimpleXAPI.kt` -- `AppPreferences.networkSMPProxyMode`, `AppPreferences.networkSMPProxyFallback` + +### Transport Session Mode +Controls how TCP sessions to SMP relays are multiplexed. Options: `User` (one session per user profile), `Session` (single shared session), `Server` (one per server), `Entity` (one per queue/entity -- maximum metadata protection). + +*See:* `SimpleXAPI.kt` -- `AppPreferences.networkSessionMode`, `TransportSessionMode` + +--- + +## 2. Core Data Types + +### ChatItem +A single item in a conversation -- a sent or received message, call event, group event, connection event, feature change, or moderation action. Contains direction (`CIDirection`), metadata (`CIMeta`), content (`CIContent`), optional formatted text, mentions, quoted item, reactions, and file attachment. + +*See:* `ChatModel.kt:2720` -- `data class ChatItem` + +### ChatInfo +The top-level discriminated union representing a conversation. Variants: +- `ChatInfo.Direct` -- wraps a `Contact` +- `ChatInfo.Group` -- wraps a `GroupInfo` +- `ChatInfo.Local` -- wraps a `NoteFolder` (saved messages / notes to self) +- `ChatInfo.ContactRequest` -- wraps a `UserContactRequest` +- `ChatInfo.ContactConnection` -- wraps a `PendingContactConnection` +- `ChatInfo.InvalidJSON` -- fallback for unrecognized data + +*See:* `ChatModel.kt:1391` -- `sealed class ChatInfo` + +### CIContent (Chat Item Content) +The content payload of a `ChatItem`. Over 30 variants including: +- `SndMsgContent` / `RcvMsgContent` -- regular message with `MsgContent` +- `SndCall` / `RcvCall` -- call event with status and duration +- `RcvIntegrityError` -- message integrity violation +- `RcvDecryptionError` -- decryption failure with error type and count +- `RcvGroupInvitation` / `SndGroupInvitation` -- group invite +- `RcvGroupEventContent` / `SndGroupEventContent` -- group lifecycle events +- `RcvChatFeature` / `SndChatFeature` -- per-chat feature toggle notifications +- `SndModerated` / `RcvModerated` / `RcvBlocked` -- moderation events +- `RcvDirectEventContent` -- direct chat lifecycle events + +*See:* `ChatModel.kt:3554` -- `sealed class CIContent` + +### MsgContent +The wire-format message body. Variants: `MCText`, `MCLink`, `MCImage`, `MCVideo`, `MCVoice`, `MCFile`, `MCReport`, `MCUnknown`. Each carries text plus optional media/file metadata. + +*See:* `ChatModel.kt` -- `sealed class MsgContent` + +### User +The local user profile. Fields: `userId`, `userContactId`, `localDisplayName`, `profile` (LocalProfile), `fullPreferences` (FullChatPreferences), `activeUser`, `activeOrder`, `showNtfs`, `sendRcptsContacts`, `sendRcptsSmallGroups`, `viewPwdHash` (for hidden profiles), `uiThemes`, `remoteHostId` (Long?), `autoAcceptMemberContacts` (Boolean). + +*See:* `ChatModel.kt:1208` -- `data class User` + +### Contact +A remote contact. Fields: `contactId`, `localDisplayName`, `profile` (LocalProfile), `activeConn` (Connection?), `viaGroup`, `contactUsed`, `contactStatus`, `chatSettings`, `userPreferences`, `mergedPreferences`, `preparedContact`, `contactRequestId`, `contactGroupMemberId`, `chatTags`, `chatItemTTL`. + +*See:* `ChatModel.kt:1711` -- `data class Contact` + +### GroupInfo +Metadata for a group conversation. Fields: `groupId`, `localDisplayName`, `groupProfile` (GroupProfile), `businessChat` (BusinessChatInfo?), `fullGroupPreferences`, `membership` (GroupMember -- the local user's membership), `chatSettings`, `preparedGroup`, `membersRequireAttention`, `chatTags`, `chatItemTTL`. + +*See:* `ChatModel.kt:2004` -- `data class GroupInfo` + +### GroupMember +A member of a group. Fields: `groupMemberId`, `groupId`, `memberId`, `memberRole` (GroupMemberRole), `memberCategory` (GroupMemberCategory), `memberStatus` (GroupMemberStatus), `memberSettings` (GroupMemberSettings), `blockedByAdmin`, `invitedBy`, `localDisplayName`, `memberProfile`, `memberContactId`, `memberContactProfileId`, `activeConn` (Connection?), `supportChat` (GroupSupportChat?). + +*See:* `ChatModel.kt:2177` -- `data class GroupMember` + +### GroupMemberRole +Enumeration of group roles, ordered for comparison: `Observer` < `Author` < `Member` < `Moderator` < `Admin` < `Owner`. Selectable roles for assignment: Observer, Member, Moderator, Admin, Owner. + +*See:* `ChatModel.kt:2369` -- `enum class GroupMemberRole` + +### Connection +An active or pending cryptographic connection to a peer. Fields: `connId`, `agentConnId`, `peerChatVRange` (VersionRange), `connStatus` (ConnStatus), `connLevel`, `viaGroupLink`, `customUserProfileId`, `connectionCode` (SecurityCode?), `pqSupport`, `pqEncryption`, `pqSndEnabled`, `pqRcvEnabled`, `connectionStats`, `authErrCounter`, `quotaErrCounter`. + +*See:* `ChatModel.kt:1882` -- `data class Connection` + +### Chat +A composite type holding `chatInfo` (ChatInfo), `chatItems` (list of ChatItem), and `chatStats` (ChatStats -- unread count, min unread item ID, etc.). Represents a full conversation for the chat list. + +*See:* `ChatModel.kt` -- `data class Chat` + +### PendingContactConnection +Represents an in-progress connection that has not yet been established. Contains the connection link and state but no contact profile yet. + +*See:* `ChatModel.kt` -- referenced in `ChatInfo.ContactConnection` + +### CryptoFile +A file reference that optionally carries `CryptoFileArgs` (key + nonce) for local encryption. `CryptoFile.plain(path)` creates an unencrypted reference. + +*See:* `ChatModel.kt` -- `data class CryptoFile` + +--- + +## 3. Commands & Events + +The codebase uses short type names for the command/event protocol: `CC` (Chat Command), `CR` (Chat Response -- also carries asynchronous events), `API` (top-level response wrapper), and `ChatError` (error hierarchy). There is no separate "ChatEvent" class; asynchronous events from the core (new messages, connection changes, call signaling) are all `CR` subclasses received via the `recvMsg` loop. + +### CC (Chat Command) +The sealed class representing all commands the app can send to the Haskell core library. Over 140 command variants organized by domain: + +**User management:** `ShowActiveUser`, `CreateActiveUser`, `ListUsers`, `ApiSetActiveUser`, `ApiHideUser`, `ApiUnhideUser`, `ApiMuteUser`, `ApiUnmuteUser`, `ApiDeleteUser` + +**Chat lifecycle:** `StartChat`, `CheckChatRunning`, `ApiStopChat`, `ApiSetAppFilePaths`, `ApiSetEncryptLocalFiles` + +**Database:** `ApiExportArchive`, `ApiImportArchive`, `ApiDeleteStorage`, `ApiStorageEncryption`, `TestStorageEncryption` + +**Messaging:** `ApiSendMessages`, `ApiUpdateChatItem`, `ApiDeleteChatItem`, `ApiDeleteMemberChatItem`, `ApiChatItemReaction`, `ApiForwardChatItems`, `ApiPlanForwardChatItems`, `ApiReportMessage` + +**Groups:** `ApiNewGroup`, `ApiAddMember`, `ApiJoinGroup`, `ApiAcceptMember`, `ApiMembersRole`, `ApiBlockMembersForAll`, `ApiRemoveMembers`, `ApiLeaveGroup`, `ApiListMembers`, `ApiUpdateGroupProfile`, `APICreateGroupLink`, `APIDeleteGroupLink`, `APIGetGroupLink`, `ApiAddGroupShortLink` + +**Connections:** `APIAddContact`, `APIConnect`, `APIConnectPlan`, `APIPrepareContact`, `APIPrepareGroup`, `APIConnectPreparedContact`, `APIConnectPreparedGroup`, `ApiConnectContactViaAddress` + +**Contacts:** `ApiDeleteChat`, `ApiClearChat`, `ApiListContacts`, `ApiUpdateProfile`, `ApiSetContactPrefs`, `ApiSetContactAlias` + +**Address:** `ApiCreateMyAddress`, `ApiDeleteMyAddress`, `ApiShowMyAddress`, `ApiAddMyAddressShortLink`, `ApiSetProfileAddress`, `ApiSetAddressSettings` + +**Calls:** `ApiGetCallInvitations`, `ApiSendCallInvitation`, `ApiRejectCall`, `ApiSendCallOffer`, `ApiSendCallAnswer`, `ApiSendCallExtraInfo`, `ApiEndCall`, `ApiCallStatus` + +**Server config:** `ApiGetServerOperators`, `ApiSetServerOperators`, `ApiGetUserServers`, `ApiSetUserServers`, `ApiValidateServers`, `APITestProtoServer` + +**Network:** `APISetNetworkConfig`, `APIGetNetworkConfig`, `APISetNetworkInfo`, `ReconnectServer`, `ReconnectAllServers` + +**Files:** `ReceiveFile`, `CancelFile`, `ApiUploadStandaloneFile`, `ApiDownloadStandaloneFile`, `ApiStandaloneFileInfo` + +**Remote access:** `SetLocalDeviceName`, `ListRemoteHosts`, `StartRemoteHost`, `SwitchRemoteHost`, `StopRemoteHost`, `DeleteRemoteHost`, `StoreRemoteFile`, `GetRemoteFile`, `ConnectRemoteCtrl`, `FindKnownRemoteCtrl`, `ConfirmRemoteCtrl`, `VerifyRemoteCtrlSession`, `ListRemoteCtrls`, `StopRemoteCtrl`, `DeleteRemoteCtrl` + +**Read status:** `ApiChatRead`, `ApiChatItemsRead`, `ApiChatUnread` + +**Settings:** `APISetChatSettings`, `ApiSetMemberSettings`, `APISetChatItemTTL`, `APIGetChatItemTTL`, `APISetChatTTL`, `ApiSaveSettings`, `ApiGetSettings` + +**Ratchet & verification:** `APISwitchContact`, `APISwitchGroupMember`, `APIAbortSwitchContact`, `APIAbortSwitchGroupMember`, `APISyncContactRatchet`, `APISyncGroupMemberRatchet`, `APIGetContactCode`, `APIGetGroupMemberCode`, `APIVerifyContact`, `APIVerifyGroupMember` + +Each command variant has a `cmdString` property that serializes it to the text protocol consumed by the Haskell FFI. + +*See:* `SimpleXAPI.kt:3529` -- `sealed class CC` + +### CR (Chat Response) +The sealed class representing all responses / events received from the Haskell core. Over 130 response types. Examples: + +- `ActiveUser`, `UsersList` -- user management results +- `ChatStarted`, `ChatRunning`, `ChatStopped` -- lifecycle +- `ApiChats`, `ApiChat` -- chat list data +- `NewChatItems`, `ChatItemUpdated`, `ChatItemsDeleted` -- message events +- `ContactConnected`, `ContactConnecting`, `ContactSndReady` -- connection lifecycle +- `GroupCreated`, `ReceivedGroupInvitation`, `JoinedGroupMemberConnecting`, `MemberAccepted` -- group events +- `RcvFileStart`, `RcvFileComplete`, `SndFileComplete` -- file transfer progress +- `CallInvitation`, `CallOffer`, `CallAnswer`, `CallExtraInfo`, `CallEnded` -- call signaling +- `ChatError` -- error wrapper + +*See:* `SimpleXAPI.kt:6114` -- `sealed class CR` + +### API +The top-level response wrapper. Two variants: +- `API.Result(remoteHostId, res: CR)` -- successful response +- `API.Error(remoteHostId, err: ChatError)` -- error response + +Properties: `ok` (Boolean -- true if `CR.CmdOk`), `result` (CR?), `rhId` (Long? -- remote host ID). + +*See:* `SimpleXAPI.kt:5975` -- `sealed class API` + +### ChatError +The error hierarchy returned from the Haskell core: +- `ChatErrorChat(errorType: ChatErrorType)` -- application-level errors (NoActiveUser, UserUnknown, DifferentActiveUser, etc.) +- `ChatErrorAgent(agentError: AgentErrorType)` -- SMP agent errors (BROKER, SMP, PROXY, etc.) +- `ChatErrorStore(storeError: StoreError)` -- database/store errors +- `ChatErrorDatabase(databaseError: DatabaseError)` -- database migration/encryption errors +- `ChatErrorRemoteHost(remoteHostError)` -- remote host control errors +- `ChatErrorRemoteCtrl(remoteCtrlError)` -- remote controller errors +- `ChatErrorInvalidJSON(json)` -- parse failure + +*See:* `SimpleXAPI.kt:6974` -- `sealed class ChatError` + +### sendCmd / recvMsg +The core FFI bridge. `sendCmd(rhId, cmd)` serializes a `CC` command and sends it to the Haskell backend via `chatSendCmd`. `recvMsg(ctrl)` blocks on `chatRecvMsg` to receive the next `API` response/event. The receiver loop runs in `ChatController.startReceiver()` on `Dispatchers.IO`. + +*See:* `SimpleXAPI.kt` -- `ChatController.sendCmd()`, `ChatController.startReceiver()` + +--- + +## 4. Connection & Identity + +### SimpleX Address (User Address) +A long-lived contact address that others can use to send connection requests. Created via `ApiCreateMyAddress`, retrieved via `ApiShowMyAddress`, deleted via `ApiDeleteMyAddress`. Can optionally include a short link (`ApiAddMyAddressShortLink`). Stored as `ChatModel.userAddress` (`UserContactLinkRec`). + +### Contact Link / Connection Link +A one-time or reusable invitation link. The `CreatedConnLink` type wraps the link string. Contact links can be one-time (single use) or long-lived (user address). Created via `APIAddContact` (one-time) or `ApiCreateMyAddress` (reusable). + +### Group Link +A reusable invitation link for joining a group. Created via `APICreateGroupLink(groupId, memberRole)`. The default role for new members joining via the link is configurable. Can also have a short link variant via `ApiAddGroupShortLink`. + +### Short Link +A compact form of a contact or group link. Created via `ApiAddMyAddressShortLink` (for user addresses) or `ApiAddGroupShortLink` (for groups). Short links resolve to the full connection link data including `ContactShortLinkData` or `GroupShortLinkData`. + +### Incognito Mode +When enabled (`AppPreferences.incognito`), the app generates a random profile name for new connections instead of using the user's real profile. Each connection gets a unique random identity. The `customUserProfileId` on a `Connection` tracks which incognito profile is used for that connection. + +*See:* `SimpleXAPI.kt` -- `AppPreferences.incognito`; `ChatModel.kt` -- `Connection.customUserProfileId` + +### Hidden Profile +A user profile protected by a password (`viewPwdHash`). Hidden profiles do not appear in the profile list unless unlocked with the password. Created via `ApiHideUser(userId, viewPwd)`, revealed via `ApiUnhideUser(userId, viewPwd)`. When switching away from a hidden profile, its notifications are cancelled. + +*See:* `SimpleXAPI.kt` -- `CC.ApiHideUser`, `CC.ApiUnhideUser`; `ChatModel.kt` -- `User.viewPwdHash` + +### Connection Verification (Security Code) +Each connection has an optional `SecurityCode` (`Connection.connectionCode`). Users can verify connections out-of-band by comparing security codes displayed via `APIGetContactCode` / `APIGetGroupMemberCode` and confirming via `APIVerifyContact` / `APIVerifyGroupMember`. + +### Connection Plan +Before connecting via a link, `APIConnectPlan` analyzes the link and returns a `ConnectionPlan` indicating whether the link leads to an existing contact, a new contact, a group join, etc. This prevents duplicate connections. + +*See:* `SimpleXAPI.kt` -- `CC.APIConnectPlan`, `CR.CRConnectionPlan` + +### Prepared Contact / Prepared Group +An intermediate state in the connection flow. `APIPrepareContact` / `APIPrepareGroup` creates the local record and displays the contact/group preview before the user confirms the connection. The user can then change the active profile (`APIChangePreparedContactUser` / `APIChangePreparedGroupUser`) and finally confirm via `APIConnectPreparedContact` / `APIConnectPreparedGroup`. + +--- + +## 5. Messaging Features + +### Delivery Receipt +Confirmation that a message was delivered to the recipient's device. Controlled per-user via `sendRcptsContacts` and `sendRcptsSmallGroups` on `User`. The global setting flow is triggered by `ChatModel.setDeliveryReceipts`. Individual overrides per-contact are managed via `ApiSetUserContactReceipts` / `ApiSetUserGroupReceipts`. + +*See:* `SimpleXAPI.kt` -- `CC.SetAllContactReceipts`, `CC.ApiSetUserContactReceipts`, `CC.ApiSetUserGroupReceipts`; `AppPreferences.privacyDeliveryReceiptsSet` + +### Timed Message (Disappearing Message) +Messages with a time-to-live after which they are automatically deleted. Configured as a `ChatFeature` / `GroupFeature` with a TTL parameter in seconds. The `customDisappearingMessageTime` preference stores the last custom duration used. Per-chat TTL can be set via `APISetChatTTL`. Global TTL via `APISetChatItemTTL`. + +*See:* `SimpleXAPI.kt` -- `CC.APISetChatItemTTL`, `CC.APISetChatTTL`; `AppPreferences.customDisappearingMessageTime` + +### Live Message +A message that updates in real-time as the sender types. Controlled by `CC.ApiSendMessages` with `live=true`. The `ComposeState.liveMessage` tracks the current live message being composed. An alert is shown on first use (`AppPreferences.liveMessageAlertShown`). + +### Message Reactions +Emoji reactions on messages. Added/removed via `ApiChatItemReaction(type, id, scope, itemId, add, reaction)`. Reaction members in groups can be queried via `ApiGetReactionMembers`. Each `ChatItem` carries a `reactions: List`. + +### Message Forwarding +Messages can be forwarded between chats. `ApiPlanForwardChatItems` checks feasibility (e.g., file availability), and `ApiForwardChatItems` performs the forward. A `ForwardConfirmation` may be required if files need downloading first. + +### Message Reports +Users can report messages in groups via `ApiReportMessage(groupId, chatItemId, reportReason, reportText)`. Admins can archive (`ApiArchiveReceivedReports`) or delete (`ApiDeleteReceivedReports`) reports. + +### Mentions +In-message mentions of group members. Stored as `mentions: Map` on `ChatItem` and `mentions: MentionedMembers` on `ComposeState`. + +### Link Previews +Automatic preview generation for URLs in messages. Controlled by `AppPreferences.privacyLinkPreviews`. An alert is shown on first use (`privacyLinkPreviewsShowAlert`). + +### Local File Encryption +Files stored on device can be encrypted. Controlled by `AppPreferences.privacyEncryptLocalFiles` and toggled via `CC.ApiSetEncryptLocalFiles(enable)`. + +### Chat Tags +User-defined tags for organizing conversations. CRUD via `ApiCreateChatTag`, `ApiUpdateChatTag`, `ApiDeleteChatTag`, `ApiReorderChatTags`. Assignment via `ApiSetChatTags`. The model tracks `userTags`, `presetTags` (system-defined categories), `unreadTags`, and the active filter (`activeChatTagFilter`). + +--- + +## 6. Calling & Media + +### WebRTC +The real-time communication framework used for audio and video calls. The app uses WebRTC for peer-to-peer media streams, with SMP used only for call signaling (offer/answer/ICE candidates). + +### Call (data class) +Represents an active call session. Fields: `remoteHostId`, `userProfile`, `contact`, `callUUID`, `callState` (CallState enum), `initialCallType` (Audio/Video), `localMediaSources`, `localCapabilities`, `peerMediaSources`, `sharedKey` (for E2E call encryption), `connectionInfo`, `connectedAt`. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt:14` + +### CallState +Enum tracking call progression: `WaitCapabilities` -> `InvitationSent` / `InvitationAccepted` -> `OfferSent` / `OfferReceived` -> `Negotiated` -> `Connected` -> `Ended`. + +### WCallCommand / WCallResponse +The command/response protocol between the Kotlin app and the WebRTC JavaScript layer: +- **Commands:** `Capabilities`, `Permission`, `Start`, `Offer`, `Answer`, `Ice`, `Media`, `Camera`, `Description`, `Layout`, `End` +- **Responses:** `Capabilities`, `Offer`, `Answer`, `Ice`, `Connection`, `Connected`, `PeerMedia`, `End`, `Ended`, `Ok`, `Error` + +*See:* `WebRTC.kt:88` -- `sealed class WCallCommand`; `WebRTC.kt:103` -- `sealed class WCallResponse` + +### CallManager +Manages incoming call invitations and the active call lifecycle. Handles reporting new incoming calls, accepting calls, switching between calls, and ending calls. Interacts with `ChatModel.callInvitations`, `ChatModel.activeCall`, and the platform notification manager. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` + +### Android: CallActivity +A dedicated Android `Activity` that displays the call UI. Launched when accepting an incoming call or initiating an outgoing call. Uses an Android `WebView` to host the WebRTC JavaScript. + +*See:* `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` + +### Android: CallService +An Android foreground `Service` that keeps the call alive when the app is in the background. Holds a `WakeLock`, displays an ongoing call notification, and manages the call lifecycle. Uses notification channel `CALL_SERVICE_NOTIFICATION`. + +*See:* `android/src/main/java/chat/simplex/app/CallService.kt` + +### Desktop: Browser-based WebRTC via NanoWSD +On Desktop, calls are implemented by opening the system browser to a locally-hosted WebSocket server. A `NanoHTTPD`/`NanoWSD` server runs on `localhost:50395`, serving the WebRTC call page and communicating with the Kotlin app via WebSocket messages. Commands are sent as JSON-serialized `WVAPICall` objects; responses are parsed as `WVAPIMessage` objects. + +*See:* `common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt` + +### ICE Servers +STUN/TURN servers used for WebRTC NAT traversal. Configurable via `AppPreferences.webrtcIceServers`. The relay policy (`AppPreferences.webrtcPolicyRelay`) controls whether calls must use TURN relays (for IP privacy) or can attempt direct connections. + +### CallMediaType +Enum: `Video`, `Audio`. Determines the initial media type of the call. + +### CallMediaSource +Enum: `Mic`, `Camera`, `ScreenAudio`, `ScreenVideo`. Used in `WCallCommand.Media` to toggle individual media streams. + +--- + +## 7. Notifications & Background + +### Android: SimplexService +A foreground `Service` that keeps the chat backend running in the background. Uses a `WakeLock` and displays a persistent notification ("SimpleX Chat service" channel). Started with `START_STICKY` for automatic restart. Manages the `chatRecvMsg` loop indirectly by keeping the process alive. + +Notification channel: `chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION` ("SimpleX Chat service") + +*See:* `android/src/main/java/chat/simplex/app/SimplexService.kt` + +### Android: MessagesFetcherWorker +A `WorkManager` periodic worker that wakes the app to fetch new messages when the foreground service is not running (i.e., when `NotificationsMode` is `PERIODIC`). Provides a battery-friendly alternative to the always-on service. + +*See:* `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` + +### Android: NotificationsMode +Enum controlling background message fetching: +- `OFF` -- no background activity; messages received only when app is open +- `PERIODIC` -- uses `MessagesFetcherWorker` for periodic fetches +- `SERVICE` -- uses `SimplexService` foreground service (default) + +*See:* `SimpleXAPI.kt:7739` -- `enum class NotificationsMode` + +### Android: Notification Channels +Android notification channels registered by the app: +- **Messages:** `chat.simplex.app.MESSAGE_NOTIFICATION` -- high importance, for incoming messages +- **Calls:** `chat.simplex.app.CALL_NOTIFICATION_2` -- high importance, for incoming call alerts with custom sound +- **Service:** `chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION` -- low importance, persistent foreground service indicator +- **Call Service:** `chat.simplex.app.CALL_SERVICE_NOTIFICATION` -- default importance, ongoing call indicator + +*See:* `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt`, `SimplexService.kt`, `CallService.kt` + +### Android: NtfManager +The Android-specific notification manager. Handles creating notification channels, displaying message notifications (with grouping via `MessageGroup`), displaying incoming call notifications (with full-screen intent for lock-screen calls), and managing notification actions (accept/reject call, open chat). + +*See:* `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` + +### Desktop: System Notifications +On Desktop, notifications use the system notification mechanism (typically via the JVM's `SystemTray` or platform-specific notification APIs). The notification manager interface is shared (`ntfManager`) but the implementation is platform-specific. + +### NotificationPreviewMode +Controls what information appears in notifications: +- `HIDDEN` -- no message content +- `CONTACT` -- shows sender name only +- `MESSAGE` -- shows sender name and message preview (default) + +*See:* `ChatModel.kt:4823` -- `enum class NotificationPreviewMode` + +### Wake Lock Management +In `ChatController.startReceiver()`, each received message acquires a wake lock (via `getWakeLock(timeout=60000)`) that is released after 30 seconds. This ensures the device stays awake long enough to process incoming messages and display notifications, particularly for incoming calls. + +--- + +## 8. Application Architecture + +### ChatController +The singleton controller that bridges the Kotlin UI layer and the Haskell core library. Responsibilities: +- Manages the `chatCtrl` (FFI handle to the Haskell runtime) +- Sends commands via `sendCmd()` and receives events via the `startReceiver()` coroutine loop +- Processes received messages in `processReceivedMsg()` +- Holds a reference to `AppPreferences` and `ChatModel` +- Provides the `messagesChannel` (Kotlin coroutine `Channel`) for consumers to observe events +- Manages retry logic for transient network errors (`sendCmdWithRetry`) + +*See:* `SimpleXAPI.kt:493` -- `object ChatController` + +### ChatModel +The singleton reactive state container for the entire app. Uses Compose `mutableStateOf` and `mutableStateListOf` for reactive UI updates. Key state: +- `currentUser` -- the active user profile +- `users` -- list of all user profiles (`UserInfo`) +- `chatsContext` / `secondaryChatsContext` -- `ChatsContext` holding the chat list +- `chatId` -- currently open chat +- `groupMembers` -- members of the currently viewed group +- `callInvitations` -- pending incoming call invitations +- `activeCall` -- the currently active call +- `userAddress` -- the user's SimpleX address +- `chatItemTTL` -- global message TTL setting +- `userTags` -- chat tags +- `terminalItems` -- debug terminal log items +- Various UI state flags (`showCallView`, `switchingUsersAndHosts`, `clearOverlays`, etc.) + +*See:* `ChatModel.kt:86` -- `object ChatModel` + +### AppPreferences +A class wrapping platform-specific key-value storage (`Settings` from `com.russhwolf.settings`). On Android, backed by `SharedPreferences`. On Desktop, backed by Java `Properties` files. Provides type-safe accessors for all user preferences. + +*See:* `SimpleXAPI.kt:94` -- `class AppPreferences` + +### ComposeState +Data class holding the state of the message composition area. Fields: `message` (ComposeMessage), `parsedMessage` (formatted text), `liveMessage`, `preview` (ComposePreview), `contextItem` (ComposeContextItem -- reply/edit context), `inProgress`, `progressByTimeout`, `useLinkPreviews`, `mentions`. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt:98` + +### ModalManager +Manages the modal/sheet presentation stack. Supports multiple placements (default, center, fullscreen, end). Holds an ordered list of `ModalViewHolder` items and exposes `showModal`, `showCustomModal`, `showModalCloseable`, `closeModal`. Uses Compose state (`modalCount`) to trigger recomposition. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt:92` + +### AlertManager +Singleton for displaying alert dialogs. Provides `showAlertMsg`, `showAlertDialog`, `showAlertDialogButtons`, etc. Works with `AlertManager.shared` for the default instance. + +### ChatsContext +Holds the chat list state for a particular scope (main or secondary). Manages `chats` (State>), provides `updateChats()` to refresh, and supports filtering/keeping specific chats during updates. + +### ConnectProgressManager +Tracks and displays connection progress in the UI. Methods: `startConnectProgress(text, onCancel)`, `stopConnectProgress()`, `cancelConnectProgress()`. Exposes `showConnectProgress` (nullable string indicating active progress text). + +*See:* `ChatModel.kt:48` -- `object ConnectProgressManager` + +### withBGApi / withLongRunningApi +Utility functions for launching coroutines on background threads. Used throughout the codebase to perform API calls without blocking the UI thread. + +--- + +## 9. Configuration & Preferences + +### AppPreferences (Storage) +All preferences are accessed through `ChatController.appPrefs`, which is a lazy-initialized `AppPreferences` instance. The underlying storage is: +- **Android:** `SharedPreferences` with ID `chat.simplex.app.SIMPLEX_APP_PREFS` +- **Desktop:** Java `Properties` files via `com.russhwolf.settings` + +Theme overrides have separate storage (`SHARED_PREFS_THEMES_ID`). + +### SharedPreference +A generic wrapper providing `get()` and `set(value)` for a single preference. All `AppPreferences` fields are `SharedPreference` instances created by factory methods (`mkBoolPreference`, `mkStrPreference`, `mkIntPreference`, `mkLongPreference`, `mkFloatPreference`, `mkEnumPreference`, `mkSafeEnumPreference`, `mkDatePreference`, `mkMapPreference`, `mkTimeoutPreference`). + +### Key Preference Categories + +**Notifications:** +- `notificationsMode` -- OFF / PERIODIC / SERVICE +- `notificationPreviewMode` -- HIDDEN / CONTACT / MESSAGE +- `canAskToEnableNotifications` -- gate for the notification prompt + +**Privacy:** +- `privacyProtectScreen` -- prevents screenshots (Android FLAG_SECURE) +- `privacyAcceptImages` -- auto-accept inline images +- `privacyLinkPreviews` -- generate URL previews +- `privacySanitizeLinks` -- strip tracking parameters from URLs +- `privacyShowChatPreviews` -- show message preview in chat list +- `privacySaveLastDraft` -- persist draft messages +- `privacyEncryptLocalFiles` -- encrypt files at rest +- `privacyAskToApproveRelays` -- prompt before using relays suggested by contacts +- `privacyMediaBlurRadius` -- blur radius for media in notifications/previews + +**Security:** +- `performLA` -- require local authentication (biometric/PIN) +- `laMode` -- local authentication mode +- `laLockDelay` -- seconds before re-locking +- `storeDBPassphrase` -- whether to persist the DB passphrase +- `initialRandomDBPassphrase` -- indicates the DB uses a random (non-user-chosen) passphrase +- `selfDestruct` -- enable self-destruct profile +- `selfDestructDisplayName` -- display name for the self-destruct profile + +**Network:** +- `networkUseSocksProxy` -- route traffic through SOCKS proxy +- `networkProxy` -- SOCKS proxy host/port configuration +- `networkSessionMode` -- transport session multiplexing mode +- `networkSMPProxyMode` -- SMP proxy / private routing mode +- `networkSMPProxyFallback` -- fallback behavior when proxy fails +- `networkHostMode` -- onion/public host preference +- `networkRequiredHostMode` -- enforce host mode strictly +- Various TCP timeout settings (background, interactive, per-KB) +- Keep-alive settings (idle, interval, count) + +**Calls:** +- `webrtcPolicyRelay` -- force TURN relay usage +- `callOnLockScreen` -- DISABLE / SHOW / ACCEPT calls on lock screen +- `webrtcIceServers` -- custom ICE server configuration +- `experimentalCalls` -- enable experimental call features + +**Appearance:** +- `currentTheme` -- active theme name +- `systemDarkTheme` -- theme for system dark mode +- `themeOverrides` -- per-theme customizations +- `profileImageCornerRadius` -- avatar rounding +- `chatItemRoundness` -- message bubble rounding +- `chatItemTail` -- show/hide message bubble tail +- `fontScale` -- text size scaling +- `densityScale` -- UI density scaling +- `inAppBarsAlpha` -- toolbar transparency +- `appearanceBarsBlurRadius` -- toolbar blur effect + +**UI:** +- `oneHandUI` -- one-handed UI mode (bottom-aligned navigation) +- `chatBottomBar` -- show bottom bar in chat view +- `simplexLinkMode` -- how SimpleX links are displayed (DESCRIPTION / FULL / BROWSER) +- `showUnreadAndFavorites` -- filter chat list to unread/favorites +- `developerTools` -- enable developer tools (terminal, etc.) + +**Database:** +- `encryptedDBPassphrase` -- encrypted form of the DB passphrase +- `initializationVectorDBPassphrase` -- IV for DB passphrase encryption +- `encryptionStartedAt` -- timestamp of encryption operation start (for crash recovery) +- `confirmDBUpgrades` -- prompt before database migrations +- `newDatabaseInitialized` -- flag for incomplete initialization recovery + +**Remote Access:** +- `deviceNameForRemoteAccess` -- device display name for remote control +- `confirmRemoteSessions` -- require confirmation for remote sessions +- `connectRemoteViaMulticast` -- use multicast discovery +- `connectRemoteViaMulticastAuto` -- auto-connect via multicast +- `desktopWindowState` -- persisted window position/size (Desktop only) + +**Migration:** +- `migrationToStage` / `migrationFromStage` -- track migration progress +- `onboardingStage` -- current onboarding step +- `lastMigratedVersionCode` -- last app version that ran migrations + +*See:* `SimpleXAPI.kt:94-489` -- `class AppPreferences` with all `SHARED_PREFS_*` constants diff --git a/apps/multiplatform/product/rules.md b/apps/multiplatform/product/rules.md new file mode 100644 index 0000000000..90a2dadada --- /dev/null +++ b/apps/multiplatform/product/rules.md @@ -0,0 +1,253 @@ +# Business Rules -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This document specifies invariants enforced by the Android and Desktop (Kotlin/Compose Multiplatform) clients. + +--- + +## Table of Contents + +1. [Security (RULE-01 through RULE-05)](#1-security) +2. [Message Integrity (RULE-06 through RULE-09)](#2-message-integrity) +3. [Group Integrity (RULE-10 through RULE-13)](#3-group-integrity) +4. [File Transfer (RULE-14 through RULE-15)](#4-file-transfer) +5. [Notification Delivery (RULE-16 through RULE-17)](#5-notification-delivery) +6. [Call Integrity (RULE-18)](#6-call-integrity) + +--- + +## 1. Security + +### RULE-01: End-to-End Encryption is Mandatory + +**Invariant:** Every message, file chunk, and call signaling payload MUST be encrypted end-to-end before transmission. The app MUST NOT transmit plaintext content to any relay server. + +**Enforcement:** The Haskell core library handles all encryption. The Kotlin layer never constructs raw SMP messages. All communication flows through `ChatController.sendCmd()` which delegates to the FFI, ensuring the encryption layer cannot be bypassed. + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `ChatController.sendCmd()`, `chatSendCmd()` FFI call + +--- + +### RULE-02: Database Encryption at Rest + +**Invariant:** The local SQLite database MUST be encrypted. A passphrase (either user-chosen or randomly generated) MUST be set before the database is operational. + +**Enforcement:** On first launch, a random passphrase is generated and stored encrypted via the platform keystore (`CryptorInterface.encryptText`). The `initialRandomDBPassphrase` preference tracks whether the user has set a custom passphrase. Database encryption state is tracked in `ChatModel.chatDbEncrypted`. Encryption/re-encryption is performed via `CC.ApiStorageEncryption(config: DBEncryptionConfig)`. + +**Caveat:** The user is not forced to set a custom passphrase -- the random passphrase is stored in app-accessible encrypted preferences. See GAP: "Database passphrase not enforced." + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` -- `CryptorInterface` +- Android: `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` -- Android Keystore +- Desktop: `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` -- **placeholder, not implemented** + +--- + +### RULE-03: Local Authentication Gating + +**Invariant:** When local authentication is enabled (`AppPreferences.performLA == true`), the app MUST require biometric/PIN authentication before displaying any chat content. The lock engages after `laLockDelay` seconds of inactivity. + +**Enforcement:** `AppLock.setPerformLA` controls the lock state. The lock delay is configurable via `AppPreferences.laLockDelay` (default 30 seconds). Authentication mode is set via `AppPreferences.laMode` (system biometric or passcode). + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt` +- `SimpleXAPI.kt` -- `AppPreferences.performLA`, `AppPreferences.laMode`, `AppPreferences.laLockDelay` + +--- + +### RULE-04: Self-Destruct Profile + +**Invariant:** When self-destruct is enabled (`AppPreferences.selfDestruct == true`), entering the self-destruct passphrase instead of the real passphrase MUST wipe the database and present a clean profile with `selfDestructDisplayName`. + +**Enforcement:** The self-destruct passphrase is stored separately (`encryptedSelfDestructPassphrase` / `initializationVectorSelfDestructPassphrase`). On Android, `SimplexService` checks for self-destruct on initialization. The comparison happens during the local authentication flow. + +**Location:** +- `SimpleXAPI.kt` -- `AppPreferences.selfDestruct`, `AppPreferences.selfDestructDisplayName` +- `android/src/main/java/chat/simplex/app/SimplexService.kt` -- initialization check + +--- + +### RULE-05: Screen Protection + +**Invariant:** When `AppPreferences.privacyProtectScreen == true` (default), the app MUST prevent screenshots and screen recording. On Android this uses `FLAG_SECURE`; on Desktop this is advisory only. + +**Enforcement:** The preference defaults to `true`. The Android activity applies `FLAG_SECURE` to its window based on this preference. The Desktop app cannot enforce this at the OS level. + +**Location:** `SimpleXAPI.kt` -- `AppPreferences.privacyProtectScreen` + +--- + +## 2. Message Integrity + +### RULE-06: Message Ordering Verification + +**Invariant:** The app MUST detect and surface message integrity violations (gaps, duplicates, out-of-order delivery) to the user. + +**Enforcement:** The Haskell core tracks message sequence numbers per connection. When a gap or integrity error is detected, a `CIContent.RcvIntegrityError(msgError: MsgErrorType)` chat item is inserted into the conversation. The UI renders these as system messages indicating the integrity issue. + +**Location:** `ChatModel.kt:3565` -- `CIContent.RcvIntegrityError` + +--- + +### RULE-07: Decryption Error Surfacing + +**Invariant:** When a message cannot be decrypted, the app MUST display a `RcvDecryptionError` item showing the error type and count of affected messages. The app MUST NOT silently drop undecryptable messages. + +**Enforcement:** The Haskell core emits `CIContent.RcvDecryptionError(msgDecryptError, msgCount)` which the UI renders with an explanation and count. Ratchet re-synchronization can be triggered via `APISyncContactRatchet` / `APISyncGroupMemberRatchet`. + +**Location:** `ChatModel.kt:3566` -- `CIContent.RcvDecryptionError` + +--- + +### RULE-08: Delivery Receipt Consistency + +**Invariant:** Delivery receipt settings MUST be consistent: when a user enables/disables receipts globally, the change MUST propagate to all contacts/groups (optionally clearing per-chat overrides via `clearOverrides`). + +**Enforcement:** Global receipt toggle triggers `CC.SetAllContactReceipts(enable)`. Per-type settings use `CC.ApiSetUserContactReceipts` / `CC.ApiSetUserGroupReceipts` with `UserMsgReceiptSettings(enable, clearOverrides)`. The `privacyDeliveryReceiptsSet` preference gates the initial setup prompt shown during onboarding. + +**Location:** +- `SimpleXAPI.kt` -- `CC.SetAllContactReceipts`, `CC.ApiSetUserContactReceipts`, `CC.ApiSetUserGroupReceipts` +- `SimpleXAPI.kt` -- `ChatController.startChat()` -- triggers `setDeliveryReceipts` prompt + +--- + +### RULE-09: Chat Item TTL Enforcement + +**Invariant:** When a chat item TTL (time-to-live) is set globally or per-chat, expired messages MUST be deleted by the core. The app MUST NOT display expired items. + +**Enforcement:** Global TTL set via `CC.APISetChatItemTTL(userId, seconds)`. Per-chat TTL set via `CC.APISetChatTTL(userId, chatType, id, seconds)`. The Haskell core performs periodic cleanup. The current global TTL is stored in `ChatModel.chatItemTTL`. + +**Location:** `SimpleXAPI.kt` -- `CC.APISetChatItemTTL`, `CC.APISetChatTTL` + +--- + +## 3. Group Integrity + +### RULE-10: Role-Based Access Control + +**Invariant:** Group operations MUST respect the member's role. Only members with sufficient role level can perform privileged operations: +- **Owner:** can delete group, change any member's role, transfer ownership +- **Admin:** can add/remove members, change roles (up to Admin), create/delete group links +- **Moderator:** can delete other members' messages, block members +- **Member / Author / Observer:** cannot perform administrative actions + +**Enforcement:** The Haskell core validates role permissions server-side. The Kotlin UI layer uses `GroupMemberRole` comparisons (the enum is ordered: Observer < Author < Member < Moderator < Admin < Owner) to show/hide action buttons. + +**Location:** `ChatModel.kt:2369` -- `enum class GroupMemberRole`; various group management views + +--- + +### RULE-11: Group Member Removal Atomicity + +**Invariant:** When removing members from a group, the removal command MUST specify all member IDs atomically. Partial removal MUST NOT leave the group in an inconsistent state. + +**Enforcement:** `CC.ApiRemoveMembers(groupId, memberIds: List, withMessages: Boolean)` sends all member IDs in a single command. The `withMessages` flag controls whether the removed members' messages are also deleted. + +**Location:** `SimpleXAPI.kt` -- `CC.ApiRemoveMembers` + +--- + +### RULE-12: Group Link Role Default + +**Invariant:** When creating a group link, the default member role for joiners MUST be explicitly specified. The role can be updated after creation without regenerating the link. + +**Enforcement:** `CC.APICreateGroupLink(groupId, memberRole)` requires a role. `CC.APIGroupLinkMemberRole(groupId, memberRole)` updates it. The link itself remains stable. + +**Location:** `SimpleXAPI.kt` -- `CC.APICreateGroupLink`, `CC.APIGroupLinkMemberRole` + +--- + +### RULE-13: Member Blocking Scope + +**Invariant:** Blocking a member (`ApiBlockMembersForAll`) MUST apply the block for all group members (not just the requester). The `blocked` flag is visible to all members. Only roles >= Moderator can block. + +**Enforcement:** `CC.ApiBlockMembersForAll(groupId, memberIds, blocked)` sends the block/unblock to the core, which propagates it to all group members. + +**Location:** `SimpleXAPI.kt` -- `CC.ApiBlockMembersForAll`; `ChatModel.kt` -- `GroupMember.blockedByAdmin` + +--- + +## 4. File Transfer + +### RULE-14: File Encryption in Transit and at Rest + +**Invariant:** Files sent via XFTP MUST be encrypted before upload. Files received MUST be decrypted only after download. When `privacyEncryptLocalFiles` is enabled (default `true`), files stored locally MUST be encrypted with per-file keys (`CryptoFile.cryptoArgs`). + +**Enforcement:** The Haskell core handles XFTP encryption. Local file encryption is toggled via `CC.ApiSetEncryptLocalFiles(enable)`. The `CryptoFile` type carries optional `CryptoFileArgs` (key + nonce) for local decryption. Files are decrypted on-demand for display via `decryptCryptoFile()`. + +**Location:** +- `SimpleXAPI.kt` -- `CC.ApiSetEncryptLocalFiles`, `AppPreferences.privacyEncryptLocalFiles` +- `ChatModel.kt` -- `CryptoFile`, `CryptoFileArgs` +- `RecAndPlay.desktop.kt` -- `decryptCryptoFile()` usage in audio playback + +--- + +### RULE-15: Relay Approval for File Transfer + +**Invariant:** When `privacyAskToApproveRelays` is enabled (default `true`), the app MUST prompt the user before using XFTP relay servers suggested by contacts (as opposed to the user's own configured servers). The `userApprovedRelays` flag on `CC.ReceiveFile` records the user's consent. + +**Enforcement:** `CC.ReceiveFile(fileId, userApprovedRelays, encrypt, inline)` passes the approval flag. The UI prompts the user when the file is from an unapproved relay. + +**Location:** `SimpleXAPI.kt` -- `CC.ReceiveFile`, `AppPreferences.privacyAskToApproveRelays` + +--- + +## 5. Notification Delivery + +### RULE-16: Background Message Delivery (Android) + +**Invariant:** On Android, when `NotificationsMode.SERVICE` is selected (default), the app MUST maintain a foreground service (`SimplexService`) to ensure continuous message delivery. The service MUST survive app backgrounding and device sleep. When `NotificationsMode.PERIODIC` is selected, `MessagesFetcherWorker` MUST periodically wake and fetch messages. When `NotificationsMode.OFF`, no background delivery occurs. + +**Enforcement:** +- `SimplexService` runs as a foreground service with `START_STICKY` and a `WakeLock`. It displays a persistent notification on the `SIMPLEX_SERVICE_NOTIFICATION` channel. +- `MessagesFetcherWorker` is a `PeriodicWorkRequest` scheduled via `WorkManager`. +- The mode is stored in `AppPreferences.notificationsMode` and checked at app startup. + +**Location:** +- `android/src/main/java/chat/simplex/app/SimplexService.kt` +- `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` +- `SimpleXAPI.kt:7739` -- `enum class NotificationsMode` + +--- + +### RULE-17: Notification Preview Privacy + +**Invariant:** Notification content MUST respect `notificationPreviewMode`: +- `HIDDEN` -- notification shows no sender or message content +- `CONTACT` -- notification shows sender name only +- `MESSAGE` -- notification shows sender name and message preview + +**Enforcement:** `NtfManager` (Android) reads the preview mode from `AppPreferences.notificationPreviewMode` and constructs notifications accordingly. The `CallService` also respects this mode for call notifications (showing or hiding caller identity). + +**Location:** +- `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` -- `displayNotification()`, `notifyCallInvitation()` +- `android/src/main/java/chat/simplex/app/CallService.kt` -- `updateNotification()` +- `SimpleXAPI.kt` -- `AppPreferences.notificationPreviewMode` + +--- + +## 6. Call Integrity + +### RULE-18: Call Lifecycle Management + +**Invariant:** An active call MUST be properly managed across the full lifecycle: +1. **Incoming calls** MUST be reported via `CallManager.reportNewIncomingCall()` which triggers a notification (and on Android, a full-screen intent for lock-screen display). +2. **Only one call** can be active at a time. Accepting a new call MUST end any existing call first (`CallManager.acceptIncomingCall` checks `activeCall` and calls `endCall` if needed, guarded by `switchingCall` flag). +3. **Call state** MUST progress through defined states: `WaitCapabilities` -> `InvitationSent`/`InvitationAccepted` -> `OfferSent`/`OfferReceived` -> `Negotiated` -> `Connected` -> `Ended`. +4. **Call end** MUST clean up all resources: send `WCallCommand.End`, call `apiEndCall`, clear `activeCall`, cancel call notifications, and release platform resources. + +**Android enforcement:** +- `CallService` (foreground service) keeps the call alive in background with a `WakeLock` and ongoing notification on `CALL_SERVICE_NOTIFICATION` channel. +- `CallActivity` hosts the WebRTC WebView. +- Lock-screen behavior controlled by `AppPreferences.callOnLockScreen` (DISABLE / SHOW / ACCEPT). + +**Desktop enforcement:** +- Calls run in the system browser via the NanoWSD WebSocket server on `localhost:50395`. +- The `WebRTCController` composable manages the WebSocket lifecycle. +- On dispose, `WCallCommand.End` is sent and the server is stopped. + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt` +- Android: `android/src/main/java/chat/simplex/app/CallService.kt`, `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` +- Desktop: `common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt` diff --git a/apps/multiplatform/product/views/call.md b/apps/multiplatform/product/views/call.md new file mode 100644 index 0000000000..51d323874c --- /dev/null +++ b/apps/multiplatform/product/views/call.md @@ -0,0 +1,115 @@ +# Audio / Video Call + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Purpose + +Make and receive end-to-end encrypted audio and video calls over WebRTC. The implementation differs significantly between Android (WebView-based with `CallActivity` and PiP support) and Desktop (browser-based WebRTC via NanoHTTPD server on localhost). + +## Route / Navigation + +- **Entry point (outgoing)**: Tap audio or video call button in `ChatInfoView` action buttons or `ChatView` toolbar +- **Entry point (incoming)**: `IncomingCallAlertView` banner appears at top of screen +- **Presented by**: `ActiveCallView()` (expect/actual composable) is shown when `chatModel.showCallView == true` +- **Dismiss**: Call ends when user taps end button or remote party disconnects; `callManager.endCall()` handles cleanup +- **Android PiP**: Call view supports picture-in-picture mode via `CallActivity` + +## Platform Differences + +| Aspect | Android | Desktop | +|---|---|---| +| WebRTC host | `WebView` with `WebViewAssetLoader` serving local assets | NanoHTTPD server on `localhost:50395` opened in system browser | +| Call activity | `CallActivity` (separate Android Activity) with lifecycle management | Inline composable with `WebRTCController` | +| PiP support | Native Android PiP via `CallActivity` | Not supported | +| Audio management | `CallAudioDeviceManager` with Android `AudioManager`, proximity wake lock | System browser audio routing | +| WebSocket | N/A | `NanoWSD` WebSocket server for bidirectional WebRTC signaling | + +## Page Sections + +### Incoming Call Banner (`IncomingCallAlertView`) + +Displayed as an overlay banner when `chatModel.activeCallInvitation` is set: + +| Element | Description | +|---|---| +| User profile image | Shown when multiple profiles exist (32dp `ProfileImage`) | +| Call type icon | `ic_videocam_filled` (green) for video, `ic_call_filled` (green) for audio | +| Call type text | `invitation.callTypeText` with caller info | +| Caller profile | `ProfilePreview` showing caller name and avatar (64dp) | +| Reject button | Red `ic_call_end_filled` icon -- ends the invitation via `callManager.endCall(invitation)` | +| Ignore button | Blue `ic_close` icon -- dismisses banner, cancels notification | +| Accept button | Green `ic_check_filled` icon -- accepts via `callManager.acceptIncomingCall(invitation)` | + +Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call view is already showing). + +### Active Call View + +#### Android (`CallView.android.kt`) + +| Element | Description | +|---|---| +| WebView | `AndroidView` wrapping a `WebView` that loads `call.html` via `WebViewAssetLoader`; handles WebRTC JS bridge | +| `ActiveCallState` | Manages proximity lock (`PROXIMITY_SCREEN_OFF_WAKE_LOCK`), audio device manager, call sounds | +| Call controls overlay | Mic toggle, speaker toggle, camera switch, video toggle, end call button | +| Audio device selection | `CallAudioDeviceManager` with device enumeration (earpiece, speaker, Bluetooth, wired headset) | +| Permissions | Runtime permission checks for `CAMERA` and `RECORD_AUDIO` via Accompanist permissions library | + +#### Desktop (`CallView.desktop.kt`) + +| Element | Description | +|---|---| +| NanoHTTPD server | HTTP server on `localhost:50395` serving `call.html` and assets | +| NanoWSD WebSocket | WebSocket endpoint for bidirectional signaling between Kotlin and browser JS | +| `WebRTCController` | Processes `WCallCommand`/`WCallResponse` messages via `chatModel.callCommand` channel | +| Browser launch | `LocalUriHandler.openUri("http://localhost:50395/call.html")` opens system browser | +| Connection list | `connections: ArrayList` tracks active WebSocket connections | + +### WebRTC Signaling Flow + +| Step | Command/Response | Description | +|---|---|---| +| 1. Capabilities | `WCallResponse.Capabilities` | Local capabilities reported; `apiSendCallInvitation()` called | +| 2. Offer | `WCallResponse.Offer` | SDP offer + ICE candidates sent via `apiSendCallOffer()` | +| 3. Answer | `WCallResponse.Answer` | SDP answer + ICE candidates sent via `apiSendCallAnswer()` | +| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates exchanged via `apiSendCallExtraInfo()` | +| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success | +| 6. Connected | `WCallResponse.Connected` | Connection info (relay/direct) stored in `call.connectionInfo` | +| 7. PeerMedia | `WCallResponse.PeerMedia` | Remote party media source changes (mic, camera, screen) | +| 8. Media control | `WCallCommand.Media` | Toggle local media sources (mic, camera, screen audio/video) | +| 9. Camera switch | `WCallCommand.Camera` | Switch between front/back camera | +| 10. End | `WCallResponse.End` / `WCallResponse.Ended` | Call termination; cleanup and UI dismissal | + +### Call States (`CallState`) + +| State | Description | +|---|---| +| `WaitCapabilities` | Waiting for WebRTC capabilities | +| `InvitationSent` | Call invitation sent to remote party | +| `InvitationAccepted` | Callee accepted, starting WebRTC | +| `OfferSent` | SDP offer sent | +| `OfferReceived` | Callee received SDP offer | +| `AnswerReceived` | Caller received SDP answer | +| `Negotiated` | ICE negotiation complete | +| `Connected` | WebRTC media flowing; `connectedAt` timestamp set | +| `Ended` | Call terminated | + +### Call Sounds + +| Sound | Trigger | +|---|---| +| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent | +| In-call sound | `CallSoundsPlayer.startInCallSound()` when delivery receipt received | +| Ringtone | `SoundPlayer.start()` for incoming calls | +| End vibration | `CallSoundsPlayer.vibrate()` on call end (if was connected) | + +## Source Files + +| File | Path | +|---|---| +| `CallView.kt` | `views/call/CallView.kt` (common expect declarations) | +| `CallView.android.kt` | `androidMain/.../views/call/CallView.android.kt` | +| `CallView.desktop.kt` | `desktopMain/.../views/call/CallView.desktop.kt` | +| `IncomingCallAlertView.kt` | `views/call/IncomingCallAlertView.kt` | +| `CallManager.kt` | `views/call/CallManager.kt` | +| `WebRTC.kt` | `views/call/WebRTC.kt` | +| `CallAudioDeviceManager.kt` | `androidMain/.../views/call/CallAudioDeviceManager.kt` | diff --git a/apps/multiplatform/product/views/chat-list.md b/apps/multiplatform/product/views/chat-list.md new file mode 100644 index 0000000000..daa7907c5d --- /dev/null +++ b/apps/multiplatform/product/views/chat-list.md @@ -0,0 +1,136 @@ +# Chat List (Home Screen) + +> **Related spec:** [spec/client/chat-list.md](../../spec/client/chat-list.md) + +## Purpose + +Main screen of the SimpleX Chat Android and Desktop apps. Displays all conversations sorted by last activity, serves as the navigation root, and provides access to user profiles, settings, and new chat creation. + +## Route / Navigation + +- **Entry point**: App launch (root view), or back-navigation from any chat +- **Presented by**: `ChatListView` composable as the default view when `chatModel.chatId == null` +- **Navigation**: `ChatListNavLinkView` handles click routing to `ChatView` for each chat type +- **UserPicker**: Triggered by tapping the user avatar in the toolbar; presents `UserPicker` as a custom sheet (Android: bottom sheet overlay; Desktop: sidebar panel) + +## Platform Layout + +| Platform | Layout | +|---|---| +| Android | Single-column list; toolbar at top or bottom (one-hand UI); FAB for new chat | +| Desktop | 3-column layout: chat list (left), chat view (center), info/detail panel (right via `ModalManager.end`) | + +## Page Sections + +### Toolbar (`ChatListToolbar`) + +| Element | Location | Behavior | +|---|---|---| +| User avatar button | Leading | Opens `UserPicker` sheet (profile switcher, address, settings, preferences, connect to desktop/mobile) | +| "Your chats" title | Center | Tappable to scroll list to top | +| Connection status indicator (`SubscriptionStatusIndicator`) | Adjacent to title | Shows SMP server subscription status; taps open `ServersSummaryView` | +| New chat button (pencil icon) | Trailing (one-hand UI) or FAB (standard) | Opens `NewChatSheet` modal via `showNewChatSheet()` | +| Active call indicator | Trailing (Desktop, one-hand UI) | `ActiveCallInteractiveArea` shown when a call is active | +| Updating progress | Trailing | Shows progress circle/indicator during database updates | +| Stopped indicator | Trailing | Red warning icon when chat engine is stopped | + +The toolbar supports two layout modes controlled by `appPrefs.oneHandUI`: +- **Standard (top)**: `DefaultAppBar` at top with `NavigationButtonMenu` leading, title center, buttons trailing. FAB at bottom-right for new chat. +- **One-hand UI (bottom)**: Toolbar at bottom of screen with `Column(Modifier.align(Alignment.BottomCenter))`; list rendered with `reverseLayout = true`; no FAB (new chat button is inline in toolbar). + +### Search Bar (`ChatListSearchBar`) + +| Element | Description | +|---|---| +| Search icon | Magnifying glass icon at leading edge | +| Text field | `SearchTextField` with placeholder "Search or paste SimpleX link" | +| Filter button | `ToggleFilterEnabledButton` (filter icon) toggles unread-only filter; shown when search text is empty | +| Clear button | Appears when text is entered; `BackHandler` clears search on back | + +Behavior: +- Filters chat list in real-time by contact/group name via `filteredChats()` +- Detects pasted SimpleX links (`strHasSingleSimplexLink`) and triggers `planAndConnect()` connection dialogue +- In one-hand UI mode, search bar appears below tag filters with IME spacer; in standard mode, above tag filters + +### Chat Filter Tags (`TagsView`) + +Managed by `chatModel.userTags`, `chatModel.presetTags`, and `chatModel.activeChatTagFilter`: + +| Filter | `PresetTagKind` | Icon | Description | +|---|---|---|---| +| Group Reports | `GROUP_REPORTS` | Flag | Chats with moderation reports (non-collapsible) | +| Favorites | `FAVORITES` | Star | User-favorited chats | +| Contacts | `CONTACTS` | Person | Direct contacts and contact requests | +| Groups | `GROUPS` | Group | Group conversations (non-business) | +| Business | `BUSINESS` | Work | Business chat conversations | +| Notes | `NOTES` | Folder | Notes to self | +| Custom tags | `UserTag(ChatTag)` | Label/emoji | User-created tags with custom emoji and name | +| Unread | `ActiveFilter.Unread` | Filter list icon | Chats with unread messages (toggle via filter button) | + +Display logic: +- When collapsible preset tags exceed 3 total (with user tags), they collapse into a `CollapsedTagsFilterView` dropdown menu +- Non-collapsible tags (`GROUP_REPORTS`) always show expanded +- User tags show with emoji or label icon; long-press opens `TagsDropdownMenu` (edit, delete, change order) +- "+" button at end opens `TagListEditor` for creating new tags + +### Chat Preview Rows (`ChatPreviewView`) + +Each row rendered by `ChatPreviewView` inside `ChatListNavLinkView`: + +| Element | Description | +|---|---| +| Avatar | `ProfileImage` with overlay icons (inactive contact, left/removed group member) | +| Chat name | Display name with verified icon for verified contacts; colored for pending/connecting states | +| Last message preview | Truncated text of most recent message; draft indicator with edit icon; attachment icons | +| Timestamp | Relative time of last activity | +| Unread badge | Numeric count badge; distinct styling for mentions | +| Muted indicator | Bell-off icon when notifications are muted | +| Favorite indicator | Star icon for favorited chats | +| Incognito indicator | Shows when connected via incognito profile | +| Connection status | Shows connecting/pending state for incomplete connections | + +Chat types handled by `ChatListNavLinkView`: +- `ChatInfo.Direct` -- direct contact chat +- `ChatInfo.Group` -- group chat (with in-progress indicator for joining) +- `ChatInfo.Local` -- note-to-self folder +- `ChatInfo.ContactRequest` -- incoming contact request (tap shows accept/reject alert) +- `ChatInfo.ContactConnection` -- pending connection (tap opens `ContactConnectionView`) + +### Context Menu (Long Press / Right Click) + +Each chat type provides specific dropdown menu items: + +| Chat Type | Menu Items | +|---|---| +| Direct contact | Mark read/unread, toggle favorite, toggle notify, tag list, clear chat, delete contact | +| Group | Mark read/unread, toggle favorite, toggle notify, tag list, clear chat, archive all reports (moderator, when reports exist), leave group, delete group | +| Note folder | Mark read/unread, clear notes | +| Contact request | Accept, reject | +| Contact connection | Set name/alias, delete | + +### Floating Elements + +| Element | Condition | Description | +|---|---|---| +| One-hand UI card (`ToggleChatListCard`) | `oneHandUICardShown == false` | Dismissible card introducing bottom toolbar mode with toggle switch | +| Address creation card (`AddressCreationCard`) | `addressCreationCardShown == false` | Prompts user to create a SimpleX address; tappable card opens `UserAddressLearnMore` | +| FAB (new chat button) | Standard mode, search empty, chat running | `FloatingActionButton` at bottom-right, pencil icon, opens `NewChatSheet` | + +### Empty States + +| State | Display | +|---|---| +| Loading | "Loading chats..." centered text | +| No chats | "You have no chats" centered text | +| No filtered chats | "No chats in list [tag name]" or "No unread chats" with clickable filter reset | +| No search results | "No chats found" centered text | + +## Source Files + +| File | Path | +|---|---| +| `ChatListView.kt` | `views/chatlist/ChatListView.kt` | +| `ChatListNavLinkView.kt` | `views/chatlist/ChatListNavLinkView.kt` | +| `ChatPreviewView.kt` | `views/chatlist/ChatPreviewView.kt` | +| `UserPicker.kt` | `views/chatlist/UserPicker.kt` | +| `TagListView.kt` | `views/chatlist/TagListView.kt` | diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md new file mode 100644 index 0000000000..64abda7ee6 --- /dev/null +++ b/apps/multiplatform/product/views/chat.md @@ -0,0 +1,135 @@ +# Chat View (Conversation) + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +Full conversation view for displaying and interacting with messages in a direct contact chat, group chat, or note-to-self. Supports text messaging with markdown, media attachments, voice messages, E2E encrypted calls, message reactions, replies, forwarding, reporting, and content search/filtering. + +## Route / Navigation + +- **Entry point**: Tap a chat row in `ChatListView` (routed by `ChatListNavLinkView`) +- **Presented by**: `ChatView` composable bound to `chatModel.chatId`; on Desktop, shown in the center column +- **Back navigation**: Sets `chatModel.chatId = null`, stops `AudioPlayer`, clears group members, returns to chat list +- **Sub-navigation**: + - Info button opens `ChatInfoView` (contact) or `GroupChatInfoView` (group) via `ModalManager.end` + - Member avatars in group chats navigate to `GroupMemberInfoView` + - Reports button opens `GroupReportsView` for groups with moderation reports + - Support chats button opens `MemberSupportView` (moderators) or member support chat (regular members) + +## Page Sections + +### Navigation Bar (`ChatLayout`) + +Custom toolbar with themed background: + +| Element | Description | +|---|---| +| Back button | Returns to chat list; stops audio/video playback | +| Contact/Group avatar | Small profile image in toolbar | +| Chat name | Display name; tappable to open info view | +| Verified shield | Shows verified contact checkmark (direct chats with verified contacts only) | +| More menu button | Opens overflow menu containing search and audio/video call buttons (call buttons shown in direct chats only) | +| Info button | Opens `ChatInfoView` (direct) or `GroupChatInfoView` (group) | +| Reports count | Badge for group reports count; taps open reports view | +| Support chats | Badge for member support; taps open support chat view | + +### Message List + +Rendered by `LazyColumnWithScrollBar` with pagination: + +| Feature | Description | +|---|---| +| Scroll direction | Bottom-to-top (newest messages at bottom) | +| Pagination | `apiLoadMessages` called on scroll to load more; supports `.before`, `.after`, `.around`, `.initial` | +| Merged items | Adjacent messages grouped with `ItemSeparation` (timestamp, large gap, date separators) | +| Floating buttons | Scroll-to-bottom button with unread count | +| Date separators | Date headers between messages from different days | +| Wallpaper | Per-chat themed background via `perChatTheme` from contact/group `uiThemes` | +| Content filter | Filter messages by type via `ContentFilter` (images, files, links, etc.) | + +### Message Types + +Each type has a dedicated composable in `views/chat/item/`: + +| Type | Composable | Description | +|---|---|---| +| Text | `FramedItemView` | Rendered with markdown (bold, italic, code, links, `@mentions`) via `CIMarkdownText` | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `ImageFullScreenView` | +| Video | `CIVideoView` | Video thumbnail with play button; inline playback via `VideoPlayerHolder` | +| Voice | `CIVoiceView` | Waveform visualization with playback controls and duration | +| File | `CIFileView` | File icon, name, size; download/open actions with progress indicator | +| Link preview | `ChatItemLinkView` | URL preview card with title, description, image (defined in `LinkPreviews.kt`) | +| Emoji-only | `EmojiItemView` | Large emoji rendering without message bubble | +| Call event | `CICallItemView` | Call status (missed, ended, duration) | +| Group event | `CIEventView` | Member joined/left, role changes, group updates | +| E2EE info | `CIChatFeatureView` | Encryption status and feature change notifications | +| Group invitation | `CIGroupInvitationView` | Inline group join invitation card | +| Deleted | `DeletedItemView` / `MarkedDeletedItemView` | Placeholder for deleted messages | +| Decryption error | `CIRcvDecryptionError` | Error with ratchet sync suggestion | +| Invalid JSON | `CIInvalidJSONView` | Developer fallback for malformed items | +| Integrity error | `IntegrityErrorItemView` | Message integrity/gap warnings | + +### Message Interactions + +Long-press context menu on any message: + +| Action | Description | +|---|---| +| Reply | Sets compose bar to reply mode with quoted message (`ComposeContextItem.QuotedItem`) | +| Forward | Opens destination picker; uses `apiPlanForwardChatItems` with confirmation for partial forwards | +| Copy | Copies message text to clipboard | +| Edit | Enters edit mode (`ComposeContextItem.EditingItem`); own messages within edit window | +| Delete | Delete for self or delete for everyone (with confirmation via `deleteMessagesAlertDialog`) | +| Moderate | Group moderators can delete messages for all members (`moderateMessagesAlertDialog`) | +| React | Emoji reaction picker | +| Report | Report message to group moderators (`ComposeContextItem.ReportedItem` with `ReportReason`) | +| Select multiple | Enters multi-select mode (`selectedChatItems`) with bulk delete/forward/archive toolbar | +| Archive | Archive selected reports (moderators) | + +### Compose Bar (`ComposeView` + `SendMsgView`) + +Bottom input area for composing messages: + +| Element | Description | +|---|---| +| Text field | `PlatformTextField` with markdown support, `@mention` autocomplete, file paste support | +| Attachment button | Opens `ModalBottomSheetLayout` with options: camera, gallery (image/video), file | +| Send button | Sends message; changes to checkmark for reports; animated size/alpha | +| Voice record button | Shown when text is empty and voice allowed; hold to record, release to preview | +| Live message button | Start/update live typing message (if `liveMessageAlertShown`) | +| Context preview | Shows quoted message, editing indicator, or forwarding source above text field | +| Media preview | Thumbnail row of selected images/videos before sending | +| Link preview | Auto-generated link preview card (`ComposePreview.CLinkPreview`) | +| Connecting status | "Connecting..." text shown when contact is not yet ready | +| Commands menu | Developer commands (`showCommandsMenu`) | + +Compose states (`ComposeState`): +- `NoContextItem` -- normal new message +- `QuotedItem` -- replying to a message +- `EditingItem` -- editing own message +- `ForwardingItems` -- forwarding from another chat +- `ReportedItem` -- reporting a message with reason + +### Multi-Select Toolbar (`SelectedItemsButtonsToolbar`) + +Shown when `selectedChatItems != null`: + +| Button | Description | +|---|---| +| Delete / Archive | Delete selected messages (for self, or for everyone if allowed by `fullDeleteAllowed`); shown as Archive for report items (group moderators only) | +| Forward | Forward selected messages to another chat | +| Moderate | Delete selected messages for all members (group moderators only) | + +### Timed/Disappearing Messages + +When `timedMessageAllowed` is true, compose bar includes a timer icon for setting message disappear time via `customDisappearingMessageTimePref`. + +## Source Files + +| File | Path | +|---|---| +| `ChatView.kt` | `views/chat/ChatView.kt` | +| `ComposeView.kt` | `views/chat/ComposeView.kt` | +| `SendMsgView.kt` | `views/chat/SendMsgView.kt` | +| Chat item views | `views/chat/item/*.kt` | diff --git a/apps/multiplatform/product/views/contact-info.md b/apps/multiplatform/product/views/contact-info.md new file mode 100644 index 0000000000..32793a3b70 --- /dev/null +++ b/apps/multiplatform/product/views/contact-info.md @@ -0,0 +1,104 @@ +# Contact Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View contact details, manage per-contact preferences, verify security codes for E2E encryption, manage connection settings (switch address, sync ratchet), and perform destructive actions like clearing or deleting a contact. + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a direct contact chat) +- **Presented by**: `ChatInfoView` composable shown via `ModalManager.end` from `ChatView` +- **Sub-navigation**: + - Contact preferences -> `ContactPreferencesView` (via `ModalManager.end`) + - Security code verification -> `VerifyCodeView` (via `ModalManager.end`) + - Chat wallpaper -> wallpaper editor + - Group profile view (for group-direct contacts) + +## Page Sections + +### Contact Info Header + +| Element | Description | +|---|---| +| Profile image | Large circular avatar (tappable) | +| Display name | Contact's display name | +| Full name | Optional full name below display name | +| Connection status | Shows if contact is ready, connecting, or has issues | + +### Local Alias + +Editable text field for setting a local-only name visible only on this device. Not shared with the contact. Changes saved via `setContactAlias()`. + +### Action Buttons + +Horizontal row of quick-action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearchClicked` to search messages in chat | +| Audio call | Initiate audio call | +| Video call | Initiate video call | +| Mute/Unmute | Toggle notification mode | + +### Incognito Section + +Shown only when `customUserProfile` is set (connected via incognito profile): + +| Element | Description | +|---|---| +| Incognito icon | Indicates incognito connection | +| Profile name | The random profile name used for this connection | + +### Chat Preferences + +| Setting | Description | +|---|---| +| Send receipts | Per-contact delivery receipt setting (`SendReceipts` tristate: default/on/off) | +| Chat item TTL | Per-contact message retention setting (`ChatItemTTL` with alert confirmation) | +| Contact preferences | Opens `ContactPreferencesView` for feature toggles (timed messages, full delete, reactions, voice, calls) | + +### Connection Details + +Shown when `connectionStats` is available: + +| Element | Description | +|---|---| +| Connection stats | Server information, agent connection ID | +| Switch address | Initiates SMP server address switch (`apiSwitchContact`) with confirmation alert | +| Abort switch | Cancels an in-progress address switch (`apiAbortSwitchContact`) | +| Sync connection | Fixes encryption ratchet synchronization (`apiSyncContactRatchet`) | +| Force sync | Force ratchet re-synchronization with confirmation alert | + +### Security Code Verification + +| Element | Description | +|---|---| +| Verify button | Opens `VerifyCodeView` showing the connection security code | +| Verified badge | Shows checkmark when contact is verified | +| Code comparison | Side-by-side code display for out-of-band verification via `apiVerifyContact` | + +### Developer Tools Section + +Shown when `developerTools` preference is enabled: + +| Element | Description | +|---|---| +| Database ID | Contact's internal database identifier | +| Agent connection ID | Underlying SMP agent connection ID | + +### Destructive Actions + +| Action | Description | +|---|---| +| Clear chat | Deletes all messages in chat (with confirmation via `clearChatDialog`) | +| Delete contact | Removes the contact and all associated data (with confirmation via `deleteContactDialog`) | + +## Source Files + +| File | Path | +|---|---| +| `ChatInfoView.kt` | `views/chat/ChatInfoView.kt` | +| `ContactPreferences.kt` | `views/chat/ContactPreferences.kt` | +| `VerifyCodeView.kt` | `views/chat/VerifyCodeView.kt` | diff --git a/apps/multiplatform/product/views/group-info.md b/apps/multiplatform/product/views/group-info.md new file mode 100644 index 0000000000..2335de7178 --- /dev/null +++ b/apps/multiplatform/product/views/group-info.md @@ -0,0 +1,172 @@ +# Group Chat Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View and manage group settings, member list, group preferences, group links, member admission, welcome messages, and moderation features. The scope of available actions depends on the user's role within the group (member, moderator, admin, owner). + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a group chat) +- **Presented by**: `GroupChatInfoView` composable shown via `ModalManager.end` from `ChatView` +- **Sub-navigation**: + - Edit group profile -> `GroupProfileView` (via `ModalManager.end`) + - Add members -> `AddGroupMembersView` (via `ModalManager.end`) + - Group link -> `GroupLinkView` (via `ModalManager.end`) + - Group preferences -> `GroupPreferencesView` (via `ModalManager.end`) + - Welcome message -> `GroupWelcomeView` (via `ModalManager.end`) + - Member info -> `GroupMemberInfoView` (via `ModalManager.end`) + - Chat wallpaper -> wallpaper editor + - Member support -> `MemberSupportView` (via `ModalManager.end`) + +## Page Sections + +### Group Info Header + +| Element | Description | +|---|---| +| Group image | Large circular profile image | +| Group name | Display name (editable by owners via `GroupProfileView`) | +| Member count | "N members" label from `activeSortedMembers` | +| Full name | Optional secondary name | +| Description | Group description text (if set) | + +### Local Alias + +Editable text field for a local-only alias (not shared with other members). Changes saved via `setGroupAlias()`. + +### Action Buttons + +Horizontal row of action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearchClicked` callback to search messages in chat | +| Mute/Unmute | Toggle notification mode | +| Add members | Opens `AddGroupMembersView` (shown when user has admin+ role and `groupInfo.canAddMembers`) | + +### Group Management Section + +Available actions depend on role (`GroupMemberRole`): + +| Action | Minimum Role | Description | +|---|---|---| +| Edit group profile | Owner | Opens `GroupProfileView` to edit name, image, description | +| Add members | Admin | Opens `AddGroupMembersView` to invite contacts | +| Manage group link | Admin | Opens `GroupLinkView` to create/share/delete group link | +| Member support | Moderator | Opens `MemberSupportView` to manage member support chats | +| Edit welcome message | Owner | Opens `GroupWelcomeView` to set the auto-sent welcome text | +| Group preferences | Any | Opens `GroupPreferencesView` (read-only; only owners can change settings) | + +### Chat Preferences + +| Setting | Description | +|---|---| +| Send receipts | Per-group delivery receipt setting (`SendReceipts`); limited to groups under `SMALL_GROUPS_RCPS_MEM_LIMIT` (20 members) | +| Chat item TTL | Per-group message retention setting with confirmation alert via `setChatTTLAlert` | + +### Member List + +Displays `activeSortedMembers` (excluding left/removed members, sorted by role descending): + +| Element | Description | +|---|---| +| Member avatar | `MEMBER_ROW_AVATAR_SIZE` (42dp) profile image | +| Member name | Display name with role badge | +| Member role | Owner, Admin, Moderator, Member, Observer | +| Member status | Active, connecting, pending, left, removed | +| Tap action | Opens `GroupMemberInfoView` with connection stats and verification code | + +### Group Link (`GroupLinkView`) + +| Element | Description | +|---|---| +| Create link button | `apiCreateGroupLink` generates a shareable group invitation link | +| QR code display | QR code rendering of the group link | +| Short link toggle | Switch between short and full link display | +| Share button | System share for the link | +| Copy button | Copy link to clipboard | +| Member role selector | Set the default role for members joining via link (`acceptMemberRole`) | +| Add short link | `apiAddGroupShortLink` creates a short link that includes group profile | +| Delete link | Remove the group link with confirmation | + +### Add Members (`AddGroupMembersView`) + +| Element | Description | +|---|---| +| Contact list | Filterable list of contacts to invite | +| Role selector | Set the role for invited members | +| Invite button | Sends group invitations to selected contacts | +| Group link option | Alternative to direct invitation | + +### Group Member Info (`GroupMemberInfoView`) + +| Element | Description | +|---|---| +| Member profile | Avatar, name, role | +| Connection stats | Server information, connection status | +| Security code | Verification code for the member connection | +| Role change | Change member role (admin+ only) | +| Remove member | Remove from group (admin+ only) | +| Block member | Block member for self | +| Direct message | Open direct chat with member | + +### Developer Tools Section + +Shown when `developerTools` preference is enabled: + +| Element | Description | +|---|---| +| Database ID | Group's internal database identifier | + +### Destructive Actions + +| Action | Condition | Description | +|---|---|---| +| Clear chat | Any member | Deletes all messages locally (`clearChatDialog`) | +| Leave group | Non-owner | Leave the group (`leaveGroupDialog`) | +| Delete group | Owner or non-current member | Delete group for all (owner) or for self (`deleteGroupDialog`) | + +Business chats use alternative labels: "Delete chat" instead of "Delete group". + +### Channel Relays View (`ChannelRelaysView`) + +Accessible from channel info; shows relay members (role == `Relay`): + +| Element | Description | +|---|---| +| Relay list | Filtered from `chatModel.groupMembers` by `Relay` role; excludes `MemRemoved` and `MemGroupDeleted` | +| Relay row | Profile image, relay display name, status text (`RelayStatus.text` or connection status via `relayConnStatus`) | +| Relay tap | Navigates to `GroupMemberInfoView` with `groupRelay:` parameter | +| Add relay entry | Owner-only "Add relay" action opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's long-press menu | +| Long-press menu | Owner-only "Remove relay" action for relays that can be removed | +| Empty state | "No chat relays" | +| Footer | "Chat relays forward messages to channel subscribers." | + +Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection status only. + +#### Channel Member Info — relay surface (in `GroupMemberInfoView`) + +| Element | Description | +|---|---| +| Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` | +| Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button | +| Status row (rejected) | Shown when `groupRelay?.relayStatus == RelayStatus.RsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `MemLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/group allow #`. | + +## Source Files + +| File | Path | +|---|---| +| `GroupChatInfoView.kt` | `views/chat/group/GroupChatInfoView.kt` | +| `GroupMemberInfoView.kt` | `views/chat/group/GroupMemberInfoView.kt` | +| `AddGroupMembersView.kt` | `views/chat/group/AddGroupMembersView.kt` | +| `GroupLinkView.kt` | `views/chat/group/GroupLinkView.kt` | +| `GroupProfileView.kt` | `views/chat/group/GroupProfileView.kt` | +| `GroupPreferences.kt` | `views/chat/group/GroupPreferences.kt` | +| `WelcomeMessageView.kt` | `views/chat/group/WelcomeMessageView.kt` | +| `MemberAdmission.kt` | `views/chat/group/MemberAdmission.kt` | +| `MemberSupportView.kt` | `views/chat/group/MemberSupportView.kt` | +| `ChannelRelaysView.kt` | `views/chat/group/ChannelRelaysView.kt` | +| `AddGroupRelayView.kt` | `views/chat/group/AddGroupRelayView.kt` | +| `AddChannelView.kt` (`RelayStatusIndicator`) | `views/newchat/AddChannelView.kt` | diff --git a/apps/multiplatform/product/views/new-chat.md b/apps/multiplatform/product/views/new-chat.md new file mode 100644 index 0000000000..b664fda67f --- /dev/null +++ b/apps/multiplatform/product/views/new-chat.md @@ -0,0 +1,96 @@ +# New Chat / Connection + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Create new contacts, groups, or connect with others via one-time invitation links or by scanning/pasting SimpleX links. This is the primary entry point for establishing new E2E encrypted connections. + +## Route / Navigation + +- **Entry point**: Tap the new chat button (pencil icon) in `ChatListView` toolbar or FAB +- **Presented by**: `NewChatSheet` modal from `ChatListView` via `showNewChatSheet()`; wraps `NewChatView` and group creation in `ModalManager.start` +- **Internal navigation**: `NewChatSheet` provides 3 action buttons: + - "Create 1-time link" -- opens `NewChatView` with `INVITE` tab (generate and share a one-time invitation link) + - "Scan / paste link" -- opens `NewChatView` with `CONNECT` tab (scan QR code or paste a received link) + - "Create group" -- opens `AddGroupView` +- **Tabs within NewChatView**: `HorizontalPager` with `TabRow` toggles between `NewChatOption.INVITE` (1-time link) and `NewChatOption.CONNECT` (connect via link) +- **Swipe gesture**: Left/right swipe switches between tabs (Android only; `userScrollEnabled = appPlatform.isAndroid`) +- **Dismiss behavior**: On dispose, a `DisposableEffect` shows an alert dialog (via `AlertManager.shared.showAlertDialog`) asking whether to keep an unused invitation link or delete it via `controller.deleteChat()` + +## Page Sections + +### Tab Selector + +| Tab | Icon | Label | Description | +|---|---|---|---| +| 1-time link | `ic_repeat_one` | "1-time link" | Generate and share a one-time invitation link | +| Connect via link | `ic_qr_code` | "Connect via link" | Scan QR code or paste a received link | + +### Invite Tab (1-time Link) -- `PrepareAndInviteView` + +Displayed when `selection == INVITE`: + +| Element | Description | +|---|---| +| QR code display | Generated QR code for the invitation link (`SimpleXLinkQRCode`) | +| Short/full link toggle | Switch between short and full link display | +| Share button | System share for the invitation link | +| Copy button | Copy link to clipboard | +| Incognito toggle | Option to connect with a random profile | +| Loading state | `CreatingLinkProgressView` with "Creating link" text while `creatingConnReq` is true | +| Retry button | `RetryButton` shown if link creation fails; calls `createInvitation()` | + +Link creation calls `apiAddContact` which returns a `CreatedConnLink` with both `connFullLink` and optional `connShortLink`. The invitation is tracked via `chatModel.showingInvitation`. + +### Connect Tab -- `ConnectView` + +Displayed when `selection == CONNECT`: + +| Element | Description | +|---|---| +| QR code scanner | Camera-based QR code scanner (`showQRCodeScanner` state) | +| Paste link field | Text field for pasting a SimpleX link (`pastedLink`) | +| Connect button | Initiates connection via `planAndConnect()` | + +When a valid SimpleX link is detected: +1. `planAndConnect()` is called with the link URI +2. If the link matches a known contact, filters to that chat +3. If the link matches a known group, filters to that group +4. Otherwise, creates a new connection + +### Create Group (`AddGroupView`) + +| Element | Description | +|---|---| +| Group name field | Required display name input with `FocusRequester` | +| Profile image picker | `GetImageBottomSheet` for selecting/cropping a group avatar | +| Incognito toggle | Option to create group with random profile (`incognitoPref`) | +| Create button | Calls `apiNewGroup()`, then opens `AddGroupMembersView` (normal) or `GroupLinkView` (incognito) | + +Group creation flow: +1. User enters group name and optionally selects an image +2. `apiNewGroup()` creates the group and returns `GroupInfo` +3. `openGroupChat()` navigates to the new group chat +4. `setGroupMembers()` preloads member data +5. `AddGroupMembersView` opens for inviting contacts (or `GroupLinkView` for incognito groups) + +### QR Code Components (`QRCode.kt`) + +| Component | Description | +|---|---| +| `SimpleXLinkQRCode` | Renders a QR code for a SimpleX connection link | +| QR scanner | Platform camera scanner for reading QR codes | +| Short link display | Compact link text with copy/share actions | + +## Source Files + +| File | Path | +|---|---| +| `NewChatView.kt` | `views/newchat/NewChatView.kt` | +| `AddGroupView.kt` | `views/newchat/AddGroupView.kt` | +| `QRCode.kt` | `views/newchat/QRCode.kt` | +| `NewChatSheet.kt` | `views/newchat/NewChatSheet.kt` | +| `ConnectPlan.kt` | `views/newchat/ConnectPlan.kt` | +| `QRCodeScanner.kt` | `views/newchat/QRCodeScanner.kt` (expect/actual) | +| `ContactConnectionInfoView.kt` | `views/newchat/ContactConnectionInfoView.kt` | diff --git a/apps/multiplatform/product/views/onboarding.md b/apps/multiplatform/product/views/onboarding.md new file mode 100644 index 0000000000..4127ac65f7 --- /dev/null +++ b/apps/multiplatform/product/views/onboarding.md @@ -0,0 +1,139 @@ +# Onboarding + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +First-time setup flow for new users. Guides through app introduction, profile creation, database passphrase setup (Desktop), server operator conditions acceptance, SimpleX address creation, and notification configuration (Android). Also provides an entry point for device migration. + +## Route / Navigation + +- **Entry point**: App launch when `onboardingStage` is not `OnboardingComplete` +- **Presented by**: `OnboardingView` renders the appropriate step based on `OnboardingStage` enum +- **Flow direction**: Linear progression controlled by `appPrefs.onboardingStage` +- **Completion**: Sets `onboardingStage` to `OnboardingComplete` + +## Onboarding Stages + +The `OnboardingStage` enum defines the flow: + +| Stage | Description | +|---|---| +| `Step1_SimpleXInfo` | Welcome screen with app introduction | +| `Step2_CreateProfile` | Create first user profile | +| `LinkAMobile` | Desktop-only: link a mobile device | +| `Step2_5_SetupDatabasePassphrase` | Desktop-only: set database encryption passphrase | +| `Step3_ChooseServerOperators` | Accept server operator conditions | +| `Step3_CreateSimpleXAddress` | Create a SimpleX contact address | +| `Step4_SetNotificationsMode` | Android-only: configure notification mode | +| `OnboardingComplete` | Onboarding finished | + +## Page Sections + +### Step 1: Welcome / SimpleX Info (`SimpleXInfo`) + +**Stage**: `Step1_SimpleXInfo` + +| Element | Description | +|---|---| +| Logo | `SimpleXLogo` -- SimpleX Chat logo (light/dark variant based on `isInDarkTheme()`) | +| Info button | `OnboardingInformationButton` -- "The next generation of private messaging"; taps open `HowItWorks` fullscreen modal | +| Privacy redefined | `InfoRow` with privacy icon: "No user identifiers" | +| Immune to spam | `InfoRow` with shield icon: "You decide who can connect" | +| Decentralized | `InfoRow` with decentralized icon: "Anybody can host servers" | +| **Create your profile** button | `OnboardingActionButton` -- primary action; advances to profile creation | +| **Migrate from another device** button | `TextButtonBelowOnboardingButton` -- opens `MigrateToDeviceView` fullscreen modal | + +Layout: `ColumnWithScrollBar` with `DEFAULT_ONBOARDING_HORIZONTAL_PADDING`, max width constrained (250dp Android, 500dp Desktop). + +### Step 2: Create Profile + +**Stage**: `Step2_CreateProfile` + +| Element | Description | +|---|---| +| Display name field | Required text input; auto-focused | +| Validation | Name validation with `mkValidName` check | +| Create button | Creates profile via API; advances to next step | + +Profile is stored locally and only shared with contacts. + +### Step 2.5: Setup Database Passphrase (Desktop only) + +**Stage**: `Step2_5_SetupDatabasePassphrase` + +| Element | Description | +|---|---| +| Passphrase field | Secure text input for database encryption key | +| Confirm field | Passphrase confirmation | +| Set button | Encrypts database with passphrase | + +### Link a Mobile (Desktop only) + +**Stage**: `LinkAMobile` + +| Element | Description | +|---|---| +| Instructions | How to connect mobile device to desktop | +| QR code | Connection QR code for mobile scanning | +| Skip button | Skip this step | + +### Step 3: Choose Server Operators + +**Stage**: `Step3_ChooseServerOperators` + +| Element | Description | +|---|---| +| Operator list | Available server operators with conditions | +| Conditions text | Terms of service for selected operators | +| Accept button | Accept conditions and continue | + +Managed by `ChooseServerOperators.kt`. + +### Step 3b: Create SimpleX Address + +**Stage**: `Step3_CreateSimpleXAddress` + +| Element | Description | +|---|---| +| Address creation | Auto-creates a SimpleX contact address | +| QR code | Displays the created address as QR code | +| Share button | Share address link | +| Skip button | Skip address creation | + +### Step 4: Set Notifications Mode (Android only) + +**Stage**: `Step4_SetNotificationsMode` + +| Element | Description | +|---|---| +| Notification options | Instant (background service) / Periodic (every 10 min) / Off | +| Description | Explains battery impact and notification behavior for each mode | +| Continue button | Saves selection and completes onboarding | + +Managed by `SetNotificationsMode.kt`. + +### What's New (`WhatsNewView`) + +Shown after onboarding or when triggered from Settings: + +| Element | Description | +|---|---| +| Version highlights | New features and changes in the current version | +| Updated conditions | Notice about updated server operator conditions (if applicable) | +| Close button | Dismisses the view | + +Triggered in `ChatListView` via `shouldShowWhatsNew()` with a 1-second delay. + +## Source Files + +| File | Path | +|---|---| +| `OnboardingView.kt` | `views/onboarding/OnboardingView.kt` | +| `SimpleXInfo.kt` | `views/onboarding/SimpleXInfo.kt` | +| `HowItWorks.kt` | `views/onboarding/HowItWorks.kt` | +| `SetupDatabasePassphrase.kt` | `views/onboarding/SetupDatabasePassphrase.kt` | +| `SetNotificationsMode.kt` | `views/onboarding/SetNotificationsMode.kt` | +| `ChooseServerOperators.kt` | `views/onboarding/ChooseServerOperators.kt` | +| `WhatsNewView.kt` | `views/onboarding/WhatsNewView.kt` | +| `LinkAMobileView.kt` | `views/onboarding/LinkAMobileView.kt` | diff --git a/apps/multiplatform/product/views/settings.md b/apps/multiplatform/product/views/settings.md new file mode 100644 index 0000000000..e668bf2d04 --- /dev/null +++ b/apps/multiplatform/product/views/settings.md @@ -0,0 +1,159 @@ +# Settings + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/services/theme.md](../../spec/services/theme.md) | [spec/services/notifications.md](../../spec/services/notifications.md) + +## Purpose + +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker or directly from the chat list toolbar. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> Settings option; or directly via `NavigationButtonMenu` when no users exist +- **Presented by**: `SettingsView` composable via `ModalManager.start.showModalCloseable` +- **Navigation title**: "Your settings" (`AppBarTitle`) +- **Sub-navigation**: Each settings row opens a dedicated view via `showSettingsModal` or `showCustomModal` + +## Platform Differences + +| Aspect | Android | Desktop | +|---|---|---| +| App section | Device settings, app version | App updates (`AppUpdater`), device settings, app version | +| Notifications | Full notification mode selection (instant/periodic/off) | Notification settings | +| Use from desktop/mobile | "Use from desktop" option in UserPicker | "Link a mobile" / "Linked mobiles" option in UserPicker | +| Database migration | "Migrate to another device" with auth | Same | + +## Page Sections + +### Settings Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Notifications | `ic_bolt` / `ic_bolt_off` | `NotificationsSettingsView` | Push notification mode and preview settings | +| Network & servers | `ic_wifi_tethering` | `NetworkAndServersView` | SMP/XFTP servers, proxy, .onion hosts, advanced network | +| Audio & video calls | `ic_videocam` | `CallSettingsView` | WebRTC relay policy, ICE servers | +| Privacy & security | `ic_lock` | `PrivacySettingsView` | SimpleX Lock, delivery receipts, link previews, auto-accept | +| Appearance | `ic_light_mode` | `AppearanceView` | Theme, language, profile images, chat bubbles | + +All rows disabled when `chatModel.chatRunning != true` (except Appearance). + +#### Notifications (`NotificationsSettingsView`) + +| Setting | Options | +|---|---| +| Notification mode | Instant (background service) / Periodic (every 10 min) / Off | +| Notification preview | Configuration for notification content visibility | + +#### Network & Servers (`NetworkAndServersView`) + +| Setting | Description | +|---|---| +| SMP servers | Messaging relay servers; per-operator configuration | +| XFTP servers | File transfer servers; per-operator configuration | +| Server operators | `OperatorView` for each configured operator | +| Advanced network | `AdvancedNetworkSettings` -- timeouts, TCP keep-alive, reconnect intervals | +| Proxy configuration | SOCKS proxy, .onion host settings | + +Sub-files: `NetworkAndServers.kt`, `ProtocolServersView.kt`, `ProtocolServerView.kt`, `NewServerView.kt`, `ScanProtocolServer.kt`, `AdvancedNetworkSettings.kt`, `OperatorView.kt` + +#### Audio & Video Calls (`CallSettingsView`) + +| Setting | Description | +|---|---| +| WebRTC relay policy | Always relay / relay when needed / never relay | +| ICE servers | Custom STUN/TURN server configuration | + +#### Privacy & Security (`PrivacySettingsView`) + +Organized in sections: + +**Device Section** (`PrivacyDeviceSection`): + +| Setting | Description | +|---|---| +| SimpleX Lock | `SimplexLockView` -- app lock with system auth or passcode (`LAMode.SYSTEM` / `LAMode.PASSCODE`) | + +**Chats Section**: + +| Setting | Preference Key | Description | +|---|---|---| +| Send link previews | `privacyLinkPreviews` | Auto-generate link preview cards | +| Sanitize links | `privacySanitizeLinks` | Strip tracking parameters from URLs | +| Show last messages | `privacyShowChatPreviews` | Show message previews in chat list | +| Message draft | `privacySaveLastDraft` | Save unsent message draft for each chat | + +**Files Section**: + +| Setting | Preference Key | Description | +|---|---|---| +| Encrypt local files | `privacyEncryptLocalFiles` | Encrypt files stored on device | +| Auto-accept images | `privacyAcceptImages` | Automatically download received images | +| Blur media radius | `privacyMediaBlurRadius` | Blur radius for media previews | +| Protect IP address | `privacyAskToApproveRelays` | Prompt before connecting to unknown file relays to protect IP address | + +#### Appearance (`AppearanceView`) + +Platform-specific composable (`expect fun AppearanceView`): + +| Setting | Description | +|---|---| +| Profile images | `ProfileImageSection` -- slider for profile image corner radius | +| Theme selection | Color scheme / theme picker | +| Language | App language selection | +| Chat wallpaper | Background image settings | +| Chat bubbles | Message bubble appearance configuration | +| Toolbar opacity | App bar transparency settings (`inAppBarsAlpha`) | +| Color picker | `ClassicColorPicker` for custom theme colors | + +### Chat Database Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Database passphrase & export | `ic_database` | `DatabaseView` | Manage encryption, export/import database | +| Migrate to another device | `ic_ios_share` | `MigrateFromDeviceView` | Device migration (requires auth) | + +Database icon shows warning color (`WarningOrange`) when database is not encrypted or passphrase is not saved. + +### Help Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| How to use SimpleX Chat | `ic_help` | `HelpView` | Usage guide | +| What's new | `ic_add` | `WhatsNewView` | Version changelog | +| About SimpleX Chat | `ic_info` | `SimpleXInfo` (non-onboarding mode) | App information | +| Chat with the founder | `ic_tag` | Opens SimpleX link | Direct chat with SimpleX team | +| Send us an email | `ic_mail` | Opens mailto: | Email support | + +### Support Section + +| Row | Icon | Description | +|---|---|---| +| Contribute | `ic_keyboard` | Opens GitHub contribution page (hidden for Android Bundle) | +| Rate the app | `ic_star` | Opens Google Play / app store listing | +| Star on GitHub | `ic_github` | Opens GitHub repository | + +### App Section (`SettingsSectionApp`) + +Platform-specific section (expect/actual composable): + +| Row | Description | +|---|---| +| App updates (Desktop) | App update checker and installer | +| Developer tools | Toggle developer mode | +| Chat console | Opens `ChatConsoleView` terminal | +| Terminal always visible (Desktop) | Keep terminal window open | +| Install terminal app | Link to CLI app on GitHub | +| Reset all hints | Reset dismissed hint/card preferences | +| App version | Version string with build info; taps open `VersionInfoView` | + +## Source Files + +| File | Path | +|---|---| +| `SettingsView.kt` | `views/usersettings/SettingsView.kt` | +| `Appearance.kt` | `views/usersettings/Appearance.kt` | +| `PrivacySettings.kt` | `views/usersettings/PrivacySettings.kt` | +| `NetworkAndServers.kt` | `views/usersettings/networkAndServers/NetworkAndServers.kt` | +| `AdvancedNetworkSettings.kt` | `views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| `OperatorView.kt` | `views/usersettings/networkAndServers/OperatorView.kt` | +| `ProtocolServersView.kt` | `views/usersettings/networkAndServers/ProtocolServersView.kt` | +| `NewServerView.kt` | `views/usersettings/networkAndServers/NewServerView.kt` | diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md new file mode 100644 index 0000000000..dfc37a5e8d --- /dev/null +++ b/apps/multiplatform/product/views/user-profiles.md @@ -0,0 +1,122 @@ +# User Profiles + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Manage multiple chat profiles within a single app instance. Users can create, switch between, hide, mute, and delete profiles. Hidden profiles are protected by password. The UserPicker provides quick profile switching from the chat list, while UserProfilesView offers full profile management. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> "Your chat profiles" +- **Presented by**: `UserProfilesView` composable via `ModalManager.start.showCustomModal` with search bar +- **Navigation title**: "Your chat profiles" (`AppBarTitle`) +- **Sub-navigation**: + - Create profile -> `CreateProfile` (via `ModalManager.center`) + - Edit active profile -> `UserProfileView` (via UserPicker tap on active user) + - User address -> `UserAddressView` (via UserPicker) + - Chat preferences -> `PreferencesView` (via UserPicker) + +## Page Sections + +### UserPicker (`UserPicker.kt`) + +Overlay panel triggered from `ChatListView` toolbar: + +| Section | Description | +|---|---| +| Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | +| Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | +| User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | +| SimpleX address | Row to open `UserAddressView` (create or view address) | +| Chat preferences | Row to open `PreferencesView` | +| Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | +| Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | +| Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | + +Platform behavior: +- **Android**: `PlatformUserPicker` renders as bottom sheet with `AnimatedViewState` transitions; shows all users inline +- **Desktop**: Sidebar panel; shows only active user in header, inactive users in separate section below divider + +### UserProfilesView + +Full profile management screen with search/password field: + +#### Search / Password Field + +Combined text field at the top (`searchTextOrPassword`): +- In normal mode: Filters visible profiles by name +- For hidden profiles: Acts as password entry to reveal hidden profiles +- Trimmed search text compared against `user.anyNameContains()` and `correctPassword()` + +#### Profile List + +Each row rendered by `UserView` -> `UserProfilePickerItem`: + +| Element | Description | +|---|---| +| Active indicator | Checkmark icon (`ic_done_filled`) for the current active profile | +| Profile image | 54dp avatar with `fontSizeSqrtMultiplier` scaling | +| Display name | Profile's display name; bold for active, normal for inactive | +| Unread count | Badge showing unread message count (`unreadCountStr`) with primary/secondary color based on mute state | +| Muted indicator | `ic_notifications_off` icon when profile notifications are muted | +| Hidden indicator | `ic_lock` icon for hidden profiles (only shown when revealed via password) | + +#### Profile Row Tap Action + +| Action | Description | +|---|---| +| Switch active | Tapping a profile row calls `changeActiveUser()` to activate the selected profile; all chats switch context | + +#### Profile Actions (Context Menu) + +Available via long-press / right-click on a profile row (`DefaultDropdownMenu`): + +| Action | Condition | Description | +|---|---|---| +| Mute | Visible, notifications on | `apiMuteUser()` mutes notifications; shows `showMuteProfileAlert` on first use | +| Unmute | Visible, notifications off | `apiUnmuteUser()` restores notifications | +| Hide | Visible, multiple visible users | Opens `HiddenProfileView` to set password | +| Unhide | Hidden profile | `apiUnhideUser()` with password entry (`ProfileActionView` with `UserProfileAction.UNHIDE`) | +| Delete | Any non-sole profile | Delete with confirmation dialog; options: "Delete with connections" (removes SMP queues) or "Delete data only" | + +#### Add Profile + +| Element | Description | +|---|---| +| Add button | "+" icon with "Add profile" text at bottom of list (hidden when searching) | +| Auth required | Profile creation requires authentication via `withAuth` | +| Create view | Opens `CreateProfile` in `ModalManager.center` | + +#### Profile Deletion (`removeUser`) + +Deletion flow: +1. If hidden profile requiring password: opens `ProfileActionView` with `UserProfileAction.DELETE` +2. If active profile: switches to another visible user first via `changeActiveUser_`, then deletes +3. If last visible profile with hidden profiles: deletes user, then changes active to null; on Android, stops chat and resets to onboarding +4. Cleans up wallpaper files and cancels notifications for the deleted user + +#### Hidden Profile Notice + +Shown once via `showHiddenProfilesNotice` preference: + +| Element | Description | +|---|---| +| Alert title | "Make profile private" | +| Alert text | "You can hide or mute user profile" | +| "Don't show again" | Disables the notice permanently | + +### Profile Password Validation + +| Function | Description | +|---|---| +| `correctPassword()` | Validates password against `user.viewPwdHash` using `chatPasswordHash(pwd, salt)` | +| `passwordEntryRequired()` | Returns true if user is hidden, active, and password does not match current search text | +| `userViewPassword()` | Extracts view password from search text for hidden user operations | + +## Source Files + +| File | Path | +|---|---| +| `UserProfilesView.kt` | `views/usersettings/UserProfilesView.kt` | +| `UserPicker.kt` | `views/chatlist/UserPicker.kt` | diff --git a/apps/multiplatform/settings.gradle.kts b/apps/multiplatform/settings.gradle.kts index 40446f1958..50a50d531d 100644 --- a/apps/multiplatform/settings.gradle.kts +++ b/apps/multiplatform/settings.gradle.kts @@ -3,7 +3,6 @@ pluginManagement { google() gradlePluginPortal() mavenCentral() - maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } plugins { diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md new file mode 100644 index 0000000000..c5d9a3b4f7 --- /dev/null +++ b/apps/multiplatform/spec/README.md @@ -0,0 +1,137 @@ +# SimpleX Chat -- Kotlin Multiplatform Specification + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Dependency Graph](#dependency-graph) +3. [Specification Documents](#specification-documents) +4. [Product Documents](#product-documents) +5. [Source Entry Points](#source-entry-points) + +--- + +## Executive Summary + +SimpleX Chat is a Kotlin Multiplatform application targeting **Android** and **Desktop** (JVM) platforms. The UI layer is built entirely with Jetpack Compose. The application communicates with a Haskell-based cryptographic core (`simplex-chat`) through a **JNI bridge** -- native functions declared in Kotlin and linked at runtime to a shared library (`libapp-lib`). Platform-specific behavior (notifications, file system paths, services, audio/video) is abstracted using the `expect`/`actual` pattern and a runtime-assignable `PlatformInterface` callback object. + +The Gradle project is structured as three modules: + +| Module | Purpose | +|---|---| +| `:common` | Shared Compose UI, models, platform abstractions (`commonMain`, `androidMain`, `desktopMain`) | +| `:android` | Android application entry point (`SimplexApp`, `MainActivity`) | +| `:desktop` | Desktop application entry point (`Main.kt`, `showApp()`) | + +All meaningful application logic resides in `:common/commonMain`. Platform source sets (`androidMain`, `desktopMain`) provide `actual` implementations for `expect` declarations and host platform-specific integration code. + +--- + +## Dependency Graph + +``` +App Entry Points ++-- Android: SimplexApp.onCreate -> initHaskell -> initMultiplatform -> initChatControllerOnStart +| MainActivity.onCreate -> setContent { AppScreen() } ++-- Desktop: main() -> initHaskell -> runMigrations -> initApp -> showApp -> AppWindow -> AppScreen() + | + v +Common Module (commonMain) ++-- ChatModel (Compose state singleton) <-> ChatController/SimpleXAPI (JNI bridge) <-> Haskell Core (chat_ctrl) ++-- Views (Compose) +| +-- App.kt: AppScreen -> MainScreen +| +-- ChatListView -> ChatView -> ComposeView -> SendMsgView +| +-- ChatItemView (message rendering: text, image, video, voice, file, call, events) +| +-- Settings: SettingsView, UserProfileView, UserProfilesView +| +-- Onboarding: OnboardingView, WhatsNewView, CreateFirstProfile +| +-- Call: CallView, IncomingCallAlertView +| +-- Database: DatabaseView, DatabaseEncryptionView, DatabaseErrorView +| +-- Groups: GroupChatInfoView, AddGroupMembersView, GroupMemberInfoView +| +-- Contacts: ContactListNavView +| +-- Remote: ConnectDesktopView, ConnectMobileView +| +-- Terminal: TerminalView ++-- Models +| +-- ChatModel -- global app state (Compose MutableState singleton) +| +-- ChatsContext -- per-context chat list state (primary + optional secondary) +| +-- Chat -- per-conversation state (chatInfo, chatItems, chatStats) +| +-- ChatController -- API command dispatch, event receiver, preferences +| +-- AppPreferences -- 150+ SharedPreferences keys ++-- Services +| +-- NtfManager -- abstract notification coordinator (Android/Desktop implementations) +| +-- SimplexService -- Android foreground service for background messaging +| +-- ThemeManager -- theme resolution (system/light/dark/simplex/black + per-user overrides) +| +-- CallManager -- WebRTC call lifecycle ++-- Platform (expect/actual) + +-- Core.kt -- JNI declarations (external fun), initChatController, chatInitTemporaryDatabase + +-- AppCommon.kt -- runMigrations, AppPlatform enum + +-- Files.kt -- dataDir, tmpDir, filesDir, dbAbsolutePrefixPath (expect) + +-- Share.kt -- shareText, shareFile, openFile (expect) + +-- VideoPlayer.kt -- VideoPlayerInterface, VideoPlayer (expect class) + +-- RecAndPlay.kt -- RecorderInterface, AudioPlayerInterface (expect) + +-- UI.kt -- showToast, hideKeyboard, getKeyboardState (expect) + +-- Notifications.kt -- allowedToShowNotification (expect) + +-- NtfManager.kt -- abstract NtfManager class + +-- Platform.kt -- PlatformInterface (runtime callback object) + +-- Cryptor.kt -- CryptorInterface (expect) + +-- Images.kt -- bitmap utilities (expect) + +-- SimplexService.kt-- getWakeLock (expect) + +-- Log.kt, Modifier.kt, Back.kt, ScrollableColumn.kt, PlatformTextField.kt, Resources.kt +``` + +--- + +## Specification Documents + +| Document | Path | Description | +|---|---|---| +| Architecture | [spec/architecture.md](architecture.md) | System layers, module structure, JNI bridge, app lifecycle, event streaming, platform abstraction | +| State Management | [spec/state.md](state.md) | ChatModel singleton, ChatsContext, Chat data class, AppPreferences, ActiveChatState | +| API | [spec/api.md](api.md) | ChatController command dispatch, ~150 API functions in 11 categories, CC/CR/API types | +| Database | [spec/database.md](database.md) | SQLite database files, migrations, encryption, backup/restore | +| Impact | [spec/impact.md](impact.md) | Source file → product concept mapping for change impact analysis | +| Chat View | [spec/client/chat-view.md](client/chat-view.md) | ChatView, ChatItemView, message rendering, item interactions | +| Chat List | [spec/client/chat-list.md](client/chat-list.md) | ChatListView, ChatPreviewView, filtering, search, tags | +| Compose | [spec/client/compose.md](client/compose.md) | ComposeView, SendMsgView, ComposeState, attachments, mentions | +| Navigation | [spec/client/navigation.md](client/navigation.md) | App screen routing, onboarding, settings, new chat flows | +| Calls | [spec/services/calls.md](services/calls.md) | WebRTC call lifecycle, signaling, platform-specific call views | +| Files | [spec/services/files.md](services/files.md) | File transfer (SMP inline / XFTP), CryptoFile encryption, platform file paths | +| Notifications | [spec/services/notifications.md](services/notifications.md) | NtfManager, SimplexService, notification channels, background delivery | +| Theme | [spec/services/theme.md](services/theme.md) | ThemeManager, color system, wallpapers, per-user overrides | + +--- + +## Product Documents + +| Category | Path | Topic | +|---|---|---| +| Overview | [product/README.md](../product/README.md) | Product overview, capability map, navigation map | +| Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source | +| Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) | +| Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories | +| Gaps | [product/gaps.md](../product/gaps.md) | 7 known gaps with recommendations | +| Flows | [product/flows/](../product/flows/) | onboarding, messaging, connection, calling, file-transfer, group-lifecycle | +| Views | [product/views/](../product/views/) | chat-list, chat, settings, onboarding, call, new-chat, contact-info, group-info, user-profiles | + +--- + +## Source Entry Points + +| Component | File | Key Symbol | Line | +|---|---|---|---| +| Android Application | [`SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L41) | `class SimplexApp` | 41 | +| Android Activity | [`MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L27) | `class MainActivity` | 27 | +| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) | `fun main()` | 21 | +| Desktop App Window | [`DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) | `fun showApp()` | 33 | +| Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 | +| Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 | +| JNI Bridge | [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | +| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | `object ChatController` | 493 | +| Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 | +| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) | `class AppPreferences` | 94 | +| Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 | +| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L19) | `abstract class NtfManager` | 19 | +| Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 | +| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L33) | `fun initHaskell(packageName: String)` | 33 | +| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L41) | `fun runMigrations()` | 41 | +| Android Service | [`SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt#L41) | `class SimplexService` | 41 | +| Gradle Root | [`settings.gradle.kts`](../settings.gradle.kts#L22) | `include(":android", ":desktop", ":common")` | 22 | +| Common Build | [`build.gradle.kts`](../common/build.gradle.kts#L14) | `kotlin { androidTarget(); jvm("desktop") }` | 14 | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md new file mode 100644 index 0000000000..4114e9de4f --- /dev/null +++ b/apps/multiplatform/spec/api.md @@ -0,0 +1,436 @@ +# Chat API Reference + +## Table of Contents + +1. [Overview](#1-overview) +2. [Command Categories](#2-command-categories) + - 2.1 [User Management](#21-user-management) + - 2.2 [Chat Lifecycle](#22-chat-lifecycle) + - 2.3 [Message Operations](#23-message-operations) + - 2.4 [Group Operations](#24-group-operations) + - 2.5 [Contact Operations](#25-contact-operations) + - 2.6 [File Operations](#26-file-operations) + - 2.7 [Call Operations](#27-call-operations) + - 2.8 [Settings & Network](#28-settings--network) + - 2.9 [Chat Tags](#29-chat-tags) + - 2.10 [Server Operators](#210-server-operators) + - 2.11 [Archive](#211-archive) +3. [Response Types](#3-response-types) +4. [Event Types](#4-event-types) +5. [Error Types](#5-error-types) +6. [Source Files](#6-source-files) + +--- + +## 1. Overview + +The SimpleX Chat API bridge connects Kotlin/Compose UI code to the Haskell core via JNI. All communication follows a **command/response JSON protocol**: + +``` +Kotlin suspend fun api*() + -> ChatController.sendCmd(rhId, CC.*, ctrl) + -> serialize CC to cmdString (JSON) + -> chatSendCmdRetry(ctrl, cmdString, retryNum) [JNI / external fun] + -> Haskell core processes command + -> returns JSON response string + -> json.decodeFromString(responseString) + -> API.Result(rhId, CR.*) or API.Error(rhId, ChatError) + -> pattern-match on CR subclass -> update ChatModel / return data to UI +``` + +**Key types in the pipeline:** + +| Type | Role | Location | +|------|------|----------| +| `CC` (sealed class) | Command definitions (~165 subclasses) | [SimpleXAPI.kt#L3529](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L3529) | +| `API` (sealed class) | Top-level response wrapper (`Result` / `Error`) | [SimpleXAPI.kt#L5975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L5975) | +| `CR` (sealed class) | Chat response variants (~180 subclasses) | [SimpleXAPI.kt#L6114](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6114) | +| `ChatError` (sealed class) | Error hierarchy | [SimpleXAPI.kt#L6974](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6974) | +| `ChatController` (object) | Singleton hosting all `api*` functions | [SimpleXAPI.kt#L493](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | + +**JNI bridge functions** (declared in [Core.kt#L25](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25)): + +```kotlin +external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array +external fun chatCloseStore(ctrl: ChatCtrl): String +external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String +external fun chatSendRemoteCmdRetry(ctrl: ChatCtrl, rhId: Int, msg: String, retryNum: Int): String +external fun chatRecvMsg(ctrl: ChatCtrl): String +external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String +``` + + + +**`sendCmd` flow** ([SimpleXAPI.kt#L804](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L804)): + +1. Obtains the `ChatCtrl` handle (or uses the provided `otherCtrl`). +2. Serializes the `CC` command to its `cmdString`. +3. Dispatches to `Dispatchers.IO`; calls `chatSendCmdRetry` (local) or `chatSendRemoteCmdRetry` (remote host). +4. Decodes the returned JSON string into `API`. +5. Logs the result to the terminal item list. + + + + + +**Asynchronous event receiver** (`startReceiver`, [SimpleXAPI.kt#L660](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660)): + +A long-running coroutine on `Dispatchers.IO` repeatedly calls `chatRecvMsgWait` (blocking JNI). Each received `API` message is dispatched to `processReceivedMsg` ([SimpleXAPI.kt#L2568](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568)), which pattern-matches on `CR` subclasses to update `ChatModel` state and trigger notifications. + +--- + + + +## 2. Command Categories + +All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.kt#L493](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493)). The `rh` / `rhId` parameter is `Long?` identifying a remote host (`null` = local device). + +### 2.1 User Management + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiGetActiveUser` | `rh: Long?, ctrl: ChatCtrl?` | Fetch the currently active user profile | [L841](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L841) | +| `apiCreateActiveUser` | `rh: Long?, p: Profile?, pastTimestamp: Boolean, ctrl: ChatCtrl?` | Create a new user profile and set it as active | [L851](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L851) | +| `listUsers` | `rh: Long?` | List all user profiles sorted by display name | [L871](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L871) | +| `apiSetActiveUser` | `rh: Long?, userId: Long, viewPwd: String?` | Switch the active user to a different profile | [L881](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L881) | +| `apiSetAllContactReceipts` | `rh: Long?, enable: Boolean` | Enable/disable delivery receipts for all contacts globally | [L888](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L888) | +| `apiSetUserContactReceipts` | `u: User, userMsgReceiptSettings: UserMsgReceiptSettings` | Set delivery receipt settings for user contacts | [L894](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L894) | +| `apiSetUserGroupReceipts` | `u: User, userMsgReceiptSettings: UserMsgReceiptSettings` | Set delivery receipt settings for user groups | [L900](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L900) | +| `apiSetUserAutoAcceptMemberContacts` | `u: User, enable: Boolean` | Toggle auto-accept for member contact requests | [L906](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L906) | +| `apiHideUser` | `u: User, viewPwd: String` | Hide a user profile behind a password | [L912](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L912) | +| `apiUnhideUser` | `u: User, viewPwd: String` | Unhide a previously hidden user profile | [L915](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L915) | +| `apiMuteUser` | `u: User` | Mute all notifications for a user profile | [L918](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L918) | +| `apiUnmuteUser` | `u: User` | Unmute notifications for a user profile | [L921](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L921) | +| `apiDeleteUser` | `u: User, delSMPQueues: Boolean, viewPwd: String?` | Delete a user profile and optionally its SMP queues | [L930](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L930) | +| `apiUpdateProfile` | `rh: Long?, profile: Profile` | Update the active user's display profile | [L1682](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1682) | +| `apiSetProfileAddress` | `rh: Long?, on: Boolean` | Enable/disable including address in user profile | [L1694](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1694) | +| `apiSetUserUIThemes` | `rh: Long?, userId: Long, themes: ThemeModeOverrides?` | Set UI theme overrides for a user | [L1732](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1732) | + +### 2.2 Chat Lifecycle + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiStartChat` | `ctrl: ChatCtrl?` | Start the chat engine (returns `true` if newly started) | [L937](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L937) | +| `apiStopChat` | _(none)_ | Stop the chat engine | [L955](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L955) | +| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder, remoteHostsFolder: String, ctrl: ChatCtrl?` | Configure file-system paths for the Haskell core | [L961](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L961) | +| `apiSetEncryptLocalFiles` | `enable: Boolean` | Enable/disable encryption of locally stored files | [L967](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L967) | +| `apiSaveAppSettings` | `settings: AppSettings` | Persist application settings to the core | [L969](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L969) | +| `apiGetAppSettings` | `settings: AppSettings` | Retrieve application settings from the core | [L975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L975) | +| `apiGetChats` | `rh: Long?` | Fetch the list of all chats for the active user | [L1013](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1013) | +| `apiGetChat` | `rh, type, id, scope, contentTag, pagination, search` | Fetch a single chat with paginated messages | [L1031](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1031) | +| `apiGetChatContentTypes` | `rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?` | Get available content type filters for a chat | [L1044](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1044) | +| `apiClearChat` | `rh: Long?, type: ChatType, id: Long` | Delete all messages in a chat | [L1675](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1675) | +| `apiDeleteChat` | `rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode` | Delete a chat (contact, group, connection, etc.) | [L1620](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1620) | +| `apiChatRead` | `rh: Long?, type: ChatType, id: Long` | Mark a chat as read | [L1888](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1888) | +| `apiChatItemsRead` | `rh, type, id, scope, itemIds` | Mark specific chat items as read | [L1902](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1902) | +| `apiChatUnread` | `rh: Long?, type: ChatType, id: Long, unreadChat: Boolean` | Toggle a chat's unread flag | [L1909](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1909) | +| `getChatItemTTL` | `rh: Long?` | Get the auto-delete TTL for chat items | [L1286](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1286) | +| `setChatItemTTL` | `rh: Long?, chatItemTTL: ChatItemTTL` | Set the auto-delete TTL for chat items | [L1299](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1299) | +| `setChatTTL` | `rh: Long?, chatType, id, chatItemTTL` | Set TTL for a specific chat | [L1306](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1306) | + +### 2.3 Message Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiSendMessages` | `rh, type, id, scope, live, ttl, composedMessages` | Send one or more messages to a chat | [L1074](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1074) | +| `apiCreateChatItems` | `rh: Long?, noteFolderId: Long, composedMessages: List` | Create items in a private notes folder | [L1111](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1111) | +| `apiReportMessage` | `rh, groupId, chatItemId, reportReason, reportText` | Report a message in a group | [L1119](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1119) | +| `apiGetChatItemInfo` | `rh, type, id, scope, itemId` | Get delivery info for a specific chat item | [L1126](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1126) | +| `apiForwardChatItems` | `rh, toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl` | Forward messages between chats | [L1133](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1133) | +| `apiPlanForwardChatItems` | `rh, fromChatType, fromChatId, fromScope, chatItemIds` | Check forward feasibility before forwarding | [L1138](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1138) | +| `apiUpdateChatItem` | `rh, type, id, scope, itemId, updatedMessage, live` | Edit an existing message | [L1145](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1145) | +| `apiChatItemReaction` | `rh, type, id, scope, itemId, add, reaction` | Add or remove a reaction to a message | [L1168](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1168) | +| `apiGetReactionMembers` | `rh: Long?, groupId: Long, itemId: Long, reaction: MsgReaction` | List members who reacted with a specific emoji | [L1175](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1175) | +| `apiDeleteChatItems` | `rh, type, id, scope, itemIds, mode` | Delete messages (for self or for everyone) | [L1183](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1183) | +| `apiDeleteMemberChatItems` | `rh: Long?, groupId: Long, itemIds: List` | Moderate: delete another member's messages | [L1190](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1190) | +| `apiArchiveReceivedReports` | `rh: Long?, groupId: Long` | Archive all received reports in a group | [L1197](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1197) | +| `apiDeleteReceivedReports` | `rh: Long?, groupId: Long, itemIds: List, mode: CIDeleteMode` | Delete specific received reports | [L1204](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1204) | + +### 2.4 Group Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiNewGroup` | `rh: Long?, incognito: Boolean, groupProfile: GroupProfile` | Create a new group | [L2092](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2092) | +| `apiAddMember` | `rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole` | Invite a contact to a group | [L2100](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2100) | +| `apiJoinGroup` | `rh: Long?, groupId: Long` | Accept a group invitation | [L2109](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2109) | +| `apiAcceptMember` | `rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole` | Accept a member joining via group link | [L2135](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2135) | +| `apiDeleteMemberSupportChat` | `rh: Long?, groupId: Long, groupMemberId: Long` | Delete a member's support chat | [L2144](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2144) | +| `apiRemoveMembers` | `rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean` | Remove members from a group | [L2151](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2151) | +| `apiMembersRole` | `rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole` | Change the role of group members | [L2160](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2160) | +| `apiBlockMembersForAll` | `rh: Long?, groupId: Long, memberIds: List, blocked: Boolean` | Block/unblock members for all group participants | [L2169](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2169) | +| `apiLeaveGroup` | `rh: Long?, groupId: Long` | Leave a group | [L2178](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2178) | +| `apiListMembers` | `rh: Long?, groupId: Long` | List all members of a group | [L2185](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2185) | +| `apiUpdateGroup` | `rh: Long?, groupId: Long, groupProfile: GroupProfile` | Update group profile (name, image, etc.) | [L2192](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2192) | +| `apiCreateGroupLink` | `rh: Long?, groupId: Long, memberRole: GroupMemberRole` | Create a group invitation link | [L2211](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2211) | +| `apiGroupLinkMemberRole` | `rh: Long?, groupId: Long, memberRole: GroupMemberRole` | Update the default role for group link joins | [L2226](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2226) | +| `apiDeleteGroupLink` | `rh: Long?, groupId: Long` | Delete the group invitation link | [L2235](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2235) | +| `apiGetGroupLink` | `rh: Long?, groupId: Long` | Retrieve the current group invitation link | [L2245](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2245) | +| `apiAddGroupShortLink` | `rh: Long?, groupId: Long` | Create a short link for the group | [L2252](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2252) | +| `apiCreateMemberContact` | `rh: Long?, groupId: Long, groupMemberId: Long` | Create a direct contact from a group member | [L2262](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2262) | +| `apiSendMemberContactInvitation` | `rh: Long?, contactId: Long, mc: MsgContent` | Send a direct message invitation to a group member | [L2271](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2271) | +| `apiAcceptMemberContact` | `rh: Long?, contactId: Long` | Accept a member's direct contact invitation | [L2280](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2280) | +| `apiSetMemberSettings` | `rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings` | Configure per-member settings (e.g., mentions) | [L1343](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1343) | +| `apiGroupMemberInfo` | `rh: Long?, groupId: Long, groupMemberId: Long` | Get a group member's info and connection stats | [L1353](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1353) | +| `apiSetGroupAlias` | `rh: Long?, groupId: Long, localAlias: String` | Set a local alias for a group | [L1718](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1718) | + +### 2.5 Contact Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiAddContact` | `rh: Long?, incognito: Boolean` | Create a one-time invitation link for a new contact | [L1444](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1444) | +| `apiSetConnectionIncognito` | `rh: Long?, connId: Long, incognito: Boolean` | Toggle incognito on a pending connection | [L1455](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1455) | +| `apiChangeConnectionUser` | `rh: Long?, connId: Long, userId: Long` | Change the user profile on a pending connection | [L1464](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1464) | +| `apiConnectPlan` | `rh: Long?, connLink: String, inProgress: MutableState` | Analyze a connection link before connecting | [L1474](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1474) | +| `apiConnect` | `rh: Long?, incognito: Boolean, connLink: CreatedConnLink` | Connect via an invitation or address link | [L1482](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1482) | +| `apiPrepareContact` | `rh, connLink, contactShortLinkData` | Prepare a contact chat from a short link (before connecting) | [L1546](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1546) | +| `apiPrepareGroup` | `rh, connLink, groupShortLinkData` | Prepare a group chat from a short link (before connecting) | [L1555](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1555) | +| `apiConnectPreparedContact` | `rh, contactId, incognito, msg` | Connect to a previously prepared contact | [L1580](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1580) | +| `apiConnectPreparedGroup` | `rh, groupId, incognito, msg` | Join a previously prepared group | [L1590](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1590) | +| `apiConnectContactViaAddress` | `rh: Long?, incognito: Boolean, contactId: Long` | Connect to a contact using their public address | [L1600](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1600) | +| `apiDeleteContact` | `rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode` | Delete a contact and return the deleted Contact | [L1644](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1644) | +| `apiContactInfo` | `rh: Long?, contactId: Long` | Get a contact's connection stats and custom profile | [L1346](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1346) | +| `apiSetContactAlias` | `rh: Long?, contactId: Long, localAlias: String` | Set a local display alias for a contact | [L1711](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1711) | +| `apiSetConnectionAlias` | `rh: Long?, connId: Long, localAlias: String` | Set a local display alias for a pending connection | [L1725](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1725) | +| `apiSetContactPrefs` | `rh: Long?, contactId: Long, prefs: ChatPreferences` | Update feature preferences for a contact | [L1704](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1704) | +| `apiCreateUserAddress` | `rh: Long?` | Create a long-term public contact address | [L1746](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1746) | +| `apiDeleteUserAddress` | `rh: Long?` | Delete the user's public contact address | [L1762](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1762) | +| `apiAddMyAddressShortLink` | `rh: Long?` | Create a short link for the user's address | [L1784](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1784) | +| `apiSetUserAddressSettings` | `rh: Long?, settings: AddressSettings` | Configure auto-accept for incoming contact requests | [L1795](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1795) | +| `apiAcceptContactRequest` | `rh: Long?, incognito: Boolean, contactReqId: Long` | Accept an incoming contact request | [L1809](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1809) | +| `apiRejectContactRequest` | `rh: Long?, contactReqId: Long` | Reject an incoming contact request | [L1832](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1832) | +| `apiSwitchContact` | `rh: Long?, contactId: Long` | Initiate SMP server switch for a contact | [L1374](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1374) | +| `apiAbortSwitchContact` | `rh: Long?, contactId: Long` | Abort an in-progress server switch | [L1388](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1388) | +| `apiSyncContactRatchet` | `rh: Long?, contactId: Long, force: Boolean` | Force ratchet synchronization with a contact | [L1402](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1402) | +| `apiGetContactCode` | `rh: Long?, contactId: Long` | Get the security verification code for a contact | [L1416](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1416) | +| `apiVerifyContact` | `rh: Long?, contactId: Long, connectionCode: String?` | Verify a contact's security code | [L1430](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1430) | + +### 2.6 File Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `receiveFiles` | `rhId, user, fileIds, userApprovedRelays, auto` | Accept and download one or more files (handles relay approval) | [L1946](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1946) | +| `receiveFile` | `rhId, user, fileId, userApprovedRelays, auto` | Accept and download a single file (convenience wrapper) | [L2062](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2062) | +| `cancelFile` | `rh: Long?, user: User, fileId: Long` | Cancel an in-progress file transfer and clean up | [L2072](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2072) | +| `apiCancelFile` | `rh: Long?, fileId: Long, ctrl: ChatCtrl?` | Cancel a file transfer (low-level, returns updated chat item) | [L2080](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2080) | +| `uploadStandaloneFile` | `user: UserLike, file: CryptoFile, ctrl: ChatCtrl?` | Upload a standalone file (used for migration) | [L1916](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1916) | +| `downloadStandaloneFile` | `user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl?` | Download a standalone file by URL | [L1926](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1926) | +| `standaloneFileInfo` | `url: String, ctrl: ChatCtrl?` | Retrieve metadata for a standalone file link | [L1936](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1936) | + +### 2.7 Call Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiGetCallInvitations` | `rh: Long?` | Retrieve pending call invitations | [L1842](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1842) | +| `apiSendCallInvitation` | `rh: Long?, contact: Contact, callType: CallType` | Initiate a call by sending an invitation | [L1849](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1849) | +| `apiRejectCall` | `rh: Long?, contact: Contact` | Reject an incoming call | [L1854](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | +| `apiSendCallOffer` | `rh, contact, rtcSession, rtcIceCandidates, media, capabilities` | Send a WebRTC call offer | [L1859](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | +| `apiSendCallAnswer` | `rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String` | Send a WebRTC call answer | [L1866](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | +| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcIceCandidates: String` | Send additional ICE candidates during a call | [L1872](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | +| `apiEndCall` | `rh: Long?, contact: Contact` | End an active call | [L1878](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | +| `apiCallStatus` | `rh: Long?, contact: Contact, status: WebRTCCallStatus` | Report call status updates to the core | [L1883](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | + +### 2.8 Settings & Network + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiSetNetworkConfig` | `cfg: NetCfg, showAlertOnError: Boolean, ctrl: ChatCtrl?` | Apply network configuration (SOCKS proxy, timeouts, etc.) | [L1313](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1313) | +| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Update network reachability information | [L1340](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1340) | +| `apiSetSettings` | `rh: Long?, type: ChatType, id: Long, settings: ChatSettings` | Update per-chat settings (notifications, favorites) | [L1333](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1333) | +| `apiStorageEncryption` | `currentKey: String, newKey: String` | Change the database encryption passphrase | [L999](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L999) | +| `testStorageEncryption` | `key: String, ctrl: ChatCtrl?` | Verify a database encryption key is correct | [L1006](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1006) | +| `testProtoServer` | `rh: Long?, server: String` | Test connectivity to a protocol server | [L1211](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1211) | +| `reconnectServer` | `rh: Long?, server: String` | Reconnect to a specific server | [L1326](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1326) | +| `reconnectAllServers` | `rh: Long?` | Reconnect to all servers | [L1331](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1331) | +| `apiSetChatUIThemes` | `rh: Long?, chatId: ChatId, themes: ThemeModeOverrides?` | Set per-chat UI theme overrides | [L1739](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1739) | +| `apiContactQueueInfo` | `rh: Long?, contactId: Long` | Get server queue diagnostics for a contact | [L1360](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1360) | +| `apiGroupMemberQueueInfo` | `rh: Long?, groupId: Long, groupMemberId: Long` | Get server queue diagnostics for a group member | [L1367](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1367) | + +### 2.9 Chat Tags + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiCreateChatTag` | `rh: Long?, tag: ChatTagData` | Create a new chat tag (folder/label) | [L1052](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1052) | +| `apiSetChatTags` | `rh: Long?, type: ChatType, id: Long, tagIds: List` | Assign tags to a chat | [L1060](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1060) | +| `apiDeleteChatTag` | `rh: Long?, tagId: Long` | Delete a chat tag | [L1068](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1068) | +| `apiUpdateChatTag` | `rh: Long?, tagId: Long, tag: ChatTagData` | Update a chat tag's name or emoji | [L1070](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1070) | +| `apiReorderChatTags` | `rh: Long?, tagIds: List` | Set the display order of chat tags | [L1072](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1072) | + +### 2.10 Server Operators + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `getServerOperators` | `rh: Long?` | Get server operator conditions detail | [L1219](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1219) | +| `setServerOperators` | `rh: Long?, operators: List` | Update the list of server operators | [L1226](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1226) | +| `getUserServers` | `rh: Long?` | Get the user's configured servers per operator | [L1233](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1233) | +| `setUserServers` | `rh: Long?, userServers: List` | Save user's configured servers per operator | [L1241](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1241) | +| `validateServers` | `rh: Long?, userServers: List` | Validate server configuration for errors | [L1253](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1253) | +| `getUsageConditions` | `rh: Long?` | Get current and accepted usage conditions | [L1261](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1261) | +| `setConditionsNotified` | `rh: Long?, conditionsId: Long` | Mark conditions as shown to user | [L1268](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1268) | +| `acceptConditions` | `rh: Long?, conditionsId: Long, operatorIds: List` | Accept usage conditions for operators | [L1275](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1275) | + +### 2.11 Archive + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiExportArchive` | `config: ArchiveConfig` | Export chat database to a ZIP archive | [L981](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L981) | +| `apiImportArchive` | `config: ArchiveConfig` | Import chat database from a ZIP archive | [L987](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L987) | +| `apiDeleteStorage` | _(none)_ | Delete all chat database storage | [L993](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L993) | + + + +`ArchiveConfig` ([SimpleXAPI.kt#L4162](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4162)): + +```kotlin +class ArchiveConfig( + val archivePath: String, + val disableCompression: Boolean? = null, + val parentTempDirectory: String? = null +) +``` + +--- + + + +## 3. Response Types + +All command responses are deserialized into the `API` sealed class ([SimpleXAPI.kt#L5975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L5975)): + +```kotlin +sealed class API { + class Result(val remoteHostId: Long?, val res: CR) : API() + class Error(val remoteHostId: Long?, val err: ChatError) : API() +} +``` + + + +The `CR` sealed class ([SimpleXAPI.kt#L6114](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6114)) contains approximately 180 response variants. Key categories: + +| Category | Examples | Lines | +|----------|---------|-------| +| User | `ActiveUser`, `UsersList`, `UserPrivacy`, `UserProfileUpdated` | L6104-L6157 | +| Chat state | `ChatStarted`, `ChatRunning`, `ChatStopped`, `ApiChats`, `ApiChat` | L6106-L6110 | +| Tags | `ChatTags`, `TagsUpdated` | L6112, L6137 | +| Contacts | `Invitation`, `SentConfirmation`, `SentInvitation`, `ContactConnected`, `ContactDeleted` | L6138-L6165 | +| Messages | `NewChatItems`, `ChatItemUpdated`, `ChatItemsDeleted`, `ChatItemReaction`, `ForwardPlan` | L6176-L6184 | +| Groups | `GroupCreated`, `SentGroupInvitation`, `UserAcceptedGroupSent`, `GroupUpdated`, `GroupMembers` | L6186-L6219 | +| Files (receive) | `RcvFileAccepted`, `RcvFileStart`, `RcvFileComplete`, `RcvFileCancelled`, `RcvFileError` | L6221-L6232 | +| Files (send) | `SndFileStart`, `SndFileComplete`, `SndFileCancelled`, `SndFileCompleteXFTP` | L6234-L6244 | +| Calls | `CallInvitation`, `CallOffer`, `CallAnswer`, `CallExtraInfo`, `CallEnded` | L6246-L6251 | +| Remote host | `RemoteHostList`, `RemoteHostStarted`, `RemoteHostConnected`, `RemoteHostStopped` | L6255-L6262 | +| Remote ctrl | `RemoteCtrlList`, `RemoteCtrlFound`, `RemoteCtrlConnected`, `RemoteCtrlStopped` | L6264-L6269 | +| Encryption | `ContactPQAllowed`, `ContactPQEnabled` | L6271-L6272 | +| Misc | `CmdOk`, `ArchiveExported`, `ArchiveImported`, `AppSettingsR`, `VersionInfo` | L6274-L6283 | +| Fallback | `Response` (unknown type + raw JSON), `Invalid` (unparseable) | L6282-L6283 | + +Each `CR` subclass is annotated with `@Serializable @SerialName("jsonTag")` for polymorphic JSON deserialization. + +--- + +## 4. Event Types + +The chat core pushes asynchronous events through the same `CR` type hierarchy. The `startReceiver` coroutine ([SimpleXAPI.kt#L660](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660)) continuously calls `chatRecvMsgWait` (blocking JNI), then dispatches each message to `processReceivedMsg` ([SimpleXAPI.kt#L2568](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568)). + +Events handled in `processReceivedMsg` include: + +| Event | Description | +|-------|-------------| +| `ContactConnected` | A contact has completed the connection handshake | +| `ContactConnecting` | A contact connection is in progress | +| `ContactSndReady` | Contact's sending channel is ready | +| `ContactDeletedByContact` | A contact deleted their side of the conversation | +| `ReceivedContactRequest` | An incoming contact request arrived | +| `NewChatItems` | New messages received | +| `ChatItemUpdated` | A message was edited | +| `ChatItemsDeleted` | Messages were deleted | +| `ChatItemReaction` | A reaction was added/removed | +| `ChatItemsStatusesUpdated` | Delivery statuses updated | +| `GroupCreated` | A new group was created | +| `ReceivedGroupInvitation` | An invitation to join a group | +| `JoinedGroupMember` | A new member joined | +| `DeletedMember` / `DeletedMemberUser` | A member was removed | +| `LeftMember` | A member left voluntarily | +| `GroupUpdated` | Group profile changed | +| `GroupRelayUpdated` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = RsRejected` and `GroupMember.memberStatus = MemLeft` — final on owner side until cleared by the relay operator's `/group allow #` (no event emitted to the owner for that clear). | +| `MemberRole` | A member's role changed | +| `MemberBlockedForAll` | A member was blocked for all | +| `RcvFileStart` / `RcvFileComplete` / `RcvFileError` | File receive progress | +| `SndFileStart` / `SndFileComplete` / `SndFileError` | File send progress | +| `CallInvitation` / `CallOffer` / `CallAnswer` / `CallEnded` | Call signaling events | +| `ContactPQEnabled` | Post-quantum encryption status changed | +| `RemoteHostStopped` / `RemoteCtrlStopped` | Remote access session ended | +| `SubscriptionStatusEvt` | Connection subscription status changed | + +Each event triggers updates to `ChatModel` (reactive Compose state) and optionally fires platform notifications via `ntfManager`. + +--- + + + +## 5. Error Types + +### ChatError ([SimpleXAPI.kt#L6974](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6974)) + +```kotlin +sealed class ChatError { + class ChatErrorChat(val errorType: ChatErrorType) // Application-level errors + class ChatErrorAgent(val agentError: AgentErrorType) // SMP/XFTP agent errors + class ChatErrorStore(val storeError: StoreError) // Database store errors + class ChatErrorDatabase(val databaseError: DatabaseError)// Database engine errors + class ChatErrorRemoteHost(val remoteHostError: ...) // Remote host errors + class ChatErrorRemoteCtrl(val remoteCtrlError: ...) // Remote controller errors + class ChatErrorInvalidJSON(val json: String) // JSON parsing failure +} +``` + +### ChatErrorType ([SimpleXAPI.kt#L7004](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7004)) + +Common application error codes (~70 variants): + +| Error | Meaning | +|-------|---------| +| `NoActiveUser` | No user profile is set as active | +| `UserExists` | Attempted to create a duplicate user | +| `InvalidDisplayName` | Display name contains invalid characters | +| `ChatNotStarted` / `ChatNotStopped` | Chat engine in wrong state | +| `InvalidConnReq` / `UnsupportedConnReq` | Bad or incompatible connection link | +| `ContactNotReady` / `ContactDisabled` | Contact in unusable state | +| `GroupUserRole` | Insufficient group permissions | +| `GroupNotJoined` | User has not joined the group | +| `FileNotFound` / `FileCancelled` / `FileAlreadyReceiving` | File transfer errors | +| `FileNotApproved` | File from unapproved relay server | +| `HasCurrentCall` / `NoCurrentCall` | Call state conflicts | +| `CommandError` / `InternalError` / `CEException` | Generic/internal errors | + +### StoreError ([SimpleXAPI.kt#L7168](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7168)) + +Database-level errors: `DuplicateName`, `UserNotFound`, `GroupNotFound`, `ChatItemNotFound`, `LargeMsg`, `UserContactLinkNotFound`, etc. + +### ArchiveError ([SimpleXAPI.kt#L7658](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7658)) + +```kotlin +sealed class ArchiveError { + class ArchiveErrorImport(val importError: String) + class ArchiveErrorFile(val file: String, val fileError: String) +} +``` + +--- + +## 6. Source Files + +| File | Purpose | Path | +|------|---------|------| +| SimpleXAPI.kt | API bridge: all `api*` functions, `CC`, `CR`, `ChatError` | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Core.kt | JNI externals, `initChatController`, `chatMigrateInit` | `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` | +| ChatModel.kt | Reactive UI state (`ChatModel` object) | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| DatabaseUtils.kt | `DBMigrationResult`, `MigrationError`, DB password helpers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | +| Files.kt | Platform-expect file path declarations | `common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt` | +| Files.android.kt | Android actual file paths | `common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt` | +| Files.desktop.kt | Desktop actual file paths | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt` | +| Cryptor.kt | Platform-expect encryption interface | `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` | +| Cryptor.android.kt | Android: AndroidKeyStore AES-GCM encryption | `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` | +| Cryptor.desktop.kt | Desktop: placeholder (no-op) encryption | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` | + +All paths are relative to `apps/multiplatform/`. diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md new file mode 100644 index 0000000000..cfef4d06c2 --- /dev/null +++ b/apps/multiplatform/spec/architecture.md @@ -0,0 +1,423 @@ +# System Architecture + +## Table of Contents + +1. [Overview](#1-overview) +2. [Module Structure](#2-module-structure) +3. [JNI Bridge](#3-jni-bridge) +4. [App Lifecycle](#4-app-lifecycle) +5. [Event Streaming](#5-event-streaming) +6. [Platform Abstraction](#6-platform-abstraction) +7. [Source Files](#7-source-files) + +--- + +## 1. Overview + +The application is a three-layer system: + +``` ++------------------------------------------------------------------+ +| Compose UI (Views) | +| ChatListView, ChatView, ComposeView, SettingsView, CallView | ++------------------------------------------------------------------+ + | ^ + | user actions | Compose MutableState recomposition + v | ++------------------------------------------------------------------+ +| Application Logic Layer | +| ChatModel (state) ChatController (command dispatch) | +| AppPreferences NtfManager ThemeManager | ++------------------------------------------------------------------+ + | ^ + | sendCmd() | recvMsg() / processReceivedMsg() + v | ++------------------------------------------------------------------+ +| JNI Bridge (Core.kt) | +| external fun chatSendCmdRetry() external fun chatRecvMsgWait()| ++------------------------------------------------------------------+ + | ^ + | C FFI | C FFI + v | ++------------------------------------------------------------------+ +| Haskell Core (libsimplex / libapp-lib) | +| chat_ctrl handle SMP/XFTP protocols SQLite/PostgreSQL | ++------------------------------------------------------------------+ +``` + +**Data flow summary:** +1. User interacts with Compose UI. +2. View calls a `suspend fun api*()` method on `ChatController`. +3. `ChatController.sendCmd()` serializes the command to a JSON string and calls `chatSendCmdRetry()` (JNI). +4. The Haskell core processes the command and returns a JSON response string. +5. The response is deserialized to an `API` sealed class and returned to the caller. +6. Asynchronous events from the core (incoming messages, connection updates, call invitations) are delivered via a receiver coroutine that calls `chatRecvMsgWait()` in a loop and dispatches each event through `processReceivedMsg()`. + +--- + +## 2. Module Structure + +### Gradle Configuration + +Root: [`settings.gradle.kts`](../settings.gradle.kts#L22) +``` +include(":android", ":desktop", ":common") +``` + +### `:common` Module + +Build file: [`common/build.gradle.kts`](../common/build.gradle.kts#L14) + +``` +kotlin { + androidTarget() + jvm("desktop") +} +``` + +Source sets: + +| Source Set | Path | Purpose | +|---|---|---| +| `commonMain` | `common/src/commonMain/kotlin/` | All shared UI, models, platform abstractions | +| `androidMain` | `common/src/androidMain/kotlin/` | Android `actual` implementations | +| `desktopMain` | `common/src/desktopMain/kotlin/` | Desktop `actual` implementations | + +Key dependencies (from `commonMain`): +- `kotlinx-serialization-json` -- JSON codec for Haskell core communication +- `kotlinx-datetime` -- cross-platform date/time +- `multiplatform-settings` (russhwolf) -- `SharedPreferences` abstraction +- `kaml` -- YAML parsing (theme import/export) +- `boofcv-core` -- QR code scanning +- `jsoup` -- HTML parsing for link previews +- `moko-resources` -- cross-platform string/image resources +- `multiplatform-markdown-renderer` -- Markdown rendering in chat + +### `:android` Module + +Build file: [`android/build.gradle.kts`](../android/build.gradle.kts) + +Contains: +- `SimplexApp` (Application subclass) +- `MainActivity` (FragmentActivity) +- `SimplexService` (foreground Service) +- `NtfManager` (Android NotificationManager wrapper) +- `CallActivity` (dedicated activity for calls) + +### `:desktop` Module + +Build file: [`desktop/build.gradle.kts`](../desktop/build.gradle.kts) + +Contains: +- `main()` entry point +- `initHaskell()` -- loads native library and calls `initHS()` +- Window management (VLC library loading on Windows) + +--- + +## 3. JNI Bridge + +All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt). + + + + +### External Native Functions + +| # | Function | Signature | Line | Purpose | +|---|---|---|---|---| +| 1 | [`initHS()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | Initialize GHC runtime system | +| 2 | [`pipeStdOutToSocket()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L20) | `external fun pipeStdOutToSocket(socketName: String): Int` | 20 | Redirect Haskell stdout to Android local socket for logging | +| 3 | [`chatMigrateInit()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25) | `external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array` | 25 | Initialize database with migration; returns `[jsonResult, chatCtrl]` | +| 4 | [`chatCloseStore()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L26) | `external fun chatCloseStore(ctrl: ChatCtrl): String` | 26 | Close database store | +| 5 | [`chatSendCmdRetry()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L27) | `external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String` | 27 | Send command to core with retry count | +| 6 | [`chatSendRemoteCmdRetry()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L28) | `external fun chatSendRemoteCmdRetry(ctrl: ChatCtrl, rhId: Int, msg: String, retryNum: Int): String` | 28 | Send command to remote host | +| 7 | [`chatRecvMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L29) | `external fun chatRecvMsg(ctrl: ChatCtrl): String` | 29 | Receive message (non-blocking) | +| 8 | [`chatRecvMsgWait()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L30) | `external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String` | 30 | Receive message with timeout (blocking up to `timeout` microseconds) | +| 9 | [`chatParseMarkdown()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L31) | `external fun chatParseMarkdown(str: String): String` | 31 | Parse markdown formatting | +| 10 | [`chatParseServer()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L32) | `external fun chatParseServer(str: String): String` | 32 | Parse SMP/XFTP server address | +| 11 | [`chatParseUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L33) | `external fun chatParseUri(str: String, safe: Int): String` | 33 | Parse SimpleX connection URI | +| 12 | [`chatPasswordHash()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L34) | `external fun chatPasswordHash(pwd: String, salt: String): String` | 34 | Hash password with salt | +| 13 | [`chatValidName()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L35) | `external fun chatValidName(name: String): String` | 35 | Validate/sanitize display name | +| 14 | [`chatJsonLength()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L36) | `external fun chatJsonLength(str: String): Int` | 36 | Get JSON-encoded string length | +| 15 | [`chatWriteFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L37) | `external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String` | 37 | Write encrypted file via core | +| 16 | [`chatReadFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L38) | `external fun chatReadFile(path: String, key: String, nonce: String): Array` | 38 | Read and decrypt file | +| 17 | [`chatEncryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39) | `external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String` | 39 | Encrypt file on disk | +| 18 | [`chatDecryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L40) | `external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String` | 40 | Decrypt file on disk | + +**Total: 18 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer). + + + + + +### Key Kotlin Functions in Core.kt + +| Function | Line | Purpose | +|---|---|---| +| [`initChatControllerOnStart()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L51) | 51 | Entry point called during app startup; launches `initChatController` in a long-running coroutine | +| [`initChatController()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62) | 62 | Main initialization: DB migration via `chatMigrateInit`, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat | +| [`chatInitTemporaryDatabase()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L190) | 190 | Creates a temporary database for migration scenarios | +| [`chatInitControllerRemovingDatabases()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L202) | 202 | Removes existing DBs and creates fresh controller (used during re-initialization) | +| [`showStartChatAfterRestartAlert()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L222) | 222 | Shows confirmation dialog when chat was stopped and DB passphrase is stored | + + + +### initChatController Flow + +``` +initChatController(useKey, confirmMigrations, startChat) + | + +-- chatMigrateInit(dbPath, dbKey, confirm) // JNI -> Haskell + | returns [jsonResult, chatCtrl] + | + +-- if migration error and rerunnable: + | chatMigrateInit(dbPath, dbKey, confirm) // retry with user confirmation + | + +-- setChatCtrl(ctrl) // store controller handle + +-- apiSetAppFilePaths(...) // tell core about file dirs + +-- apiSetEncryptLocalFiles(...) + +-- apiGetActiveUser() -> currentUser + +-- getServerOperators() -> conditions + +-- if shouldImportAppSettings: apiGetAppSettings + importIntoApp + +-- if user exists and startChat confirmed: + | startChat(user) // starts receiver, API commands + +-- else if no user: + set onboarding stage, optionally startChatWithoutUser() +``` + +--- + +## 4. App Lifecycle + +### Android + +Entry: [`SimplexApp.onCreate()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L47) + +``` +SimplexApp.onCreate() + +-- initHaskell(packageName) // Load native lib, pipe stdout, call initHS() + | +-- System.loadLibrary("app-lib") + | +-- pipeStdOutToSocket(packageName) + | +-- initHS() + +-- initMultiplatform() // Set up ntfManager, platform callbacks + +-- reconfigureBroadcastReceivers() + +-- runMigrations() // Theme migration, version code tracking + +-- initChatControllerOnStart() // -> initChatController() -> chatMigrateInit -> startChat +``` + +Activity: [`MainActivity.onCreate()`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L32) + +``` +MainActivity.onCreate() + +-- processNotificationIntent(intent) // Handle OpenChat/AcceptCall from notifications + +-- processIntent(intent) // Handle VIEW intents (deep links) + +-- processExternalIntent(intent) // Handle SEND/SEND_MULTIPLE (share sheet) + +-- setContent { AppScreen() } // Compose UI entry point +``` + +Lifecycle callbacks in `SimplexApp` (implements `LifecycleEventObserver`): +- `ON_START`: refresh chat list from API if chat is running +- `ON_RESUME`: show background service notice, start `SimplexService` if configured + +### Desktop + +Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) + +``` +main() + +-- initHaskell() // Load native lib from resources dir, call initHS() + | +-- System.load(libapp-lib.so/dll/dylib) + | +-- initHS() + +-- runMigrations() + +-- setupUpdateChecker() + +-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart + +-- showApp() // Compose window with AppScreen() +``` + +[`showApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) creates a Compose `Window` with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window. + +[`initApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) sets the `ntfManager` implementation (desktop notifications via `NtfManager` in `common/model/`) and calls `initChatControllerOnStart()`. + +--- + +## 5. Event Streaming + +### Receiver Coroutine + +[`ChatController.startReceiver()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660) launches a coroutine on `Dispatchers.IO` that continuously polls for events from the Haskell core: + +```kotlin +// SimpleXAPI.kt line 660 +private fun startReceiver() { + if (receiverJob != null || chatCtrl == null) return // guard against double-start + receiverJob = CoroutineScope(Dispatchers.IO).launch { + var releaseLock: (() -> Unit) = {} + while (isActive) { + val ctrl = chatCtrl + if (ctrl == null) { stopReceiver(); break } // chatCtrl became null + try { + val release = releaseLock + launch { delay(30000); release() } // release previous wake lock after 30s + val msg = recvMsg(ctrl) // calls chatRecvMsgWait with 300s timeout + releaseLock = getWakeLock(timeout = 60000) // acquire wake lock (60s timeout) + if (msg != null) { + val finished = withTimeoutOrNull(60_000L) { + processReceivedMsg(msg) + messagesChannel.trySend(msg) + } + if (finished == null) { + Log.e(TAG, "Timeout processing: " + msg.responseType) + } + } + } catch (e: Exception) { + Log.e(TAG, "recvMsg/processReceivedMsg exception: " + e.stackTraceToString()) + } catch (e: Throwable) { + Log.e(TAG, "recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + } + } + } +} +``` + +### Message Reception + +[`recvMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L829) calls `chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)` where `MESSAGE_TIMEOUT = 300_000_000` microseconds (300 seconds). Returns `null` on timeout (empty string from Haskell), otherwise deserializes the JSON response to an `API` instance. + +### Command Sending + +[`sendCmd()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L804) runs on `Dispatchers.IO`, serializes the command via `CC.cmdString`, calls `chatSendCmdRetry()` (or `chatSendRemoteCmdRetry()` for remote hosts), deserializes the response, and logs terminal items. + +### Event Processing + +[`processReceivedMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568) is a large `when` block that dispatches on the `CR` (ChatResponse) type: + +- `CR.ContactConnected` -- update contact in `ChatModel` +- `CR.NewChatItems` -- add items to chat, trigger notifications +- `CR.RcvCallInvitation` -- add to `callInvitations`, trigger call UI +- `CR.ChatStopped` -- set `chatRunning = false` +- `CR.GroupMemberConnected`, `CR.GroupMemberUpdated`, etc. -- update group state +- Many more event types for connection status, file transfers, SMP relay events, etc. + +### Wake Lock + +On Android, the receiver acquires a wake lock via [`getWakeLock(timeout)`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) (expect function) after each received message with a 60-second timeout. The previous iteration's wake lock is released after a 30-second delay, ensuring overlap so the CPU does not sleep between messages. + +--- + +## 6. Platform Abstraction + +### expect/actual Pattern + +The `commonMain` source set declares `expect` functions and classes. Each platform source set provides `actual` implementations. + +Examples from platform files: + +| expect Declaration | File | Line | +|---|---|---| +| `expect val appPlatform: AppPlatform` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L20) | 20 | +| `expect val deviceName: String` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L22) | 22 | +| `expect fun isAppVisibleAndFocused(): Boolean` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L24) | 24 | +| `expect val dataDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L18) | 18 | +| `expect val tmpDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L19) | 19 | +| `expect val filesDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L20) | 20 | +| `expect val appFilesDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L21) | 21 | +| `expect val dbAbsolutePrefixPath: String` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L24) | 24 | +| `expect fun showToast(text: String, timeout: Long)` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L6) | 6 | +| `expect fun hideKeyboard(view: Any?, clearFocus: Boolean)` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L16) | 16 | +| `expect fun getKeyboardState(): State` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L15) | 15 | +| `expect fun allowedToShowNotification(): Boolean` | [`Notifications.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt#L3) | 3 | +| `expect class VideoPlayer` | [`VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt#L25) | 25 | +| `expect class RecorderNative` | [`RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt#L17) | 17 | +| `expect val cryptor: CryptorInterface` | [`Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt#L9) | 9 | +| `expect fun base64ToBitmap(base64ImageString: String): ImageBitmap` | [`Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt#L17) | 17 | +| `expect fun getWakeLock(timeout: Long): (() -> Unit)` | [`SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) | 3 | +| `expect class GlobalExceptionsHandler` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L24) | 24 | +| `expect fun UriHandler.sendEmail(subject: String, body: CharSequence)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L7) | 7 | +| `expect fun ClipboardManager.shareText(text: String)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L9) | 9 | +| `expect fun shareFile(text: String, fileSource: CryptoFile)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L10) | 10 | + +### PlatformInterface Callback Object + +[`PlatformInterface`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) is an interface with default no-op implementations. It is assigned at runtime by each platform entry point: + +- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L187) (line 187) +- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L50) (line 50) + +The global variable is declared at [`Platform.kt line 50`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L50): +```kotlin +var platform: PlatformInterface = object : PlatformInterface {} +``` + +#### PlatformInterface Callbacks + +| Callback | Default | Android Implementation | +|---|---|---| +| `androidServiceStart()` | no-op | Start `SimplexService` foreground service | +| `androidServiceSafeStop()` | no-op | Stop `SimplexService` | +| `androidCallServiceSafeStop()` | no-op | Stop `CallService` | +| `androidNotificationsModeChanged(mode)` | no-op | Toggle receivers, start/stop service | +| `androidChatStartedAfterBeingOff()` | no-op | Start service or schedule periodic worker | +| `androidChatStopped()` | no-op | Cancel workers, stop service | +| `androidChatInitializedAndStarted()` | no-op | Show background service notice, start service | +| `androidIsBackgroundCallAllowed()` | `true` | Check battery restriction | +| `androidSetNightModeIfSupported()` | no-op | Set `UiModeManager` night mode | +| `androidSetStatusAndNavigationBarAppearance(...)` | no-op | Configure system bar colors/appearance | +| `androidStartCallActivity(acceptCall, rhId, chatId)` | no-op | Launch `CallActivity` | +| `androidPictureInPictureAllowed()` | `true` | Check PiP permission via AppOps | +| `androidCallEnded()` | no-op | Destroy call WebView | +| `androidRestartNetworkObserver()` | no-op | Restart `NetworkObserver` | +| `androidCreateActiveCallState()` | empty `Closeable` | Create `ActiveCallState` | +| `androidIsXiaomiDevice()` | `false` | Check device brand | +| `androidApiLevel` | `null` | `Build.VERSION.SDK_INT` | +| `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` | +| `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog | +| `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) | + +--- + +## 7. Source Files + +### Core Infrastructure + +| File | Path | Key Contents | +|---|---|---| +| Core.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt) | JNI externals, `initChatController`, `chatInitTemporaryDatabase` | +| SimpleXAPI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | `ChatController`, `AppPreferences`, `startReceiver`, `sendCmd`, `recvMsg`, `processReceivedMsg`, all `api*` functions | +| ChatModel.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | `ChatModel` singleton, `ChatsContext`, `Chat`, `ChatInfo`, `ChatItem` and all domain types | +| App.kt | [`common/src/commonMain/kotlin/chat/simplex/common/App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt) | `AppScreen()`, `MainScreen()` | + +### Platform Layer + +| File | Path | Key Contents | +|---|---|---| +| Platform.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt) | `PlatformInterface`, global `platform` var | +| AppCommon.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt) | `AppPlatform`, `runMigrations()` | +| AppCommon.android.kt | [`common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt) | `initHaskell()`, `androidAppContext` | +| AppCommon.desktop.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt) | `initApp()`, desktop NtfManager setup | +| Files.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | `expect val dataDir/tmpDir/filesDir/dbAbsolutePrefixPath` | +| NtfManager.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) | `abstract class NtfManager` | +| Notifications.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt) | `expect fun allowedToShowNotification()` | +| UI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt) | `showToast`, `hideKeyboard`, `GlobalExceptionsHandler` | +| Share.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt) | `shareText`, `shareFile`, `openFile` | +| VideoPlayer.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt) | `VideoPlayerInterface`, `expect class VideoPlayer` | +| RecAndPlay.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt) | `RecorderInterface`, `AudioPlayerInterface` | +| Cryptor.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt) | `CryptorInterface` | +| Images.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt) | `base64ToBitmap`, `resizeImageToStrSize` | +| SimplexService.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt) | `expect fun getWakeLock()` | + +### Entry Points + +| File | Path | Key Contents | +|---|---|---| +| SimplexApp.kt | [`android/src/main/java/chat/simplex/app/SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt) | Android Application class, lifecycle observer | +| MainActivity.kt | [`android/src/main/java/chat/simplex/app/MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt) | Android main activity | +| SimplexService.kt | [`android/src/main/java/chat/simplex/app/SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt) | Android foreground service | +| Main.kt | [`desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt) | Desktop `main()` | +| DesktopApp.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt) | `showApp()`, `SimplexWindowState` | + +### Theme + +| File | Path | Key Contents | +|---|---|---| +| ThemeManager.kt | [`common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) | Theme resolution, system/light/dark/custom, per-user overrides | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md new file mode 100644 index 0000000000..b0f3750659 --- /dev/null +++ b/apps/multiplatform/spec/client/chat-list.md @@ -0,0 +1,314 @@ +# Chat List Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatListView Composable](#2-chatlistview-composable) +3. [Data Sources](#3-data-sources) +4. [Filter System](#4-filter-system) +5. [Chat Preview](#5-chat-preview) +6. [ChatListNavLinkView](#6-chatlistnavlinkview) +7. [Tag System](#7-tag-system) +8. [UserPicker](#8-userpicker) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 126 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection. + +--- + +## 1. Overview + +``` +ChatListView +|-- ChatListToolbar (top or bottom app bar) +| |-- UserProfileButton (opens UserPicker) +| |-- Title ("Your chats") +| |-- SubscriptionStatusIndicator +| +-- NewChatButton / StoppedIndicator +|-- ChatListWithLoadingScreen +| |-- ChatList (LazyColumnWithScrollBar) +| | |-- Spacer (top/bottom padding) +| | |-- stickyHeader +| | | |-- ChatListSearchBar (search input + filter toggle) +| | | +-- TagsView (preset + custom tag chips) +| | |-- ChatListNavLinkView[] (per-chat row items) +| | +-- ChatListFeatureCards (one-hand UI card, address card) +| +-- EmptyState text +|-- NewChatSheetFloatingButton (FAB, standard mode only) +|-- UserPicker (slide-in panel, Android) ++-- ActiveCallInteractiveArea (desktop, in-call banner) +``` + +--- + + + +## 2. ChatListView Composable + +**Location:** [`ChatListView.kt#L127`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L127) + +```kotlin +fun ChatListView( + chatModel: ChatModel, + userPickerState: MutableStateFlow, + setPerformLA: (Boolean) -> Unit, + stopped: Boolean +) +``` + +### Initialization + +- Shows "What's New" modal on first launch after update (line ~130), with a 1-second delay. +- On desktop, closing a chat resets audio/video players (line ~138). + +### Layout Modes + +The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout: + +| Mode | Toolbar Position | List Direction | FAB | Search/Tags Position | +|---|---|---|---|---| +| **Standard** (`oneHandUI = false`) | Top | Top-to-bottom | Bottom-right FAB | Below toolbar | +| **One-hand** (`oneHandUI = true`) | Bottom | Bottom-to-top (reversed) | Integrated in toolbar | Above toolbar | + +### State + +| State | Type | Purpose | +|---|---|---| +| `searchText` | `MutableState` | Search query (saved across recomposition) | +| `listState` | `LazyListState` | Scroll position (persisted in `lazyListState` var) | +| `oneHandUI` | `State` | One-hand UI mode toggle | + +### Android-specific + +- `SetNotificationsModeAdditions`: Notification permission setup (line ~184). +- `UserPicker`: Overlay side panel for user switching (line ~192). + +--- + +## 3. Data Sources + +| Source | Location | Description | +|---|---|---| +| `chatModel.chats` | `ChatModel.chatsContext.chats` | Full list of `Chat` objects for the active user | +| `chatModel.activeChatTagFilter` | `ChatModel.activeChatTagFilter` | Currently active filter (`PresetTag`, `UserTag`, or `Unread`) | +| `chatModel.userTags` | `ChatModel.userTags` | User-created custom tags | +| `chatModel.presetTags` | `ChatModel.presetTags` | Map of `PresetTagKind` to count | +| `chatModel.unreadTags` | `ChatModel.unreadTags` | Map of tag ID to unread count | +| `chatModel.chatId` | `ChatModel.chatId` | Currently selected chat ID (highlights row) | +| `chatModel.currentUser` | `ChatModel.currentUser` | Active user profile | +| `chatModel.users` | `ChatModel.users` | All user profiles (for UserPicker) | +| `chatModel.showChatPreviews` | `ChatModel.showChatPreviews` | Privacy toggle for message previews | + +--- + +## 4. Filter System + +### Active Filter Types + +Defined as sealed class `ActiveFilter` (line ~51): + +```kotlin +sealed class ActiveFilter { + data class PresetTag(val tag: PresetTagKind) : ActiveFilter() + data class UserTag(val tag: ChatTag) : ActiveFilter() + data object Unread : ActiveFilter() +} +``` + +### PresetTagKind Enum + +| Value | Description | +|---|---| +| `GROUP_REPORTS` | Groups with active reports (moderator-visible) | +| `FAVORITES` | Chats marked as favorite | +| `CONTACTS` | Direct (1:1) chats | +| `GROUPS` | Group chats | +| `BUSINESS` | Business-type chats | +| `NOTES` | Local note folders | + +### Search Filtering + +The `filteredChats` function (line ~1188) applies filters in this order: + +1. **SimpleX link match:** If a pasted link resolved to a known contact/group, show only that chat. +2. **Text search:** Case-insensitive match against `chat.chatInfo.chatViewName`, `chat.chatInfo.fullName`, and `chat.chatInfo.localAlias`. +3. **Active filter:** + - `PresetTag`: Matches chat type and characteristics (e.g., `CONTACTS` filters `ChatInfo.Direct`, `GROUPS` filters `ChatInfo.Group`). + - `UserTag`: Matches chats whose `chatTags` contain the tag ID. + - `Unread`: Matches chats with `unreadCount > 0` or `unreadChat == true`. + +### Search Bar + +`ChatListSearchBar` (line ~611) provides: +- Text input with search icon. +- SimpleX link detection: When a pasted string contains a single SimpleX link, it triggers `planAndConnect` for connection, suppressing normal search. +- Unread filter toggle button (right side, when search is empty). + +--- + + + +## 5. Chat Preview + +**Location:** [`ChatPreviewView.kt#L40`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L40) + +```kotlin +fun ChatPreviewView( + chat: Chat, + showChatPreviews: Boolean, + chatModelDraft: ComposeState?, + chatModelDraftChatId: ChatId?, + currentUserProfileDisplayName: String?, + disabled: Boolean, + linkMode: SimplexLinkMode, + inProgress: Boolean, + progressByTimeout: Boolean, + defaultClickAction: () -> Unit +) +``` + +### Layout + +Each chat preview row contains: + +| Element | Position | Content | +|---|---|---| +| Profile image | Left | `ChatInfoImage` with overlay icons for inactive contacts/groups | +| Title row | Top-right of image | Chat name (bold), verified shield (direct), timestamp | +| Preview row | Below title | Last message preview or draft indicator, unread badge | +| Unread badge | Right | Circular badge with count, or dot for muted chats | + +### Draft Display + +When `chatModelDraftChatId` matches the chat ID, the preview shows a draft indicator (pencil icon) with the draft message text instead of the last chat item. + +### Inactive Indicators + +- Inactive contacts: cancel icon overlay on profile image. +- Left/removed/deleted groups: cancel icon overlay. + +--- + + + +## 6. ChatListNavLinkView + +**Location:** [`ChatListNavLinkView.kt#L37`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt#L37) + +Routes each chat to the appropriate click action and context menu based on `chat.chatInfo`: + +| ChatInfo Type | Click Action | Context Menu | +|---|---|---| +| `ChatInfo.Direct` | `directChatAction` (opens chat) | `ContactMenuItems`: mark read/unread, mute, favorite, tag, clear, delete | +| `ChatInfo.Group` | `groupChatAction` (opens chat or joins) | `GroupMenuItems`: mark read/unread, mute, favorite, tag, clear, leave, delete | +| `ChatInfo.Local` | `noteFolderChatAction` (opens notes) | `NoteFolderMenuItems`: mark read, clear, delete | +| `ChatInfo.ContactRequest` | `contactRequestAlertDialog` (accept/reject) | `ContactRequestMenuItems`: reject | +| `ChatInfo.ContactConnection` | Sets `chatModel.chatId` (opens connection info) | `ContactConnectionMenuItems`: delete | +| `ChatInfo.InvalidJSON` | Sets `chatModel.chatId` | No menu | + +### Selection Highlight + +On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) receives a highlight background. `nextChatSelected` state is used to suppress the bottom divider when the next chat in the list is selected. + +--- + +## 7. Tag System + +### TagsView + +**Location:** [`ChatListView.kt#L929`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L929) + +Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable). + +Layout logic: +- If there are more than 1 collapsible preset tags and the total tag count exceeds 3, preset tags collapse into a `CollapsedTagsFilterView` dropdown. +- Otherwise, each preset tag renders as an `ExpandedTagFilterView` chip. +- User tags render as individual chips with emoji or label icon, bold when active. +- A "+" button at the end opens `TagListEditor` for creating new tags. + +### Tag Interactions + +- **Single tap:** Toggles the tag filter on `chatModel.activeChatTagFilter`. +- **Long press / right-click (user tags):** Opens dropdown menu with edit/delete/reorder options. +- **Unread dot:** Shown on tags that have chats with unread messages. + + + +### TagListView + +**Location:** [`TagListView.kt#L48`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L48) + +Full-screen tag management view opened from the "+" button or long-press menu. + +```kotlin +fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) +``` + +- Displays all user tags in a `LazyColumnWithScrollBar`. +- Supports drag-and-drop reordering via `rememberDragDropState` (calls `apiReorderChatTags`). +- Each tag row shows emoji/icon, name, chat count, and a checkbox if opened for a specific chat (to assign/unassign tags). +- "Create list" button opens `TagListEditor` modal. + +--- + + + +## 8. UserPicker + +**Location:** [`UserPicker.kt#L46`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt#L46) + +```kotlin +fun UserPicker( + chatModel: ChatModel, + userPickerState: MutableStateFlow, + setPerformLA: (Boolean) -> Unit +) +``` + +### Behavior + +- **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. +- **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. +- Closes automatically when any `ModalManager.start` modal opens. + +### Content + +| Section | Content | +|---|---| +| **Active user** | Profile image, display name, "active" indicator | +| **Other users** | List of non-hidden user profiles sorted by `activeOrder`; tapping switches user | +| **Remote hosts** | Connected remote devices (desktop linking) | +| **Settings** | Opens `SettingsView` modal | +| **Color mode** | `ColorModeSwitcher` for theme toggle | +| **Add profile** | Opens `CreateProfile` flow | +| **Lock** | Locks app (calls `AppLock.setPerformLA`) | + +### State Machine + +Uses `AnimatedViewState` (`GONE`, `VISIBLE`, `HIDING`) with a `MutableStateFlow` to coordinate animation between the parent screen and the picker overlay. + +--- + +## 9. Source Files + +| File | Description | +|---|---| +| `ChatListView.kt` | Main chat list view, toolbar, search, tags, filtering | +| `ChatListNavLinkView.kt` | Per-chat row routing and context menus | +| `ChatPreviewView.kt` | Chat preview row layout (image, title, last message) | +| `ChatHelpView.kt` | Empty-state help content | +| `ContactConnectionView.kt` | Pending connection preview row | +| `ContactRequestView.kt` | Contact request preview row | +| `ServersSummaryView.kt` | Server connection status summary | +| `ShareListNavLinkView.kt` | Share target list row (forwarding) | +| `ShareListView.kt` | Share target list (forwarding flow) | +| `TagListView.kt` | Tag management and assignment view | +| `UserPicker.kt` | User switching side panel | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md new file mode 100644 index 0000000000..728ace4936 --- /dev/null +++ b/apps/multiplatform/spec/client/chat-view.md @@ -0,0 +1,332 @@ +# Chat View Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatView Composable](#2-chatview-composable) +3. [Message List](#3-message-list) +4. [ChatItemView](#4-chatitemview) +5. [Message Types](#5-message-types) +6. [Context Menu Actions](#6-context-menu-actions) +7. [ChatInfoView](#7-chatinfoview) +8. [GroupChatInfoView](#8-groupchatinfoview) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +The Chat View is the primary message display and interaction surface in SimpleX Chat. It is built around the `ChatView` composable (line ~96 in `ChatView.kt`), which orchestrates a `ChatLayout` containing a reverse-scrolling `LazyColumn` of `ChatItemView` items and a `ComposeView` for message input. The view supports direct chats, group chats, local notes, and contact connections, with per-chat theming, search/filter, multi-select, and side-panel info modals. Message rendering is delegated to type-specific composables in the `views/chat/item/` package. + +--- + +## 1. Overview + +``` +ChatView +|-- ChatLayout +| |-- ChatInfoToolbar (top/bottom app bar with back, title, call, search, menu) +| |-- SupportChatsCountToolbar (reports/support banner, group only) +| |-- ChatItemsList (LazyColumnWithScrollBar, reverse layout) +| | |-- ChatViewListItem +| | | |-- DateSeparator +| | | |-- MemberNameAndRole (group received messages) +| | | |-- MemberImage (group received messages) +| | | +-- ChatItemView (message type routing) +| | |-- ChatBannerView (first item: chat profile banner) +| | +-- FloatingButtons (scroll-to-bottom, unread counter) +| |-- ComposeView (message composition area) +| | |-- ContextItemView (reply/edit/forward/report indicator) +| | |-- previewView (link/media/voice/file preview) +| | +-- SendMsgView (text input + send/voice/timed buttons) +| |-- GroupMentions (mention autocomplete popup) +| |-- CommandsMenuView (bot commands popup) +| +-- ChooseAttachmentView (bottom sheet for attachment type) +|-- ChatInfoView (contact info, end modal) ++-- GroupChatInfoView (group management, end modal) +``` + +--- + + + +## 2. ChatView Composable + +**Location:** [`ChatView.kt#L97`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L97) + +```kotlin +fun ChatView( + chatsCtx: ChatModel.ChatsContext, + staleChatId: State, + scrollToItemId: MutableState, + onComposed: suspend (chatId: String) -> Unit +) +``` + +### State Management + +| State Variable | Type | Purpose | +|---|---|---| +| `showSearch` | `MutableState` | Controls search bar visibility | +| `searchText` | `MutableState` | Current search query text | +| `composeState` | `MutableState` | Full compose area state (message, preview, context, mentions) | +| `attachmentOption` | `MutableState` | Selected attachment type from bottom sheet | +| `selectedChatItems` | `MutableState?>` | Multi-select mode item IDs; `null` = selection off | +| `showCommandsMenu` | `MutableState` | Bot commands menu visibility | +| `contentFilter` | `MutableState` | Active content type filter (images, videos, etc.) | +| `availableContent` | `MutableState>` | Content types available in this chat | +| `activeChat` | `State` | Derived from `chatModel.chats` matching `staleChatId` | +| `unreadCount` | `State` | Unread message count derived from chat stats | + +### Chat Loading + +On chat ID change (via `snapshotFlow` on `chatModel.chatId.value`, line ~162): + +1. Marks unread chat as read (`markUnreadChatAsRead`) +2. Clears group members state +3. Resets search, content filter, and selection +4. Fetches available content types (`updateAvailableContent`) +5. For direct chats, loads contact info and connection stats +6. For groups with pending membership, opens member support chat + +### Chat Type Routing + +The outer `when (chatInfo)` (line ~229) branches: + +| ChatInfo Type | Behavior | +|---|---| +| `ChatInfo.Direct`, `ChatInfo.Group`, `ChatInfo.Local` | Full `ChatLayout` with compose, search, reactions, per-chat theme | +| `ChatInfo.ContactConnection` | `ModalView` wrapping `ContactConnectionInfoView` | +| `ChatInfo.InvalidJSON` | `ModalView` with raw JSON display and share button | + +--- + +## 3. Message List + +**Location:** [`ChatView.kt#L1592`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1592) (`ChatItemsList` composable) + +The message list is a `LazyColumnWithScrollBar` with `reverseLayout = true`, meaning index 0 is the newest message at the bottom of the screen. + +### Key Behaviors + +- **Merged Items:** Messages are grouped via `MergedItems.create()` (line ~1653), which collapses consecutive similar system events into expandable groups. Revealed state is tracked in `revealedItems`. +- **Pagination:** `PreloadItems` triggers `loadMessages` with `ChatPagination.Before` (older) or `ChatPagination.Last` (newer) when the user scrolls near list boundaries. +- **Scroll To Item:** `scrollToItem` lambda supports animated scrolling to a specific item ID, used by search result taps and quoted message navigation. +- **Unread Marking:** `MarkItemsReadAfterDelay` composable marks newly visible received items as read after a brief delay. +- **Date Separators:** `DateSeparator` composable renders between messages when the date changes (via `ItemSeparation.date`). +- **Swipe to Reply:** `SwipeToDismiss` modifier on each item (EndToStart direction, 30dp threshold) sets `ComposeContextItem.QuotedItem`. +- **Selection Mode:** When `selectedChatItems` is non-null, a checkbox overlay appears on each item; a full-width clickable overlay toggles selection. + +### Item Layout (ChatViewListItem) + +- **Group received messages** with `showAvatar = true`: Column layout with `MemberNameAndRole` header, `MemberImage` (clickable to `showMemberInfo`), and message bubble. +- **Group received without avatar:** Indented to align with avatar-bearing messages. +- **Sent messages (group or direct):** Right-aligned with larger start padding. +- **Direct messages:** Symmetric padding (76dp opposite side). + +--- + + + +## 4. ChatItemView + +**Location:** [`item/ChatItemView.kt#L66`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L66) + +```kotlin +fun ChatItemView( + chatsCtx, rhId, chat, cItem, composeState, imageProvider, + useLinkPreviews, linkMode, revealed, highlighted, hoveredItemId, + range, selectedChatItems, searchIsNotBlank, fillMaxWidth, + selectChatItem, deleteMessage, deleteMessages, archiveReports, + receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, + openDirectChat, forwardItem, scrollToItem, scrollToItemId, + scrollToQuotedItemFromItem, setReaction, showItemDetails, + reveal, showMemberInfo, showChatInfo, developerTools, showViaProxy, + showTimestamp, itemSeparation, ... +) +``` + +The composable routes based on `cItem.content` and `cItem.meta.itemDeleted`: + +- **Deleted items** -> `DeletedItemView` or `MarkedDeletedItemView` +- **Message content** (`SndMsgContent`, `RcvMsgContent`) -> `FramedItemView` or specialized views depending on `msgContent` type +- **Call items** -> `CICallItemView` +- **Integrity/decryption errors** -> `IntegrityErrorItemView`, `CIRcvDecryptionError` +- **Group invitations** -> `CIGroupInvitationView` +- **Events** (group/direct/connection events) -> `CIEventView` +- **Feature changes** -> `CIChatFeatureView`, `CIFeaturePreferenceView` +- **E2EE info** -> `CIEventView` +- **Chat banner** -> handled at list level, not in `ChatItemView` +- **Invalid JSON** -> `CIInvalidJSONView` + +### Reactions + +`ChatItemReactions` row renders below each message bubble, showing emoji reaction counts. Tapping own reactions removes them; tapping others' opens a member list dropdown. + +### Context Menu + +Long-press or right-click opens a dropdown menu with context-sensitive actions (see section 6). + +--- + +## 5. Message Types + +| CIContent Variant | MsgContent Type | View Composable | Source File | +|---|---|---|---| +| `SndMsgContent` / `RcvMsgContent` | `MCText` | `FramedItemView` -> `TextItemView` or `EmojiItemView` | `TextItemView.kt`, `EmojiItemView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCLink` | `FramedItemView` (with link preview) | `FramedItemView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCImage` | `CIImageView` (inside `FramedItemView`) | `CIImageView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCVideo` | `CIVideoView` (inside `FramedItemView`) | `CIVideoView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCVoice` | `CIVoiceView` | `CIVoiceView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCFile` | `CIFileView` | `CIFileView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCReport` | `FramedItemView` (with report styling) | `FramedItemView.kt` | +| `SndCall` / `RcvCall` | -- | `CICallItemView` | `CICallItemView.kt` | +| `RcvIntegrityError` | -- | `IntegrityErrorItemView` | `IntegrityErrorItemView.kt` | +| `RcvDecryptionError` | -- | `CIRcvDecryptionError` | `CIRcvDecryptionError.kt` | +| `RcvGroupInvitation` / `SndGroupInvitation` | -- | `CIGroupInvitationView` | `CIGroupInvitationView.kt` | +| `RcvDirectEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvGroupEventContent` / `SndGroupEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvConnEventContent` / `SndConnEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvChatFeature` / `SndChatFeature` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `RcvChatPreference` / `SndChatPreference` | -- | `CIFeaturePreferenceView` | `CIFeaturePreferenceView.kt` | +| `RcvGroupFeature` / `SndGroupFeature` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `SndModerated` / `RcvModerated` / `RcvBlocked` | -- | `MarkedDeletedItemView` | `MarkedDeletedItemView.kt` | +| `SndDirectE2EEInfo` / `RcvDirectE2EEInfo` | -- | `CIEventView` | `CIEventView.kt` | +| `SndGroupE2EEInfo` / `RcvGroupE2EEInfo` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvChatFeatureRejected` / `RcvGroupFeatureRejected` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `ChatBanner` | -- | `ChatBannerView` (inline in `ChatItemsList`) | `ChatView.kt` | +| `InvalidJSON` | -- | `CIInvalidJSONView` | `CIInvalidJSONView.kt` | +| `CIMemberCreatedContact` | -- | `CIMemberCreatedContactView` | `CIMemberCreatedContactView.kt` | + +--- + +## 6. Context Menu Actions + +Context menu actions are built dynamically in `ChatItemView` based on message type, direction, chat type, and feature flags. + +| Action | Condition | Effect | +|---|---|---| +| **Reply** | Message content (not event/deleted), not local notes | Sets `ComposeContextItem.QuotedItem` | +| **Edit** | Sent message, editable (`meta.editable`), text/link content | Sets `ComposeContextItem.EditingItem` | +| **Delete for me** | Any deletable item | `apiDeleteChatItems` with `cidmInternal` mode | +| **Delete for everyone** | Sent + within time window, or moderator privilege | `apiDeleteChatItems` with `cidmBroadcast` mode | +| **Moderate** | Group moderator + received message | `apiDeleteMemberChatItems` | +| **Forward** | Message content, not live message | Opens share sheet via `SharedContent.Forward` | +| **Select** | Any selectable item | Enters multi-select mode (`selectedChatItems`) | +| **React** | Message content, reactions enabled | Opens emoji picker; calls `apiChatItemReaction` | +| **Report** | Received group message, reports enabled | Sets `ComposeContextItem.ReportedItem` with reason | +| **Info** | Any message | Opens `ChatItemInfoView` in end modal | +| **Copy** | Text content present | Copies text to clipboard | +| **Save** | Image/video/file with completed download | Saves media to device | +| **Open** | File with completed download | Opens file with system handler | +| **Reveal / Hide** | Part of a merged group; expanded or collapsed | Toggles `revealedItems` state | + +--- + +## 7. ChatInfoView + +**Location:** [`ChatInfoView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt) + +Opened via the `info` callback when the user taps the toolbar title in a direct chat. Displayed in `ModalManager.end`. + +Preloads `apiContactInfo` (connection stats, server profile) and `apiGetContactCode` (verification code) before showing the modal. + +Key sections: contact profile, local alias, connection stats, shared media, disappearing messages preference, voice/call/file feature toggles, encryption verification, and contact deletion. + +--- + +## 8. GroupChatInfoView + +**Location:** [`group/GroupChatInfoView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt) + +Opened via the `info` callback for group chats. Displayed in `ModalManager.end`. + +Preloads group members (`setGroupMembers`) and group link (`apiGetGroupLink`). + +Key sections: group profile, group link, member list with roles, group preferences (disappearing messages, direct messages, full deletion, voice, files, SimpleX links, history), member admission, welcome message, reports view, and group deletion/leave. + +--- + +## 9. Source Files + +### `views/chat/` + +| File | Description | +|---|---| +| `ChatView.kt` | Main chat view, ChatLayout, ChatItemsList, ChatInfoToolbar | +| `ChatInfoView.kt` | Contact info modal | +| `ChatItemInfoView.kt` | Individual message delivery/read info | +| `ChatItemsLoader.kt` | Pagination and message loading logic | +| `ChatItemsMerger.kt` | MergedItems grouping of consecutive events | +| `CommandsMenuView.kt` | Bot `/command` menu popup | +| `ComposeContextContactRequestActionsView.kt` | Contact request action buttons in compose area | +| `ComposeContextGroupDirectInvitationActionsView.kt` | Group direct invitation compose actions | +| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions | +| `ComposeContextProfilePickerView.kt` | Profile picker in compose context | +| `ComposeFileView.kt` | File attachment preview in compose | +| `ComposeImageView.kt` | Image/video attachment preview in compose | +| `ComposeView.kt` | Main compose area (ComposeState, send logic) | +| `ComposeVoiceView.kt` | Voice recording preview in compose | +| `ContactPreferences.kt` | Per-contact feature preferences | +| `ContextItemView.kt` | Reply/edit/forward context indicator | +| `ScanCodeView.kt` | QR code scanner | +| `SelectableChatItemToolbars.kt` | Multi-select toolbar (delete, forward, moderate) | +| `SendMsgView.kt` | Text input field, send button, voice record button | +| `VerifyCodeView.kt` | Contact/member encryption verification | + +### `views/chat/item/` + +| File | Description | +|---|---| +| `ChatItemView.kt` | Message type routing, context menu, reactions | +| `CIBrokenComposableView.kt` | Fallback for rendering errors | +| `CICallItemView.kt` | Call event display (incoming/outgoing/missed) | +| `CIChatFeatureView.kt` | Chat feature change event | +| `CIEventView.kt` | Generic event display (group/direct/connection) | +| `CIFeaturePreferenceView.kt` | Feature preference change event | +| `CIFileView.kt` | File message (download/upload progress) | +| `CIGroupInvitationView.kt` | Group invitation card | +| `CIImageView.kt` | Image message (thumbnail + fullscreen) | +| `CIInvalidJSONView.kt` | Invalid JSON fallback display | +| `CIMemberCreatedContactView.kt` | Member-created contact event | +| `CIMetaView.kt` | Message metadata (time, status indicators) | +| `CIRcvDecryptionError.kt` | Decryption error display | +| `CIVideoView.kt` | Video message (thumbnail + player) | +| `CIVoiceView.kt` | Voice message (waveform + player) | +| `DeletedItemView.kt` | Deleted message placeholder | +| `EmojiItemView.kt` | Large emoji-only message | +| `FramedItemView.kt` | Message bubble frame (quoted item, text, media) | +| `ImageFullScreenView.kt` | Fullscreen image gallery | +| `IntegrityErrorItemView.kt` | Message integrity error | +| `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | +| `TextItemView.kt` | Plain text message with markdown | + +### `views/chat/group/` + +| File | Description | +|---|---| +| `AddGroupMembersView.kt` | Add members to group | +| `GroupChatInfoView.kt` | Group info and management | +| `GroupLinkView.kt` | Group link display and management | +| `GroupMemberInfoView.kt` | Individual member info | +| `GroupMembersToolbar.kt` | Members toolbar in group info | +| `GroupMentions.kt` | @mention autocomplete | +| `GroupPreferences.kt` | Group feature preferences | +| `GroupProfileView.kt` | Group profile editor | +| `GroupReportsView.kt` | Group reports list view | +| `MemberAdmission.kt` | Member admission settings | +| `MemberSupportChatView.kt` | Member support chat (scoped context) | +| `MemberSupportView.kt` | Support chat list for moderators | +| `WelcomeMessageView.kt` | Group welcome message editor | +| `ChannelRelaysView.kt` | Channel relay list. Owner-only Add relay entry opens `AddGroupRelayView` with `existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet()` — every relay currently in `groupRelays` is excluded regardless of `relayStatus`, mirroring the backend `APIAddGroupRelays` gate. Long-press menu offers Remove relay for relays that can be removed. | +| `AddGroupRelayView.kt` | Sheet to pick relays to add to a channel | + +### Relay Rejection Surface + +When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `MemLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`MemRejected` is reserved for the knocking-admission flow). In `GroupMemberInfoView`, an additional "Status: rejected by relay operator" `InfoRow` appears when `groupRelay?.relayStatus == RelayStatus.RsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #`, which has no owner-facing event. + +The `RelayStatusIndicator` composable in `AddChannelView.kt` renders `RsRejected` with a red dot and "rejected" text, matching the `connFailed`/`removed` rendering. diff --git a/apps/multiplatform/spec/client/compose.md b/apps/multiplatform/spec/client/compose.md new file mode 100644 index 0000000000..241dcf667b --- /dev/null +++ b/apps/multiplatform/spec/client/compose.md @@ -0,0 +1,399 @@ +# Message Composition Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt`, `SendMsgView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ComposeState Data Class](#2-composestate-data-class) +3. [ComposePreview Sealed Class](#3-composepreview-sealed-class) +4. [ComposeContextItem Sealed Class](#4-composecontextitem-sealed-class) +5. [SendMsgView](#5-sendmsgview) +6. [Attachment Handling](#6-attachment-handling) +7. [Draft Persistence](#7-draft-persistence) +8. [Source Files](#8-source-files) + +--- + +## Executive Summary + +Message composition in SimpleX Chat is managed by `ComposeView` (line ~345 in `ComposeView.kt`) backed by the serializable `ComposeState` data class. The compose area supports text input, link previews, media/file/voice attachments, reply/edit/forward contexts, live (streaming) messages, member @mentions, message reports, and timed (disappearing) messages. The `SendMsgView` composable (in `SendMsgView.kt`) provides the text field and action buttons. Draft state persists across chat switches when the privacy preference is enabled. + +--- + + + +## 1. Overview + +``` +ComposeView +|-- contextItemView() +| |-- ContextItemView (QuotedItem) [reply indicator] +| |-- ContextItemView (EditingItem) [edit indicator] +| |-- ContextItemView (ForwardingItems) [forward indicator] +| +-- ContextItemView (ReportedItem) [report indicator] +|-- ReportReasonView [report reason header] +|-- MsgNotAllowedView [disabled send reason] +|-- previewView() +| |-- ComposeLinkView [link preview card] +| |-- ComposeImageView [media thumbnails] +| |-- ComposeVoiceView [voice recording waveform] +| +-- ComposeFileView [file name display] +|-- AttachmentAndCommandsButtons +| |-- CommandsButton [bot commands "//"] +| +-- AttachmentButton [paperclip icon] ++-- SendMsgView + |-- PlatformTextField [multiline text input] + |-- DeleteTextButton [clear text, shown on long text] + |-- SendMsgButton [arrow/check icon] + |-- RecordVoiceView [microphone + hold-to-record] + |-- StartLiveMessageButton [bolt icon] + |-- CancelLiveMessageButton [cancel live] + +-- TimedMessageDropdown [disappearing message timer] +``` + +--- + + + +## 2. ComposeState Data Class + +**Location:** [`ComposeView.kt#L98`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L98) + +```kotlin +@Serializable +data class ComposeState( + val message: ComposeMessage = ComposeMessage(), + val parsedMessage: List = emptyList(), + val liveMessage: LiveMessage? = null, + val preview: ComposePreview = ComposePreview.NoPreview, + val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, + val inProgress: Boolean = false, + val progressByTimeout: Boolean = false, + val useLinkPreviews: Boolean, + val mentions: MentionedMembers = emptyMap() +) +``` + +### Fields + +| Field | Type | Description | +|---|---|---| +| `message` | `ComposeMessage` | Current text and cursor selection (`TextRange`) | +| `parsedMessage` | `List` | Markdown-parsed representation of message text | +| `liveMessage` | `LiveMessage?` | Active live (streaming) message state | +| `preview` | `ComposePreview` | Attachment preview (link, media, voice, file) | +| `contextItem` | `ComposeContextItem` | Reply/edit/forward/report context | +| `inProgress` | `Boolean` | Send operation in flight | +| `progressByTimeout` | `Boolean` | Show spinner after 1-second send delay | +| `useLinkPreviews` | `Boolean` | Link preview feature flag | +| `mentions` | `MentionedMembers` | Map of mention display name to `CIMention` | + +### Computed Properties + +| Property | Type | Description | +|---|---|---| +| `editing` | `Boolean` | True when `contextItem` is `EditingItem` | +| `forwarding` | `Boolean` | True when `contextItem` is `ForwardingItems` | +| `reporting` | `Boolean` | True when `contextItem` is `ReportedItem` | +| `sendEnabled` | `() -> Boolean` | True when there is content to send and not in progress | +| `linkPreviewAllowed` | `Boolean` | True when no media/voice/file preview is active | +| `linkPreview` | `LinkPreview?` | Extracts link preview from `CLinkPreview` | +| `attachmentDisabled` | `Boolean` | True when editing, forwarding, live, in-progress, or reporting | +| `attachmentPreview` | `Boolean` | True when a file or media preview is showing | +| `empty` | `Boolean` | True when no text, no preview, and no context item | +| `whitespaceOnly` | `Boolean` | True when message text contains only whitespace | +| `placeholder` | `String` | Input placeholder text (report reason text or default) | +| `memberMentions` | `Map` | Extracted member ID map for API calls | + +### ComposeMessage + +```kotlin +@Serializable +data class ComposeMessage( + val text: String = "", + val selection: TextRange = TextRange.Zero +) +``` + +### LiveMessage + +```kotlin +@Serializable +data class LiveMessage( + val chatItem: ChatItem, + val typedMsg: String, + val sentMsg: String, + val sent: Boolean +) +``` + +Tracks a live (streaming) message: the associated `ChatItem`, the currently typed text, the last sent text, and whether the initial send has occurred. + +### Serialization + +`ComposeState` is fully `@Serializable` with a custom `Saver` (line ~214) that uses `json.encodeToString`/`decodeFromString` for `rememberSaveable` persistence across configuration changes. + +--- + + + +## 3. ComposePreview Sealed Class + +**Location:** [`ComposeView.kt#L52`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L52) + +```kotlin +sealed class ComposePreview { + object NoPreview : ComposePreview() + class CLinkPreview(val linkPreview: LinkPreview?) : ComposePreview() + class MediaPreview(val images: List, val content: List) : ComposePreview() + data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean) : ComposePreview() + class FilePreview(val fileName: String, val uri: URI) : ComposePreview() +} +``` + +| Variant | Fields | View | +|---|---|---| +| `NoPreview` | -- | Nothing shown | +| `CLinkPreview` | `linkPreview: LinkPreview?` (null = loading) | `ComposeLinkView`: title, description, image thumbnail, cancel button | +| `MediaPreview` | `images: List` (base64 thumbnails), `content: List` | `ComposeImageView`: horizontal thumbnail strip, cancel button | +| `VoicePreview` | `voice: String` (file path), `durationMs: Int`, `finished: Boolean` | `ComposeVoiceView`: waveform visualization, duration, play/pause | +| `FilePreview` | `fileName: String`, `uri: URI` | `ComposeFileView`: file icon, file name, cancel button | + +### UploadContent + +Used within `MediaPreview` to track the source type: + +- `SimpleImage(uri: URI)` -- still image +- `AnimatedImage(uri: URI)` -- GIF or animated WebP +- `Video(uri: URI, duration: Int)` -- video with duration in seconds + +--- + +## 4. ComposeContextItem Sealed Class + +**Location:** [`ComposeView.kt#L61`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L61) + +```kotlin +sealed class ComposeContextItem { + object NoContextItem : ComposeContextItem() + class QuotedItem(val chatItem: ChatItem) : ComposeContextItem() + class EditingItem(val chatItem: ChatItem) : ComposeContextItem() + class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo) : ComposeContextItem() + class ReportedItem(val chatItem: ChatItem, val reason: ReportReason) : ComposeContextItem() +} +``` + +| Variant | Trigger | Compose Behavior | +|---|---|---| +| `NoContextItem` | Default state | Normal message composition | +| `QuotedItem` | Swipe-to-reply or reply menu action | Shows quoted message indicator; sends with `quoted` parameter | +| `EditingItem` | Edit menu action | Populates text field with existing message; send button becomes checkmark; calls `apiUpdateChatItem` | +| `ForwardingItems` | Forward action from another chat | Shows forwarded items indicator; calls `apiForwardChatItems`; can include optional text message | +| `ReportedItem` | Report menu action | Shows report indicator with reason; placeholder changes to reason text; calls `apiReportMessage` | + +### Context Item View + +`contextItemView()` (line ~1098 in `ComposeView.kt`) renders the active context as a dismissible bar above the text input: + +- Icon: reply (ic_reply), edit (ic_edit_filled), forward (ic_forward), report (ic_flag) +- Content: quoted message preview text with sender name +- Close button: resets `contextItem` to `NoContextItem` (or `clearState()` for editing) + +--- + + + +## 5. SendMsgView + +**Location:** [`SendMsgView.kt#L36`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt#L36) + +```kotlin +fun SendMsgView( + composeState: MutableState, + showVoiceRecordIcon: Boolean, + recState: MutableState, + isDirectChat: Boolean, + liveMessageAlertShown: SharedPreference, + sendMsgEnabled: Boolean, + userCantSendReason: Pair?, + sendButtonEnabled: Boolean, + sendToConnect: (() -> Unit)?, + hideSendButton: Boolean, + nextConnect: Boolean, + needToAllowVoiceToContact: Boolean, + allowedVoiceByPrefs: Boolean, + sendButtonColor: Color, + allowVoiceToContact: () -> Unit, + timedMessageAllowed: Boolean, + customDisappearingMessageTimePref: SharedPreference?, + placeholder: String, + sendMessage: (Int?) -> Unit, + sendLiveMessage: (suspend () -> Unit)?, + updateLiveMessage: (suspend () -> Unit)?, + cancelLiveMessage: (() -> Unit)?, + editPrevMessage: () -> Unit, + onFilesPasted: (List) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, + textStyle: MutableState, + focusRequester: FocusRequester? +) +``` + +### Layout + +The view is a `Box` containing: + +1. **PlatformTextField:** Multiline text input (platform-specific `expect`). Handles text changes via `onMessageChange`, up-arrow to `editPrevMessage`, file paste via `onFilesPasted`, and Enter to send. +2. **DeleteTextButton:** Shown when text is long; clears the field. +3. **Action area** (bottom-right, stacked): + - **Progress indicator:** Shown when `progressByTimeout` is true. + - **Report confirm button:** Checkmark icon when context is `ReportedItem`. + - **Voice record button:** Shown when message is empty, not editing/forwarding, no preview active. + - `RecordVoiceView`: Hold-to-record with waveform display. + - `DisallowedVoiceButton`: Shown when voice is disabled by preferences. + - `VoiceButtonWithoutPermissionByPlatform`: Shown when microphone permission is not granted. + - **Live message button:** Bolt icon, starts streaming message (calls `sendLiveMessage`). + - **Send button:** Arrow icon (new message) or checkmark (editing/live). Long-press opens dropdown: + - "Send live message" option + - Timed message options (1min, 5min, 1hr, 8hr, 1day, 1week, 1month, custom) + +### RecordingState + +```kotlin +sealed class RecordingState { + object NotStarted : RecordingState() + class Started(val filePath: String, val progressMs: Int) : RecordingState() + class Finished(val filePath: String, val durationMs: Int) : RecordingState() +} +``` + +Voice recording of 300ms or less is auto-cancelled. + +### Disabled State + +When `sendMsgEnabled` is false (e.g., contact not ready, group permissions), an overlay covers the text field. If `userCantSendReason` is provided, tapping the overlay shows an alert explaining why sending is disabled. + +--- + +## 6. Attachment Handling + + + +### Attachment Selection + +The `AttachmentSelection` composable (line ~263 in `ComposeView.kt`) is an `expect` function with platform-specific implementations: + +**Android:** +- Camera launcher (image capture) +- Gallery launcher (image/video picker, multi-select) +- File picker (any file type) + +**Desktop:** +- File chooser dialog (filters for images or all files) + +### ChooseAttachmentView + +Bottom sheet (`ModalBottomSheetLayout`) presenting attachment type options: + +| Option | Result | +|---|---| +| Camera (Android) | Launches camera intent; result processed as `SimpleImage` | +| Gallery | Launches media picker; results processed via `processPickedMedia` | +| File | Launches file picker; result processed via `processPickedFile` | + +### File Processing + +**`processPickedFile`** (line ~281): +1. Checks file size against `maxFileSize` (XFTP limit). +2. Extracts file name from URI. +3. Sets `ComposePreview.FilePreview` on compose state. + +**`processPickedMedia`** (line ~300): +1. For each URI, determines type (image, animated image, video). +2. Images: Gets bitmap, creates `SimpleImage` or `AnimatedImage` upload content. +3. Videos: Extracts thumbnail and duration, creates `Video` upload content. +4. Generates base64 preview thumbnails (max 14KB). +5. Sets `ComposePreview.MediaPreview` with thumbnails and content list. + +**`onFilesAttached`** (line ~270): +Groups dropped/pasted files into images and non-images; routes to `processPickedMedia` or `processPickedFile`. + +### Send Flow + +On send (line ~603, `sendMessageAsync`): + +1. **Forwarding:** Calls `apiForwardChatItems`, then optionally sends a text message quoting the last forwarded item. +2. **Editing:** Calls `apiUpdateChatItem` with updated `MsgContent`. +3. **Reporting:** Calls `apiReportMessage` with reason and text. +4. **New message:** Iterates over `msgs` (one per media item or single for text/file/voice): + - Saves file to app storage (or remote host). + - For voice: encrypts if `privacyEncryptLocalFiles` is enabled. + - Calls `apiSendMessages` or `apiCreateChatItems` (local notes). +5. On failure of the last message, restores compose state for retry. + +### Link Preview + +When `privacyLinkPreviews` is enabled and the message contains a URL: + +1. `showLinkPreview` extracts first non-SimpleX, non-cancelled link from parsed markdown. +2. Sets `ComposePreview.CLinkPreview(null)` (loading state). +3. After 1.5s debounce, calls `getLinkPreview(url)`. +4. On success, updates to `CLinkPreview(linkPreview)`. +5. Cancel button adds the URL to `cancelledLinks` set. + +--- + +## 7. Draft Persistence + +**Location:** [`ComposeView.kt#L1230`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1230) (`KeyChangeEffect(chatModel.chatId.value)`) + +Controlled by the `privacySaveLastDraft` preference. + +### Save Behavior + +When the user navigates away from a chat (`chatModel.chatId.value` changes): + +| Compose State | Action | +|---|---| +| Live message active (text present or already sent) | Sends the live message immediately, clears draft | +| In progress | Clears in-progress flag, clears previous draft | +| Non-empty (text, preview, or context) | If `saveLastDraft` is true: saves `composeState.value` to `chatModel.draft.value` and `chatModel.draftChatId.value` | +| Empty but draft exists for current chat | Restores draft from `chatModel.draft` | +| Empty, no draft | Clears previous draft, deletes unused files | + +### Restore Behavior + +When entering a chat (line ~132 in `ChatView.kt`): + +1. Checks if `chatModel.draftChatId.value` matches the chat ID. +2. If match and draft is not null (and not a cross-chat forward), initializes `composeState` from the draft. +3. Otherwise, creates a fresh `ComposeState`. + +### Desktop-specific + +On desktop, a `DisposableEffect` (line ~1256) saves the draft on dispose when forwarding content, since the `KeyChangeEffect` mechanism is Android-specific. + +### Draft Display in Chat List + +When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the draft text instead of the last message preview. + +--- + +## 8. Source Files + +| File | Description | +|---|---| +| `ComposeView.kt` | ComposeState, ComposePreview, ComposeContextItem, ComposeView composable, send logic, link preview, draft persistence | +| `SendMsgView.kt` | Text input field, send/voice/live/timed buttons, recording state | +| `ComposeFileView.kt` | File attachment preview (name, cancel) | +| `ComposeImageView.kt` | Media attachment preview (thumbnails, cancel) | +| `ComposeVoiceView.kt` | Voice recording preview (waveform, duration, play) | +| `ContextItemView.kt` | Reply/edit/forward/report context bar | +| `ComposeContextContactRequestActionsView.kt` | Contact request action buttons in compose area | +| `ComposeContextGroupDirectInvitationActionsView.kt` | Group direct invitation compose actions | +| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions | +| `ComposeContextProfilePickerView.kt` | Profile picker in compose context | +| `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) | diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md new file mode 100644 index 0000000000..c9939ea3c0 --- /dev/null +++ b/apps/multiplatform/spec/client/navigation.md @@ -0,0 +1,379 @@ +# Navigation Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/App.kt` (470 lines) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [AppScreen Composable](#2-appscreen-composable) +3. [MainScreen](#3-mainscreen) +4. [Android Layout](#4-android-layout) +5. [Desktop Layout](#5-desktop-layout) +6. [ModalManager](#6-modalmanager) +7. [Authentication Gate](#7-authentication-gate) +8. [Onboarding Flow](#8-onboarding-flow) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +SimpleX Chat navigation is a platform-adaptive system implemented in `App.kt`. The root `AppScreen` composable applies theming and safe-area insets, delegating to `MainScreen` which acts as a state machine routing between onboarding, authentication, database error, and the main chat interface. Android uses a 2-column sliding layout (`AndroidScreen`), while desktop uses a fixed 3-column layout (`DesktopScreen`). Modal presentation is managed by `ModalManager`, which provides named zones (start, center, end, fullscreen) for layered content. Authentication is gated by `AppLock`, and onboarding follows a linear `OnboardingStage` enum. + +--- + +## 1. Overview + +``` +AppScreen (line 46) ++-- SimpleXTheme + +-- Surface + +-- MainScreen (line 82) + |-- [Migration in progress] -> DefaultProgressView + |-- [Database opening] -> DefaultProgressView + |-- [Database error] -> DatabaseErrorView + |-- [Encryption check pending] -> SplashView + |-- [Onboarding incomplete] -> AnimatedContent { OnboardingStage views } + |-- [Onboarding complete] + | |-- [Android] + | | +-- AndroidWrapInCallLayout + | | +-- AndroidScreen (line 293) + | | |-- StartPartOfScreen (ChatListView) + | | +-- ChatView (slide-in panel) + | +-- [Desktop] + | +-- DesktopScreen (line 406) + | |-- StartPartOfScreen + UserPicker (left column) + | |-- ModalManager.start (overlay on left) + | |-- CenterPartOfScreen / ChatView (center column) + | +-- ModalManager.end (right column) + |-- [Unauthorized] -> AuthView / SplashView / PasscodeView + |-- [Active call] -> ActiveCallView (desktop) / startCallActivity (Android) + +-- [Incoming call] -> IncomingCallAlertView +``` + +--- + + + +## 2. AppScreen Composable + +**Location:** [`App.kt#L47`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) + +```kotlin +@Composable +fun AppScreen() +``` + +### Responsibilities + +1. **Theme application:** Wraps content in `SimpleXTheme` with `Surface` using `MaterialTheme.colors.background`. +2. **Window insets:** Computes safe padding for landscape mode, accounting for display cutouts on both sides. Uses `WindowInsets.safeDrawing` and `WindowInsets.displayCutout` to calculate symmetric padding. +3. **Fullscreen gallery overlay:** When `chatModel.fullscreenGalleryVisible` is true, draws a black rectangle behind content extending into the cutout areas to provide an immersive gallery background. +4. **Delegates to `MainScreen()`.** + +--- + + + +## 3. MainScreen + +**Location:** [`App.kt#L84`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L84) + +```kotlin +@Composable +fun MainScreen() +``` + +### State Machine + +`MainScreen` evaluates a series of conditions in priority order: + +| Priority | Condition | View | +|---|---|---| +| 1 | `onboarding == Step1_SimpleXInfo && migrationState != null` | `SimpleXInfo` (migration in progress) | +| 2 | `dbMigrationInProgress` | `DefaultProgressView("Database migration...")` | +| 3 | `chatDbStatus == null && showInitializationView` | `DefaultProgressView("Opening database...")` | +| 4 | `showChatDatabaseError` | `DatabaseErrorView` | +| 5 | `chatDbEncrypted == null \|\| localUserCreated == null` | `SplashView` | +| 6 | `onboarding == OnboardingComplete` | Platform-specific main screen | +| 7 | Other onboarding stages | `AnimatedContent` with stage-specific views | + +### Onboarding Complete Branch (line ~156) + +When onboarding is complete: + +1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call). +2. Sets up clipboard listener. +3. Routes to `AndroidScreen` or `DesktopScreen` based on platform. + +### Overlay Layers (bottom of MainScreen) + +| Layer | Condition | Content | +|---|---|---| +| `ModalManager.fullscreen` | Android + migration/onboarding | Fullscreen modals | +| `SwitchingUsersView` | User switch in progress | Loading overlay | +| Auth gate | `userAuthorized != true` | `AuthView` or `SplashView` + passcode | +| Active call | `showCallView == true` | `ActiveCallView` (desktop) or call activity (Android) | +| One-time passcode | Always | `ModalManager.fullscreen.showOneTimePasscodeInView` | +| Privacy alerts | Always | `AlertManager.privacySensitive` | +| Incoming call | `activeCallInvitation != null` | `IncomingCallAlertView` | +| Shared alerts | Always | `AlertManager.shared` | + +--- + + + +## 4. Android Layout + +**Location:** [`App.kt#L296`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L296) + +```kotlin +@Composable +fun AndroidScreen(userPickerState: MutableStateFlow) +``` + +### 2-Column Slide Animation + +Uses `BoxWithConstraints` to get `maxWidth`, then two `Box` containers: + +1. **Left panel (StartPartOfScreen):** Chat list, positioned at `translationX = -offset`. +2. **Right panel (ChatView):** Chat view, positioned at `translationX = maxWidth - offset`. + +The `offset` is an `Animatable`: +- `0f` when no chat is selected (chat list visible). +- `maxWidth.value` when a chat is open (chat view visible). + +### Animation Flow + +1. `snapshotFlow { chatModel.chatId.value }` detects chat ID changes. +2. When `chatId` becomes null, `onComposed(null)` animates offset to 0. +3. When `ChatView` finishes composing (calls `onComposed(chatId)`), offset animates to `maxWidth`. +4. Animation uses `chatListAnimationSpec()` (standard spring or tween). + +### Display Cutout Handling + +If the device has a display cutout on horizontal sides (detected via `WindowInsets.displayCutout`), the panels are clipped with `RectangleShape` to prevent the chat list from showing through during transition. + +### Call Layout Wrapper + +`AndroidWrapInCallLayout` (line ~279) adds a 40dp top padding when an active call is in progress (not in `WaitCapabilities` or `InvitationAccepted` state), with an `ActiveCallInteractiveArea` banner above. + +--- + + + +## 5. Desktop Layout + +**Location:** [`App.kt#L410`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L410) + +```kotlin +@Composable +fun DesktopScreen(userPickerState: MutableStateFlow) +``` + +### 3-Column Layout + +| Column | Width | Content | +|---|---|---| +| **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay | +| **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` | +| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) | +| **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) | + +### Column Separators + +- `VerticalDivider` between left and center columns (always visible). +- `VerticalDivider` between center and right columns (visible when `ModalManager.end.hasModalsOpen()`). + +### Click-to-Dismiss Overlay + +When the UserPicker is visible or a start modal is open (but no center modal), a full-size clickable overlay covers the center+right area (line ~428). Clicking it closes start modals and hides the UserPicker. + +### CenterPartOfScreen + +**Location:** [`App.kt#L373`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L373) + +- When `chatId` is null and no center modals: shows "No selected chat" placeholder. +- When `chatId` is null and center modals open: shows `ModalManager.center`. +- When `chatId` is set: shows `ChatView`. +- Automatically closes center modals when a chat is selected. + +### StartPartOfScreen + +**Location:** [`App.kt#L352`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L352) + +Routes between: +- `SetDeliveryReceiptsView` (if `chatModel.setDeliveryReceipts` is true) +- `ChatListView` (normal operation) +- `ShareListView` (when `chatModel.sharedContent` is non-null, i.e., forwarding) + +--- + +## 6. ModalManager + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt` (line 92) + +```kotlin +class ModalManager(private val placement: ModalPlacement?) +``` + +### Zones + +| Zone | Android Behavior | Desktop Behavior | +|---|---|---| +| `start` | Shared (same as all others) | Left column overlay, slides from start | +| `center` | Shared | Center column overlay, replaces ChatView | +| `end` | Shared | Right column, slides from end | +| `fullscreen` | Shared | Fullscreen overlay | + +On Android, all four zones point to the same `shared` instance, meaning modals stack in a single overlay. On desktop, each zone is independent with its own `ModalPlacement`. + +```kotlin +companion object { + val start = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.START) + val center = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.CENTER) + val end = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.END) + val fullscreen = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.FULLSCREEN) +} +``` + +### Modal Stack + +Each `ModalManager` maintains a stack of `ModalViewHolder` objects with: +- `id: ModalViewId?` -- optional identifier for deduplication +- `animated: Boolean` -- whether to use enter/exit transitions +- `data: ModalData` -- scoped data for the modal +- `modal: @Composable ModalData.(close: () -> Unit) -> Unit` -- the modal content + +### Key Methods + +| Method | Description | +|---|---| +| `showModal` | Push a simple modal onto the stack | +| `showModalCloseable` | Push a modal with a close callback | +| `showCustomModal` | Push a modal with full control over `ModalView` wrapper | +| `closeModals` | Pop all modals from the stack | +| `closeModalsExceptFirst` | Pop all but the bottom modal | +| `hasModalsOpen()` | Check if any modals are on the stack | +| `showInView` | Render the current modal stack into the composable tree | + +### Usage Pattern + +| Action | Zone Used | +|---|---| +| Settings, New Chat, User Address | `ModalManager.start` | +| Onboarding conditions, What's New | `ModalManager.center` | +| ChatInfoView, GroupChatInfoView, ChatItemInfoView, GroupMemberInfoView | `ModalManager.end` | +| Passcode entry, Call view, Migration | `ModalManager.fullscreen` | + +--- + + + +## 7. Authentication Gate + +**Location:** [`AppLock.kt#L17`](../../common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt#L17) + +```kotlin +object AppLock { + val userAuthorized = mutableStateOf(null) + val enteredBackground = mutableStateOf(null) + val laFailed = mutableStateOf(false) +} +``` + +### State + +| Field | Type | Description | +|---|---|---| +| `userAuthorized` | `MutableState` | `null` = not yet determined, `true` = authenticated, `false` = locked | +| `enteredBackground` | `MutableState` | Timestamp when app entered background (for lock delay) | +| `laFailed` | `MutableState` | True if last authentication attempt failed | + +### Authentication Flow + +1. **MainScreen** checks `unauthorized` (derived: `userAuthorized.value != true`) at line ~135. +2. If unauthorized and not in an active call: + - Launches `AppLock.runAuthenticate()` which triggers platform-specific biometric/passcode prompt. + - On Android with system auth finishing during activity destruction, authentication is skipped. +3. If `performLA` preference is set and `laFailed` is true: shows `AuthView` with "Unlock" button. +4. If `performLA` is set and `laFailed` is false: shows `SplashView` with passcode overlay. + +### Lock Delay + +The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. + +### Lock Modes + +- `LAMode.SYSTEM`: Uses Android biometric/system lock screen. +- `LAMode.PASSCODE`: Uses in-app passcode (`SetAppPasscodeView`). + +### First-Time Lock Notice + +`showLANotice` (line ~33 in `AppLock.kt`) prompts users to enable SimpleX Lock when they have more than 3 chats, have not yet been shown the notice, and have not enabled lock. On Android, it offers a choice between system auth and passcode. + +--- + +## 8. Onboarding Flow + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt` (line 3) + +```kotlin +enum class OnboardingStage { + Step1_SimpleXInfo, + Step2_CreateProfile, + LinkAMobile, + Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, + Step3_CreateSimpleXAddress, + Step4_SetNotificationsMode, + OnboardingComplete +} +``` + +### Stage Progression + +| Stage | View | Next Stage | +|---|---|---| +| `Step1_SimpleXInfo` | `SimpleXInfo` -- app introduction, privacy features | `Step2_CreateProfile` or `LinkAMobile` (desktop) | +| `Step2_CreateProfile` | `CreateFirstProfile` -- display name, optional image | `Step2_5_SetupDatabasePassphrase` or `Step3_ChooseServerOperators` | +| `LinkAMobile` | `LinkAMobile` -- desktop linking to mobile device | `Step2_CreateProfile` | +| `Step2_5_SetupDatabasePassphrase` | `SetupDatabasePassphrase` -- optional DB encryption | `Step3_ChooseServerOperators` | +| `Step3_ChooseServerOperators` | `OnboardingConditionsView` -- server operator selection, T&C | `Step3_CreateSimpleXAddress` or `Step4_SetNotificationsMode` | +| `Step3_CreateSimpleXAddress` | `SetNotificationsMode` (legacy backcompat) | `Step4_SetNotificationsMode` | +| `Step4_SetNotificationsMode` | `SetNotificationsMode` -- notification permission setup | `OnboardingComplete` | +| `OnboardingComplete` | Main app screen | -- | + +### Animated Transitions + +Onboarding uses `AnimatedContent` with directional transitions: +- Forward: `fromEndToStartTransition` (slide left). +- Backward: `fromStartToEndTransition` (slide right). + +The stage value is stored in `appPrefs.onboardingStage` and persisted across app restarts. + +--- + +## 9. Source Files + +| File | Description | +|---|---| +| `App.kt` | AppScreen, MainScreen, AndroidScreen, DesktopScreen, StartPartOfScreen, CenterPartOfScreen, EndPartOfScreen | +| `AppLock.kt` | AppLock object, authentication state, lock notice, LA mode selection | +| `views/helpers/ModalView.kt` | ModalManager class, ModalPlacement enum, modal stack management | +| `views/onboarding/OnboardingView.kt` | OnboardingStage enum | +| `views/onboarding/SimpleXInfo.kt` | Step 1: App introduction | +| `views/WelcomeView.kt` | Step 2: Profile creation (CreateFirstProfile) | +| `views/onboarding/LinkAMobileView.kt` | Desktop: Link a mobile device | +| `views/onboarding/SetupDatabasePassphrase.kt` | Step 2.5: Database passphrase | +| `views/onboarding/ChooseServerOperators.kt` | Step 3: Server operators and conditions | +| `views/onboarding/SetNotificationsMode.kt` | Step 4: Notification setup | +| `views/chatlist/ChatListView.kt` | Chat list (StartPartOfScreen content) | +| `views/chatlist/UserPicker.kt` | User switching panel | +| `views/chat/ChatView.kt` | Chat view (CenterPartOfScreen content) | +| `views/database/DatabaseErrorView.kt` | Database error recovery | +| `views/SplashView.kt` | Splash / loading screen | +| `views/call/CallView.kt` | In-call fullscreen view (ActiveCallView) | +| `views/localauth/PasswordEntry.kt` | Column divider utility (contains VerticalDivider) | diff --git a/apps/multiplatform/spec/database.md b/apps/multiplatform/spec/database.md new file mode 100644 index 0000000000..f6ecedb721 --- /dev/null +++ b/apps/multiplatform/spec/database.md @@ -0,0 +1,393 @@ +# Database & Storage + +## Table of Contents + +1. [Overview](#1-overview) +2. [Database Files & Paths](#2-database-files--paths) +3. [Haskell Store Modules](#3-haskell-store-modules) +4. [Migrations](#4-migrations) +5. [Database Encryption](#5-database-encryption) +6. [File Storage](#6-file-storage) +7. [Export & Import](#7-export--import) +8. [Source Files](#8-source-files) + +--- + +## 1. Overview + +SimpleX Chat uses **two SQLite databases** managed entirely by the Haskell core. Kotlin code **never reads or writes the databases directly** -- all data access goes through the JNI command/response protocol defined in [SimpleXAPI.kt](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt). + +The two databases are: + +| Database | Suffix | Contents | +|----------|--------|----------| +| Chat database | `_chat.db` | Users, contacts, groups, messages, files metadata, settings | +| Agent database | `_agent.db` | SMP/XFTP agent state: connections, queues, encryption keys, delivery tracking | + +Both databases are created and migrated by the `chatMigrateInit` JNI function. The Kotlin layer handles: +- Providing the correct file path prefix (`dbAbsolutePrefixPath`) +- Providing the encryption key +- Interpreting migration results (`DBMigrationResult`) +- Exposing API functions that proxy to Haskell store operations + +--- + +## 2. Database Files & Paths + +### Expect Declarations + +The common module declares platform-dependent paths as `expect` values in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt): + +```kotlin +expect val dataDir: File // L18 +expect val tmpDir: File // L19 +expect val filesDir: File // L20 +expect val appFilesDir: File // L21 +expect val wallpapersDir: File // L22 +expect val coreTmpDir: File // L23 +expect val dbAbsolutePrefixPath: String // L24 +expect val preferencesDir: File // L25 +expect val preferencesTmpDir: File // L26 + +expect val chatDatabaseFileName: String // L28 +expect val agentDatabaseFileName: String // L29 + +expect val databaseExportDir: File // L35 +expect val remoteHostsDir: File // L37 +``` + +### Android Actual Values + +From [Files.android.kt](../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt): + +| Variable | Value | Notes | +|----------|-------|-------| +| `dataDir` | `androidAppContext.dataDir` | `/data/data//` | +| `tmpDir` | `getDir("temp", MODE_PRIVATE)` | Private temp directory | +| `filesDir` | `dataDir/files` | Parent for all file storage | +| `appFilesDir` | `filesDir/app_files` | User-visible chat file attachments | +| `wallpapersDir` | `filesDir/assets/wallpapers` | Custom wallpaper images | +| `coreTmpDir` | `filesDir/temp_files` | Haskell core temp directory | +| `dbAbsolutePrefixPath` | `dataDir/files` | Prefix: core appends `_chat.db` / `_agent.db` | +| `chatDatabaseFileName` | `"files_chat.db"` | Full filename: `files_chat.db` | +| `agentDatabaseFileName` | `"files_agent.db"` | Full filename: `files_agent.db` | +| `databaseExportDir` | `androidAppContext.cacheDir` | Temp location for archive export | +| `remoteHostsDir` | `tmpDir/remote_hosts` | Remote host file staging | +| `preferencesDir` | `dataDir/shared_prefs` | Android SharedPreferences directory | + +### Desktop Actual Values + +From [Files.desktop.kt](../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt): + +| Variable | Value | Notes | +|----------|-------|-------| +| `dataDir` | `desktopPlatform.dataPath` | XDG_DATA_HOME (Linux), AppData (Windows), Application Support (macOS) | +| `tmpDir` | `java.io.tmpdir/simplex` | System temp with `deleteOnExit` | +| `filesDir` | `dataDir/simplex_v1_files` | Flat file storage | +| `appFilesDir` | Same as `filesDir` | No subdirectory on desktop | +| `wallpapersDir` | `dataDir/simplex_v1_assets/wallpapers` | Custom wallpaper images | +| `coreTmpDir` | `dataDir/tmp` | Haskell core temp directory | +| `dbAbsolutePrefixPath` | `dataDir/simplex_v1` | Prefix: core appends `_chat.db` / `_agent.db` | +| `chatDatabaseFileName` | `"simplex_v1_chat.db"` | Full filename: `simplex_v1_chat.db` | +| `agentDatabaseFileName` | `"simplex_v1_agent.db"` | Full filename: `simplex_v1_agent.db` | +| `databaseExportDir` | Same as `tmpDir` | Temp location for archive export | +| `remoteHostsDir` | `dataDir/remote_hosts` | Remote host file staging | +| `preferencesDir` | `desktopPlatform.configPath` | Platform config directory | + +### Resulting Database Paths + +| Platform | Chat DB | Agent DB | +|----------|---------|----------| +| Android | `/data/data//files_chat.db` | `/data/data//files_agent.db` | +| Desktop (Linux) | `~/.local/share/simplex/simplex_v1_chat.db` | `~/.local/share/simplex/simplex_v1_agent.db` | +| Desktop (macOS) | `~/Library/Application Support/simplex/simplex_v1_chat.db` | ... | +| Desktop (Windows) | `%APPDATA%/simplex/simplex_v1_chat.db` | ... | + +--- + +## 3. Haskell Store Modules + +The Haskell core organizes database access into store modules. Kotlin code invokes these indirectly through `CC` commands. The store modules are: + +| Module | Path | Responsibilities | +|--------|------|-----------------| +| `Messages.hs` | `src/Simplex/Chat/Store/Messages.hs` | Message CRUD, chat items, reactions, delivery statuses, TTL cleanup | +| `Groups.hs` | `src/Simplex/Chat/Store/Groups.hs` | Group profiles, membership, roles, invitations, group links | +| `Direct.hs` | `src/Simplex/Chat/Store/Direct.hs` | Contact management, direct connections, contact requests | +| `Files.hs` | `src/Simplex/Chat/Store/Files.hs` | File transfer metadata, XFTP state, standalone files | +| `Profiles.hs` | `src/Simplex/Chat/Store/Profiles.hs` | User profiles, display names, address book | +| `Connections.hs` | `src/Simplex/Chat/Store/Connections.hs` | SMP agent connections, pending connections, server switches | + +All store operations execute within SQLite transactions managed by the Haskell core. The Kotlin layer has no direct knowledge of table schemas or SQL queries. + +--- + +## 4. Migrations + +### JNI Entry Point + +Database migration is triggered by the `chatMigrateInit` external function ([Core.kt#L25](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25)): + +```kotlin +external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array +``` + +**Parameters:** +- `dbPath` -- the `dbAbsolutePrefixPath` (core appends `_chat.db` and `_agent.db`) +- `dbKey` -- encryption passphrase (empty string = unencrypted) +- `confirm` -- migration confirmation mode: `"error"`, `"yesUp"`, or `"yesUpDown"` + +**Returns:** `Array` where: +- `[0]` -- JSON string encoding a `DBMigrationResult` +- `[1]` -- `ChatCtrl` handle (Long) if migration succeeded + +### Migration Flow in `initChatController` + +The full initialization sequence is in [Core.kt#L62](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62): + +1. Obtain the DB encryption key from `DatabaseUtils.useDatabaseKey()`. +2. Determine the confirmation mode (default: `YesUp`; developer mode with confirm upgrades: `Error`). +3. Call `chatMigrateInit(dbAbsolutePrefixPath, dbKey, "error")` -- first attempt with `Error` to detect pending migrations. +4. Parse the result as `DBMigrationResult`. +5. If the result is `ErrorMigration` with an `Upgrade` error and confirmation allows it, re-run `chatMigrateInit` with the appropriate confirmation (`"yesUp"`). +6. If `OK`, store the `ChatCtrl` handle, set `chatDbEncrypted`, and proceed to start the chat. +7. If not `OK`, handle special case: if the `newDatabaseInitialized` preference is not set AND the database was only partially initialized (single DB file exists), remove both files and retry once. + + + +### DBMigrationResult + +Defined in [DatabaseUtils.kt#L79](../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt#L79): + +```kotlin +sealed class DBMigrationResult { + object OK // Migration succeeded + object InvalidConfirmation // Invalid confirmation parameter + data class ErrorNotADatabase(val dbFile: String) // File exists but is not a valid database + data class ErrorMigration(val dbFile: String, // Migration error with details + val migrationError: MigrationError) + data class ErrorSQL(val dbFile: String, // SQL error during migration + val migrationSQLError: String) + object ErrorKeychain // Keychain/keystore error + data class Unknown(val json: String) // Unparseable response +} +``` + +### MigrationError + +```kotlin +sealed class MigrationError { + class Upgrade(val upMigrations: List) // Pending forward migrations + class Downgrade(val downMigrations: List) // Database is newer than app + class Error(val mtrError: MTRError) // Conflict or missing migrations +} +``` + +### MigrationConfirmation + +```kotlin +enum class MigrationConfirmation(val value: String) { + YesUp("yesUp"), // Auto-confirm forward migrations + YesUpDown("yesUpDown"), // Auto-confirm both directions (not used in UI) + Error("error") // Report errors without running migrations +} +``` + +--- + +## 5. Database Encryption + +### Encryption API + +Two API functions manage database encryption, both in [SimpleXAPI.kt](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Parameters | Description | Line | +|----------|-----------|-------------|------| +| `apiStorageEncryption` | `currentKey: String, newKey: String` | Change or set the database encryption passphrase | [L999](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L999) | +| `testStorageEncryption` | `key: String, ctrl: ChatCtrl?` | Test whether a given key can decrypt the database | [L1006](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1006) | + +Both delegate to the Haskell core via `CC.ApiStorageEncryption(DBEncryptionConfig)` and `CC.TestStorageEncryption(key)` respectively. + + + +`DBEncryptionConfig` ([SimpleXAPI.kt#L4166](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4166)): + +```kotlin +class DBEncryptionConfig(val currentKey: String, val newKey: String) +``` + +### Passphrase Storage -- CryptorInterface + +The `CryptorInterface` ([Cryptor.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt)) provides platform-specific key encryption for storing the DB passphrase at rest: + +```kotlin +interface CryptorInterface { + fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? + fun encryptText(text: String, alias: String): Pair + fun deleteKey(alias: String) +} + +expect val cryptor: CryptorInterface +``` + +### Android Implementation + +[Cryptor.android.kt](../common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt): + +- Uses **Android KeyStore** (`"AndroidKeyStore"` provider) +- Algorithm: **AES/GCM/NoPadding** (128-bit authentication tag) +- Keys are hardware-backed when available +- On decryption failure with a random initial passphrase, throws to prevent overwriting +- Shows user alerts for keychain errors + +```kotlin +internal class Cryptor: CryptorInterface { + private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + // AES-GCM encryption/decryption using AndroidKeyStore-managed keys +} +``` + +### Desktop Implementation + +[Cryptor.desktop.kt](../common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt): + +- **Placeholder/no-op implementation** -- data is returned as-is +- No actual encryption of the stored passphrase on desktop +- `decryptData` returns `String(data)` without decryption +- `encryptText` returns the raw bytes without encryption + +```kotlin +actual val cryptor: CryptorInterface = object : CryptorInterface { + override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? = String(data) + override fun encryptText(text: String, alias: String) = text.toByteArray() to text.toByteArray() + override fun deleteKey(alias: String) {} +} +``` + +### Passphrase Management + +`DatabaseUtils` ([DatabaseUtils.kt](../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt)) provides: + +- `ksDatabasePassword` -- encrypted passphrase stored in platform preferences (SharedPreferences on Android, file-based on desktop) +- `useDatabaseKey()` -- retrieves the passphrase, decrypting it via `CryptorInterface` +- `randomDatabasePassword()` -- generates a 32-byte random passphrase (Base64-encoded) for initial database creation + +The flow: +1. On first launch, `randomDatabasePassword()` generates a key. +2. `CryptorInterface.encryptText()` encrypts the key for storage. +3. The encrypted (data, IV) pair is saved to preferences via `ksDatabasePassword`. +4. On subsequent launches, `ksDatabasePassword.get()` retrieves the encrypted pair, and `CryptorInterface.decryptData()` recovers the plaintext key. +5. The key is passed to `chatMigrateInit` to open the encrypted SQLite databases. + +--- + +## 6. File Storage + +### Directory Layout + +Declared in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) with platform-specific implementations: + +| Directory | Variable | Android Path | Desktop Path | Purpose | +|-----------|----------|-------------|--------------|---------| +| App files | `appFilesDir` | `dataDir/files/app_files` | `dataDir/simplex_v1_files` | Chat file attachments (images, videos, documents) | +| Wallpapers | `wallpapersDir` | `dataDir/files/assets/wallpapers` | `dataDir/simplex_v1_assets/wallpapers` | Custom chat wallpaper images | +| Core temp | `coreTmpDir` | `dataDir/files/temp_files` | `dataDir/tmp` | Haskell core temporary files (in-progress transfers) | +| App temp | `tmpDir` | `getDir("temp", MODE_PRIVATE)` | `java.io.tmpdir/simplex` | Application-level temporary files | +| Remote hosts | `remoteHostsDir` | `tmpDir/remote_hosts` | `dataDir/remote_hosts` | Files staged for remote host sessions | +| DB export | `databaseExportDir` | `androidAppContext.cacheDir` | Same as `tmpDir` | Temporary storage for database archive ZIP | +| Preferences | `preferencesDir` | `dataDir/shared_prefs` | `desktopPlatform.configPath` | User preferences, theme YAML | +| Migration temp | `getMigrationTempFilesDirectory()` | `dataDir/migration_temp_files` | `dataDir/migration_temp_files` | Temporary files during database migration | + +### File Path Resolution + +Files referenced by chat items use `CryptoFile` (optional encryption metadata + relative path). Path resolution is handled by helper functions in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt): + +- `getAppFilePath(fileName)` ([L81](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L81)) -- resolves to `appFilesDir/fileName` for local, or `remoteHostsDir//simplex_v1_files/fileName` for remote hosts +- `getWallpaperFilePath(fileName)` ([L91](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L91)) -- resolves wallpaper paths similarly +- `getLoadedFilePath(file)` ([L105](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L105)) -- returns the full path if the file is downloaded and ready + +### Local File Encryption + +The `apiSetEncryptLocalFiles(enable)` command ([SimpleXAPI.kt#L967](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L967)) tells the Haskell core to encrypt files stored in `appFilesDir`. When enabled, files are written as `CryptoFile` with a random AES key and nonce. The JNI functions `chatEncryptFile` and `chatDecryptFile` ([Core.kt#L39-L40](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39)) handle the actual crypto operations. + +--- + +## 7. Export & Import + +### API Functions + +| Function | CC Command | CR Response | Line | +|----------|-----------|-------------|------| +| `apiExportArchive(config)` | `CC.ApiExportArchive(config)` | `CR.ArchiveExported(archiveErrors)` | [SimpleXAPI.kt#L981](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L981) | +| `apiImportArchive(config)` | `CC.ApiImportArchive(config)` | `CR.ArchiveImported(archiveErrors)` | [SimpleXAPI.kt#L987](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L987) | +| `apiDeleteStorage()` | `CC.ApiDeleteStorage()` | `CR.CmdOk` | [SimpleXAPI.kt#L993](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L993) | + +### ArchiveConfig + +Defined at [SimpleXAPI.kt#L4162](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4162): + +```kotlin +class ArchiveConfig( + val archivePath: String, // Full path to the ZIP archive + val disableCompression: Boolean?, // Skip compression for speed + val parentTempDirectory: String? // Temp directory for extraction +) +``` + +### Export Flow + +1. UI constructs an `ArchiveConfig` with a path under `databaseExportDir`. +2. Calls `apiExportArchive(config)` which sends `CC.ApiExportArchive` to the Haskell core. +3. The core creates a ZIP containing both `_chat.db` and `_agent.db` (and optionally files). +4. Returns `CR.ArchiveExported` with a list of `ArchiveError` (non-fatal issues during export). +5. UI offers the archive file for sharing/saving. + +### Import Flow + +1. User selects an archive file. +2. UI copies it to a temp location and constructs an `ArchiveConfig`. +3. Calls `apiImportArchive(config)` which sends `CC.ApiImportArchive` to the Haskell core. +4. The core extracts and replaces both databases. +5. Returns `CR.ArchiveImported` with a list of `ArchiveError` (non-fatal issues during import). +6. UI triggers re-initialization via `initChatController`. + + + +### ArchiveError + +Defined at [SimpleXAPI.kt#L7658](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7658): + +```kotlin +sealed class ArchiveError { + class ArchiveErrorImport(val importError: String) // General import error + class ArchiveErrorFile(val file: String, val fileError: String) // Per-file error +} +``` + +### Delete Storage + +`apiDeleteStorage()` removes both database files entirely. This is used during account deletion or database reset operations. After calling this, `initChatController` must be called to create fresh databases. + +--- + +## 8. Source Files + +| File | Purpose | Path | +|------|---------|------| +| SimpleXAPI.kt | API functions: encryption, export/import, storage commands | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Core.kt | JNI externals (`chatMigrateInit`, `chatEncryptFile`, etc.), `initChatController` | `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` | +| Files.kt | Platform-expect file/directory path declarations | `common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt` | +| Files.android.kt | Android actual paths (dataDir, appFilesDir, etc.) | `common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt` | +| Files.desktop.kt | Desktop actual paths (XDG/AppData, etc.) | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt` | +| Cryptor.kt | Platform-expect encryption interface for passphrase storage | `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` | +| Cryptor.android.kt | Android: AES-GCM via AndroidKeyStore | `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` | +| Cryptor.desktop.kt | Desktop: placeholder (no-op) implementation | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` | +| DatabaseUtils.kt | `DBMigrationResult`, `MigrationError`, `MigrationConfirmation`, passphrase helpers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | +| Messages.hs | Haskell store: message CRUD, reactions, delivery | `src/Simplex/Chat/Store/Messages.hs` | +| Groups.hs | Haskell store: groups, membership, roles | `src/Simplex/Chat/Store/Groups.hs` | +| Direct.hs | Haskell store: contacts, direct connections | `src/Simplex/Chat/Store/Direct.hs` | +| Files.hs | Haskell store: file transfer metadata | `src/Simplex/Chat/Store/Files.hs` | +| Profiles.hs | Haskell store: user profiles | `src/Simplex/Chat/Store/Profiles.hs` | +| Connections.hs | Haskell store: SMP agent connections | `src/Simplex/Chat/Store/Connections.hs` | + +All Kotlin paths are relative to `apps/multiplatform/`. All Haskell paths are relative to the repository root. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md new file mode 100644 index 0000000000..f808cf31ba --- /dev/null +++ b/apps/multiplatform/spec/impact.md @@ -0,0 +1,536 @@ +# SimpleX Chat Android & Desktop -- Impact Graph + +> Source file to product concept mapping. Use this to identify which product documents must be updated when a source file changes. +> +> Covers Kotlin Multiplatform (Compose) sources: commonMain, androidMain, desktopMain, and the Android and Desktop app modules. Also covers the shared Haskell core. + +--- + +## Product Concept Legend + +| ID | Concept | +|----|---------| +| PC1 | Chat List | +| PC2 | Direct Chat | +| PC3 | Group Chat | +| PC4 | Message Composition | +| PC5 | Message Reactions | +| PC6 | Message Editing | +| PC7 | Message Deletion | +| PC8 | Timed Messages | +| PC9 | Voice Messages | +| PC10 | File Transfer | +| PC11 | Link Previews | +| PC12 | Contact Connection | +| PC13 | Contact Verification | +| PC14 | Group Management | +| PC15 | Group Links | +| PC16 | Member Roles | +| PC17 | Audio/Video Calls | +| PC18 | Notifications | +| PC19 | User Profiles | +| PC20 | Incognito Mode | +| PC21 | Hidden Profiles | +| PC22 | Local Authentication | +| PC23 | Database Encryption | +| PC24 | Theme System | +| PC25 | Network Configuration | +| PC26 | Device Migration | +| PC27 | Remote Desktop | +| PC28 | Chat Tags | +| PC29 | User Address | +| PC30 | Member Support Chat | +| PC31 | Channels (Relays) | + +--- + +## 1. Common Sources (commonMain) + +Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` + +### 1.1 Core Model & Platform + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `App.kt` | PC1 through PC31 | High | Root composable — navigation scaffold for all features | +| `AppLock.kt` | PC22 | Medium | App lock state and authorization lifecycle | +| `model/ChatModel.kt` | PC1 through PC31 | High | Central state object — every feature reads or writes here | +| `model/SimpleXAPI.kt` | PC1 through PC31 | High | FFI bridge to Haskell core — all commands and responses | +| `model/CryptoFile.kt` | PC10, PC23 | Medium | Encrypted file read/write helpers | +| `platform/Core.kt` | PC1 through PC31 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | +| `platform/AppCommon.kt` | PC1 through PC31 | Medium | Shared app initialization logic | +| `platform/Files.kt` | PC10, PC23, PC26 | Medium | File path resolution, temp dirs, encryption utilities | +| `platform/NtfManager.kt` | PC18 | High | Notification manager expect declarations | +| `platform/Notifications.kt` | PC18 | Medium | Notification channel and permission abstractions | +| `platform/SimplexService.kt` | PC18 | Medium | Background service expect declarations | +| `platform/RecAndPlay.kt` | PC9 | Medium | Audio recording and playback abstractions | +| `platform/VideoPlayer.kt` | PC10, PC17 | Low | Video playback abstractions | +| `platform/Cryptor.kt` | PC23 | Medium | Keystore encryption expect declarations | +| `platform/Share.kt` | PC10, PC12 | Low | Share sheet abstractions | +| `platform/Images.kt` | PC10, PC19 | Low | Image processing utilities | +| `platform/Platform.kt` | PC1 through PC31 | Low | Platform detection and capability flags | +| `platform/PlatformTextField.kt` | PC4 | Low | Native text input expect declarations | +| `platform/Back.kt` | PC1 | Low | Back navigation handling | +| `platform/UI.kt` | PC24 | Low | UI density and locale helpers | +| `platform/ScrollableColumn.kt` | PC1 | Low | Scrollable list abstractions | +| `platform/Log.kt` | — | Low | Logging utility — no direct product impact | +| `platform/Modifier.kt` | PC24 | Low | Compose modifier extensions | +| `platform/Resources.kt` | PC24 | Low | Resource loading helpers | + +### 1.2 Theme + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `ui/theme/ThemeManager.kt` | PC24 | Medium | Theme resolution engine — all color and wallpaper logic | +| `ui/theme/Theme.kt` | PC24 | Medium | Theme composables and `SimpleXTheme` | +| `ui/theme/Color.kt` | PC24 | Low | Color palette definitions | +| `ui/theme/Shape.kt` | PC24 | Low | Shape token definitions | +| `ui/theme/Type.kt` | PC24 | Low | Typography definitions | + +### 1.3 Views — Chat List + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chatlist/ChatListView.kt` | PC1, PC28 | High | Main screen — chat list rendering and search | +| `views/chatlist/ChatListNavLinkView.kt` | PC1, PC2, PC3 | Medium | Navigation from chat list item to chat | +| `views/chatlist/ChatPreviewView.kt` | PC1, PC2, PC3, PC11 | Medium | Chat row preview rendering | +| `views/chatlist/TagListView.kt` | PC28 | Medium | Chat tag filter UI | +| `views/chatlist/UserPicker.kt` | PC19, PC21 | Medium | Multi-profile switcher overlay | +| `views/chatlist/ShareListView.kt` | PC10 | Low | Share target list | +| `views/chatlist/ShareListNavLinkView.kt` | PC10 | Low | Share target navigation | +| `views/chatlist/ChatHelpView.kt` | PC1 | Low | Empty-state help content | +| `views/chatlist/ContactRequestView.kt` | PC12 | Medium | Incoming contact request row | +| `views/chatlist/ContactConnectionView.kt` | PC12 | Low | Pending connection row | +| `views/chatlist/ServersSummaryView.kt` | PC25 | Low | Server status summary | + +### 1.4 Views — Chat & Messaging + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | +| `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages | +| `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle | +| `views/chat/ComposeVoiceView.kt` | PC9 | Medium | Voice message recording UI | +| `views/chat/ComposeFileView.kt` | PC10 | Low | File attachment preview in compose area | +| `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area | +| `views/chat/ContextItemView.kt` | PC6 | Low | Reply/edit quote preview | +| `views/chat/SelectableChatItemToolbars.kt` | PC7, PC10 | Medium | Multi-select toolbar (delete, forward) | +| `views/chat/ChatInfoView.kt` | PC2, PC13, PC20 | Medium | Contact details and verification | +| `views/chat/ContactPreferences.kt` | PC2, PC8 | Medium | Per-contact feature preferences | +| `views/chat/ChatItemInfoView.kt` | PC2, PC3 | Low | Message delivery detail | +| `views/chat/ChatItemsLoader.kt` | PC2, PC3 | Medium | Pagination and message loading logic | +| `views/chat/ChatItemsMerger.kt` | PC2, PC3 | Medium | Merges incremental message updates | +| `views/chat/VerifyCodeView.kt` | PC13 | Medium | Contact security code verification | +| `views/chat/ScanCodeView.kt` | PC13 | Low | QR code scanning for verification | +| `views/chat/CommandsMenuView.kt` | PC4 | Low | Slash-command menu | +| `views/chat/ComposeContextProfilePickerView.kt` | PC20 | Low | Incognito profile picker in compose | +| `views/chat/ComposeContextPendingMemberActionsView.kt` | PC14, PC30 | Low | Pending member action buttons in compose | +| `views/chat/ComposeContextGroupDirectInvitationActionsView.kt` | PC14 | Low | Direct invitation action buttons in compose | +| `views/chat/ComposeContextContactRequestActionsView.kt` | PC12 | Low | Contact request action buttons in compose | + +### 1.5 Views — Chat Items + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/item/ChatItemView.kt` | PC2, PC3, PC5, PC6, PC7, PC8 | High | Root chat item renderer with context menus | +| `views/chat/item/TextItemView.kt` | PC2, PC3, PC4 | Medium | Text message bubble rendering | +| `views/chat/item/FramedItemView.kt` | PC4, PC6, PC10, PC11 | Medium | Framed (quoted/forwarded) message container | +| `views/chat/item/CIImageView.kt` | PC10 | Medium | Image message rendering | +| `views/chat/item/CIVideoView.kt` | PC10 | Medium | Video message rendering | +| `views/chat/item/CIFileView.kt` | PC10 | Medium | File message rendering | +| `views/chat/item/CIVoiceView.kt` | PC9 | Medium | Voice message rendering and playback | +| `views/chat/item/EmojiItemView.kt` | PC5 | Low | Emoji reaction display | +| `views/chat/item/CIMetaView.kt` | PC2, PC3, PC8 | Low | Timestamp, delivery status, timed message indicator | +| `views/chat/item/CICallItemView.kt` | PC17 | Low | Call event item rendering | +| `views/chat/item/CIEventView.kt` | PC3, PC14, PC16 | Low | Group event item rendering | +| `views/chat/item/CIGroupInvitationView.kt` | PC3, PC14 | Low | Group invitation item rendering | +| `views/chat/item/CIMemberCreatedContactView.kt` | PC3, PC12 | Low | Member-created contact event | +| `views/chat/item/CIChatFeatureView.kt` | PC8 | Low | Feature change event rendering | +| `views/chat/item/CIFeaturePreferenceView.kt` | PC8 | Low | Feature preference change rendering | +| `views/chat/item/CIRcvDecryptionError.kt` | PC2, PC3 | Low | Decryption error display | +| `views/chat/item/DeletedItemView.kt` | PC7 | Low | Deleted message placeholder | +| `views/chat/item/MarkedDeletedItemView.kt` | PC7 | Low | Moderated/marked-deleted placeholder | +| `views/chat/item/ImageFullScreenView.kt` | PC10 | Low | Full-screen image viewer | +| `views/chat/item/CIBrokenComposableView.kt` | — | Low | Fallback for render failures | +| `views/chat/item/CIInvalidJSONView.kt` | — | Low | Fallback for malformed items | +| `views/chat/item/IntegrityErrorItemView.kt` | PC2, PC3 | Low | Message integrity error display | + +### 1.6 Views — Groups + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/group/GroupChatInfoView.kt` | PC3, PC14, PC15, PC16, PC30 | High | Group management hub | +| `views/chat/group/AddGroupMembersView.kt` | PC14, PC16 | Medium | Member invitation flow | +| `views/chat/group/GroupMemberInfoView.kt` | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; relay-address + rejected-status info rows | +| `views/chat/group/ChannelRelaysView.kt` | PC31 | Medium | Channel relay list, add/remove entries | +| `views/chat/group/AddGroupRelayView.kt` | PC31 | Low | Add relay sheet | +| `views/chat/group/GroupProfileView.kt` | PC3, PC14 | Medium | Group profile editing | +| `views/chat/group/GroupLinkView.kt` | PC15 | Low | Group link creation and sharing | +| `views/chat/group/GroupPreferences.kt` | PC3, PC8, PC14 | Medium | Group feature toggles | +| `views/chat/group/GroupMentions.kt` | PC3, PC4 | Medium | @mention resolution and display | +| `views/chat/group/GroupMembersToolbar.kt` | PC3, PC14 | Low | Member list toolbar | +| `views/chat/group/GroupReportsView.kt` | PC3, PC14 | Low | Group content reports | +| `views/chat/group/MemberAdmission.kt` | PC14, PC16 | Medium | Member admission settings | +| `views/chat/group/MemberSupportView.kt` | PC30 | Medium | Member support chat toggle | +| `views/chat/group/MemberSupportChatView.kt` | PC30 | Medium | Member support chat conversation | +| `views/chat/group/WelcomeMessageView.kt` | PC3, PC14 | Low | Group welcome message editor | + +### 1.7 Views — Calls + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/call/CallView.kt` | PC17 | High | Call UI and WebRTC composable | +| `views/call/CallManager.kt` | PC17 | High | Call lifecycle management | +| `views/call/WebRTC.kt` | PC17 | High | WebRTC types and signaling | +| `views/call/IncomingCallAlertView.kt` | PC17, PC18 | Medium | Incoming call overlay | + +### 1.8 Views — New Chat & Contacts + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/newchat/NewChatView.kt` | PC12, PC29 | High | New connection creation — onramp for all contacts | +| `views/newchat/NewChatSheet.kt` | PC12 | Medium | Bottom sheet with connection options | +| `views/newchat/ConnectPlan.kt` | PC12, PC15 | Medium | Link parsing and connection plan resolution | +| `views/newchat/AddGroupView.kt` | PC3, PC14 | Medium | New group creation flow | +| `views/newchat/AddChannelView.kt` | PC31 | Medium | Public channel creation, channel link card, `RelayStatusIndicator` | +| `views/newchat/ContactConnectionInfoView.kt` | PC12 | Low | Pending connection details | +| `views/newchat/AddContactLearnMore.kt` | PC12 | Low | Educational content | +| `views/newchat/QRCode.kt` | PC12 | Low | QR code display | +| `views/newchat/QRCodeScanner.kt` | PC12 | Low | QR code camera scanner | +| `views/contacts/ContactListNavView.kt` | PC1, PC12 | Medium | Contact list navigation | +| `views/contacts/ContactPreviewView.kt` | PC12 | Low | Contact row preview | + +### 1.9 Views — User Settings + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/usersettings/SettingsView.kt` | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | +| `views/usersettings/Appearance.kt` | PC24 | Low | Theme and appearance customization | +| `views/usersettings/PrivacySettings.kt` | PC20, PC22 | Medium | Privacy and lock settings | +| `views/usersettings/UserProfileView.kt` | PC19 | Medium | Profile display name and image editing | +| `views/usersettings/UserProfilesView.kt` | PC19, PC21 | Medium | Multi-profile management | +| `views/usersettings/HiddenProfileView.kt` | PC21 | Medium | Hidden profile access | +| `views/usersettings/IncognitoView.kt` | PC20 | Low | Incognito mode explanation | +| `views/usersettings/UserAddressView.kt` | PC29 | Medium | User SimpleX address management | +| `views/usersettings/UserAddressLearnMore.kt` | PC29 | Low | Address educational content | +| `views/usersettings/NotificationsSettingsView.kt` | PC18 | Medium | Notification mode configuration | +| `views/usersettings/CallSettings.kt` | PC17 | Low | Call-related settings | +| `views/usersettings/Preferences.kt` | PC2, PC3, PC8 | Medium | Chat feature preferences UI | +| `views/usersettings/SetDeliveryReceiptsView.kt` | PC2 | Low | Delivery receipts toggle | +| `views/usersettings/RTCServers.kt` | PC17, PC25 | Medium | WebRTC ICE server configuration | +| `views/usersettings/DeveloperView.kt` | — | Low | Developer/debug settings | +| `views/usersettings/HelpView.kt` | — | Low | Help and support links | +| `views/usersettings/MarkdownHelpView.kt` | PC4 | Low | Markdown formatting guide | +| `views/usersettings/VersionInfoView.kt` | — | Low | Version display | +| `views/usersettings/networkAndServers/NetworkAndServers.kt` | PC25 | High | Server and network configuration hub | +| `views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | PC25 | Medium | SOCKS proxy, timeouts, etc. | +| `views/usersettings/networkAndServers/OperatorView.kt` | PC25 | Medium | Server operator management | +| `views/usersettings/networkAndServers/ProtocolServersView.kt` | PC25 | Medium | SMP/XFTP server list | +| `views/usersettings/networkAndServers/ProtocolServerView.kt` | PC25 | Low | Individual server editing | +| `views/usersettings/networkAndServers/NewServerView.kt` | PC25 | Low | Add new server | +| `views/usersettings/networkAndServers/ScanProtocolServer.kt` | PC25 | Low | QR scan for server address | + +### 1.10 Views — Database & Migration + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/database/DatabaseView.kt` | PC23, PC26 | High | Database management — export, import, passphrase | +| `views/database/DatabaseEncryptionView.kt` | PC23 | High | Database encryption passphrase change | +| `views/database/DatabaseErrorView.kt` | PC23 | Medium | Database open error recovery | +| `views/migration/MigrateFromDevice.kt` | PC26 | High | Outbound device migration | +| `views/migration/MigrateToDevice.kt` | PC26 | High | Inbound device migration | + +### 1.11 Views — Local Auth & Onboarding + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/localauth/LocalAuthView.kt` | PC22 | Medium | App lock authentication flow | +| `views/localauth/SetAppPasscodeView.kt` | PC22 | Medium | Passcode creation and change | +| `views/localauth/PasscodeView.kt` | PC22 | Medium | Passcode entry UI | +| `views/localauth/PasswordEntry.kt` | PC22 | Low | Password input field | +| `views/onboarding/OnboardingView.kt` | PC1 | Medium | Onboarding flow navigation | +| `views/onboarding/SimpleXInfo.kt` | PC1 | Low | Welcome screen | +| `views/onboarding/SetNotificationsMode.kt` | PC18 | Medium | Notification permission and mode setup | +| `views/onboarding/SetupDatabasePassphrase.kt` | PC23 | Medium | Initial database passphrase setup | +| `views/onboarding/ChooseServerOperators.kt` | PC25 | Medium | Initial server operator selection | +| `views/onboarding/WhatsNewView.kt` | — | Low | Release notes display | +| `views/onboarding/HowItWorks.kt` | — | Low | Educational content | +| `views/onboarding/LinkAMobileView.kt` | PC27 | Low | Mobile linking onboarding | + +### 1.12 Views — Remote Desktop + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/remote/ConnectDesktopView.kt` | PC27 | Medium | Connect-to-desktop flow (from mobile) | +| `views/remote/ConnectMobileView.kt` | PC27 | Medium | Connect-to-mobile flow (from desktop) | + +### 1.13 Views — Helpers + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/helpers/AlertManager.kt` | PC1 through PC31 | Medium | Modal alert system used across all features | +| `views/helpers/ModalView.kt` | PC1 through PC31 | Medium | Modal navigation stack | +| `views/helpers/Utils.kt` | PC1 through PC31 | Low | Shared formatting, clipboard, and utility functions | +| `views/helpers/DatabaseUtils.kt` | PC23 | Medium | Keystore passphrase and database helpers | +| `views/helpers/LinkPreviews.kt` | PC11 | Medium | Link preview fetching and rendering | +| `views/helpers/LocalAuthentication.kt` | PC22 | Medium | Biometric/passcode authentication expect | +| `views/helpers/ChatWallpaper.kt` | PC24 | Low | Chat wallpaper rendering | +| `views/helpers/ChatInfoImage.kt` | PC19 | Low | Profile image composable | +| `views/helpers/ThemeModeEditor.kt` | PC24 | Low | Theme mode toggle | +| `views/helpers/ChooseAttachmentView.kt` | PC10 | Low | Attachment picker | +| `views/helpers/GetImageView.kt` | PC10, PC19 | Low | Image capture and crop | +| `views/helpers/TextEditor.kt` | PC4 | Low | Rich text editor helpers | +| `views/helpers/SearchTextField.kt` | PC1 | Low | Search bar composable | +| `views/helpers/CustomTimePicker.kt` | PC8 | Low | Time picker for timed messages | +| `views/helpers/DragAndDrop.kt` | PC10 | Low | Drag-and-drop file handling | +| `views/helpers/ProcessedErrors.kt` | — | Low | Error aggregation | +| `views/helpers/AnimationUtils.kt` | PC24 | Low | Animation helpers | +| `views/helpers/DefaultDialog.kt` | — | Low | Dialog composable primitives | +| `views/helpers/DefaultDropdownMenu.kt` | — | Low | Dropdown menu composable | +| `views/helpers/Section.kt` | — | Low | Settings section composable | +| `views/helpers/SimpleButton.kt` | — | Low | Button composable | +| `views/helpers/DefaultTopAppBar.kt` | — | Low | App bar composable | +| `views/helpers/DefaultBasicTextField.kt` | PC4 | Low | Text field composable | +| `views/helpers/AppBarTitle.kt` | — | Low | App bar title composable | +| `views/helpers/BlurModifier.kt` | PC22 | Low | Blur modifier for app lock | +| `views/helpers/CollapsingAppBar.kt` | — | Low | Collapsing toolbar composable | +| `views/helpers/CustomIcons.kt` | — | Low | Custom icon definitions | +| `views/helpers/DataClasses.kt` | — | Low | Shared data class utilities | +| `views/helpers/DefaultProgressBar.kt` | — | Low | Progress bar composable | +| `views/helpers/DefaultSwitch.kt` | — | Low | Switch composable | +| `views/helpers/Enums.kt` | — | Low | Enum utility extensions | +| `views/helpers/ExposedDropDownSettingRow.kt` | — | Low | Dropdown setting row composable | +| `views/helpers/GestureDetector.kt` | — | Low | Touch gesture utilities | +| `views/helpers/Modifiers.kt` | — | Low | Compose modifier extensions | +| `views/helpers/SubscriptionStatusIcon.kt` | PC25 | Low | Server connection status icon | + +### 1.14 Views — Other + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/TerminalView.kt` | — | Low | Developer chat console | +| `views/SplashView.kt` | — | Low | Splash screen | +| `views/WelcomeView.kt` | PC1 | Low | Empty-state welcome | +| `views/Preview.kt` | — | Low | Compose preview utilities | + +--- + +## 2. Android Sources + +### 2.1 Android App Module + +Path prefix: `android/src/main/java/chat/simplex/app/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `SimplexApp.kt` | PC1 through PC31 | High | Application class — initializes core, preferences, and notification channels | +| `MainActivity.kt` | PC1 through PC31 | High | Single-activity host — intent handling, lifecycle, deep links | +| `SimplexService.kt` | PC18 | High | Foreground service — keeps message receiver alive | +| `CallService.kt` | PC17 | Medium | Foreground service for active calls | +| `MessagesFetcherWorker.kt` | PC18 | Medium | WorkManager periodic message fetch | +| `model/NtfManager.android.kt` | PC18 | High | Android notification channels, display, and actions | +| `views/call/CallActivity.kt` | PC17 | Medium | Dedicated activity for full-screen call UI | +| `views/helpers/Util.kt` | — | Low | Android-specific utility extensions | + +### 2.2 Android Platform Implementations (androidMain) + +Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `platform/AppCommon.android.kt` | PC1 through PC31 | Medium | Android app initialization actual declarations | +| `platform/SimplexService.android.kt` | PC18 | Medium | Android foreground service actual implementation | +| `platform/Files.android.kt` | PC10, PC23, PC26 | Medium | Android file paths and content-URI resolution | +| `platform/Cryptor.android.kt` | PC23 | Medium | Android Keystore encryption actual implementation | +| `platform/RecAndPlay.android.kt` | PC9 | Medium | Android MediaRecorder/MediaPlayer actual implementation | +| `platform/VideoPlayer.android.kt` | PC10 | Low | Android ExoPlayer actual implementation | +| `platform/Notifications.android.kt` | PC18 | Medium | Android notification channel creation | +| `platform/Images.android.kt` | PC10, PC19 | Low | Android bitmap processing | +| `platform/PlatformTextField.android.kt` | PC4 | Low | Android native text field actual implementation | +| `platform/Share.android.kt` | PC10 | Low | Android share intent actual implementation | +| `platform/Back.android.kt` | PC1 | Low | Android back press handler | +| `platform/UI.android.kt` | PC24 | Low | Android density and locale | +| `platform/ScrollableColumn.android.kt` | PC1 | Low | Android lazy list actual implementation | +| `platform/Log.android.kt` | — | Low | Android Log wrapper | +| `platform/Modifier.android.kt` | — | Low | Android modifier extensions | +| `platform/Resources.android.kt` | — | Low | Android resource loading | +| `helpers/NetworkObserver.kt` | PC25 | Medium | Android ConnectivityManager observer | +| `helpers/Permissions.kt` | PC9, PC10, PC17, PC18 | Medium | Android runtime permission requests | +| `helpers/SoundPlayer.kt` | PC17, PC18 | Low | Android sound playback for calls and notifications | +| `helpers/Extensions.kt` | — | Low | Kotlin extension utilities | +| `helpers/Locale.kt` | — | Low | Locale helpers | +| `views/call/CallView.android.kt` | PC17 | Medium | Android WebView-based WebRTC call | +| `views/call/CallAudioDeviceManager.kt` | PC17 | Medium | Android audio routing (speaker, earpiece, bluetooth) | +| `views/chat/ComposeView.android.kt` | PC4, PC10 | Low | Android compose view extensions | +| `views/chat/SendMsgView.android.kt` | PC4 | Low | Android send button extensions | +| `views/chat/item/ChatItemView.android.kt` | PC2, PC3 | Low | Android chat item extensions | +| `views/chat/item/CIImageView.android.kt` | PC10 | Low | Android image rendering extensions | +| `views/chat/item/CIVideoView.android.kt` | PC10 | Low | Android video rendering extensions | +| `views/chat/item/CIFileView.android.kt` | PC10 | Low | Android file view extensions | +| `views/chat/item/EmojiItemView.android.kt` | PC5 | Low | Android emoji rendering extensions | +| `views/chat/item/ImageFullScreenView.android.kt` | PC10 | Low | Android full-screen image viewer | +| `views/chatlist/ChatListView.android.kt` | PC1 | Low | Android chat list extensions | +| `views/chatlist/ChatListNavLinkView.android.kt` | PC1 | Low | Android chat list navigation extensions | +| `views/chatlist/TagListView.android.kt` | PC28 | Low | Android tag list extensions | +| `views/chatlist/UserPicker.android.kt` | PC19 | Low | Android profile picker extensions | +| `views/database/DatabaseView.android.kt` | PC23, PC26 | Low | Android database view extensions | +| `views/database/DatabaseEncryptionView.android.kt` | PC23 | Low | Android encryption view extensions | +| `views/helpers/LocalAuthentication.android.kt` | PC22 | Medium | Android BiometricPrompt actual implementation | +| `views/helpers/ChooseAttachmentView.android.kt` | PC10 | Low | Android file/camera chooser | +| `views/helpers/GetImageView.android.kt` | PC10, PC19 | Low | Android image capture | +| `views/helpers/CustomTimePicker.android.kt` | PC8 | Low | Android time picker | +| `views/helpers/Utils.android.kt` | — | Low | Android utility extensions | +| `views/helpers/DefaultDialog.android.kt` | — | Low | Android dialog extensions | +| `views/helpers/WorkaroundFocusSearchLayout.kt` | — | Low | Android focus workaround | +| `views/newchat/QRCode.android.kt` | PC12 | Low | Android QR code rendering | +| `views/newchat/QRCodeScanner.android.kt` | PC12 | Low | Android camera QR scanner | +| `views/onboarding/SimpleXInfo.android.kt` | PC1 | Low | Android onboarding extensions | +| `views/onboarding/SetNotificationsMode.android.kt` | PC18 | Low | Android notification mode extensions | +| `views/usersettings/Appearance.android.kt` | PC24 | Low | Android appearance extensions | +| `views/usersettings/PrivacySettings.android.kt` | PC20, PC22 | Low | Android privacy settings extensions | +| `views/usersettings/SettingsView.android.kt` | — | Low | Android settings extensions | +| `views/usersettings/networkAndServers/OperatorView.android.kt` | PC25 | Low | Android operator view extensions | +| `views/usersettings/networkAndServers/ScanProtocolServer.android.kt` | PC25 | Low | Android server QR scan | +| `ui/theme/Theme.android.kt` | PC24 | Low | Android dynamic color / system theme | +| `ui/theme/Type.android.kt` | PC24 | Low | Android typography | + +--- + +## 3. Desktop Sources + +### 3.1 Desktop App Module + +Path prefix: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `Main.kt` | PC1 through PC31 | High | JVM entry point — Haskell init, migrations, app launch | + +### 3.2 Desktop Platform Implementations (desktopMain) + +Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery | +| `StoreWindowState.kt` | — | Low | Window position/size persistence | +| `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | +| `platform/AppCommon.desktop.kt` | PC1 through PC31 | Medium | Desktop app initialization actual declarations | +| `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | +| `platform/Files.desktop.kt` | PC10, PC23, PC26 | Medium | Desktop file path resolution | +| `platform/Cryptor.desktop.kt` | PC23 | Medium | Desktop keystore encryption actual implementation | +| `platform/RecAndPlay.desktop.kt` | PC9 | Medium | Desktop audio recording/playback actual implementation | +| `platform/VideoPlayer.desktop.kt` | PC10 | Low | Desktop VLC-based video player | +| `platform/Videos.desktop.kt` | PC10 | Low | Desktop video utilities | +| `platform/Notifications.desktop.kt` | PC18 | Low | Desktop notification setup | +| `platform/Images.desktop.kt` | PC10 | Low | Desktop image processing | +| `platform/PlatformTextField.desktop.kt` | PC4 | Low | Desktop text field actual implementation | +| `platform/Share.desktop.kt` | PC10 | Low | Desktop clipboard/share | +| `platform/Back.desktop.kt` | PC1 | Low | Desktop back navigation | +| `platform/UI.desktop.kt` | PC24 | Low | Desktop density and locale | +| `platform/ScrollableColumn.desktop.kt` | PC1 | Low | Desktop lazy list | +| `platform/Platform.desktop.kt` | — | Low | Platform detection | +| `platform/Log.desktop.kt` | — | Low | Desktop log output | +| `platform/Modifier.desktop.kt` | — | Low | Desktop modifier extensions | +| `platform/Resources.desktop.kt` | — | Low | Desktop resource loading | +| `views/call/CallView.desktop.kt` | PC17 | Medium | Desktop WebView-based WebRTC call | +| `views/chat/ComposeView.desktop.kt` | PC4, PC10 | Low | Desktop compose view (drag-and-drop, paste) | +| `views/chat/SendMsgView.desktop.kt` | PC4 | Low | Desktop send shortcut (Enter key handling) | +| `views/chat/item/ChatItemView.desktop.kt` | PC2, PC3 | Low | Desktop chat item extensions | +| `views/chat/item/CIImageView.desktop.kt` | PC10 | Low | Desktop image rendering | +| `views/chat/item/CIVideoView.desktop.kt` | PC10 | Low | Desktop video rendering | +| `views/chat/item/CIFileView.desktop.kt` | PC10 | Low | Desktop file open/save | +| `views/chat/item/EmojiItemView.desktop.kt` | PC5 | Low | Desktop emoji rendering | +| `views/chat/item/ImageFullScreenView.desktop.kt` | PC10 | Low | Desktop full-screen image | +| `views/chatlist/ChatListView.desktop.kt` | PC1 | Low | Desktop chat list extensions | +| `views/chatlist/ChatListNavLinkView.desktop.kt` | PC1 | Low | Desktop chat list navigation | +| `views/chatlist/TagListView.desktop.kt` | PC28 | Low | Desktop tag list extensions | +| `views/chatlist/UserPicker.desktop.kt` | PC19 | Low | Desktop profile picker | +| `views/database/DatabaseView.desktop.kt` | PC23, PC26 | Low | Desktop database view extensions | +| `views/database/DatabaseEncryptionView.desktop.kt` | PC23 | Low | Desktop encryption view extensions | +| `views/helpers/AppUpdater.kt` | — | Low | Desktop auto-update checker and installer | +| `views/helpers/OkHttpProgressListener.kt` | — | Low | Download progress tracking for updates | +| `views/helpers/LocalAuthentication.desktop.kt` | PC22 | Low | Desktop passcode-only auth (no biometrics) | +| `views/helpers/ChooseAttachmentView.desktop.kt` | PC10 | Low | Desktop file chooser dialog | +| `views/helpers/GetImageView.desktop.kt` | PC10, PC19 | Low | Desktop image file picker | +| `views/helpers/CustomTimePicker.desktop.kt` | PC8 | Low | Desktop time picker | +| `views/helpers/Utils.desktop.kt` | — | Low | Desktop utility extensions | +| `views/helpers/DefaultDialog.desktop.kt` | — | Low | Desktop dialog extensions | +| `views/newchat/QRCode.desktop.kt` | PC12 | Low | Desktop QR code rendering | +| `views/newchat/QRCodeScanner.desktop.kt` | PC12 | Low | Desktop QR code scanner (screen/clipboard) | +| `views/onboarding/SimpleXInfo.desktop.kt` | PC1 | Low | Desktop onboarding extensions | +| `views/onboarding/SetNotificationsMode.desktop.kt` | PC18 | Low | Desktop notification mode extensions | +| `views/usersettings/Appearance.desktop.kt` | PC24 | Low | Desktop appearance extensions | +| `views/usersettings/PrivacySettings.desktop.kt` | PC20, PC22 | Low | Desktop privacy settings extensions | +| `views/usersettings/SettingsView.desktop.kt` | — | Low | Desktop settings extensions | +| `views/usersettings/networkAndServers/OperatorView.desktop.kt` | PC25 | Low | Desktop operator view extensions | +| `views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt` | PC25 | Low | Desktop server address scan | +| `ui/theme/Theme.desktop.kt` | PC24 | Low | Desktop system theme detection | +| `ui/theme/Type.desktop.kt` | PC24 | Low | Desktop typography | +| `other/videoplayer/SkiaBitmapVideoSurface.kt` | PC10 | Low | Desktop Skia video surface for VLC | + +--- + +## 4. Haskell Core Impact + +The Haskell core is compiled as a shared native library (`libsimplex.so` / `libsimplex.dylib`) and linked via JNI through `Core.kt`. Changes here affect both Android and Desktop identically. + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `src/Simplex/Chat.hs` | PC1 through PC31 | High | Main chat module — top-level orchestration | +| `src/Simplex/Chat/Controller.hs` | PC1 through PC31 | High | Command processor — all API commands dispatched here | +| `src/Simplex/Chat/Types.hs` | PC1 through PC31 | High | Core data types shared across all features | +| `src/Simplex/Chat/Core.hs` | PC1 through PC31 | High | Chat engine lifecycle (start, stop, subscribe) | +| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC31 | High | API command handler implementations | +| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC31 | High | Internal helpers for command processing | +| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC31 | High | Event subscriber — incoming message routing | +| `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| `src/Simplex/Chat/Messages.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | +| `src/Simplex/Chat/Messages/CIContent.hs` | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | +| `src/Simplex/Chat/Messages/CIContent/Events.hs` | PC3, PC14, PC16 | Medium | Group event content types | +| `src/Simplex/Chat/Messages/Batch.hs` | PC2, PC3, PC4 | Medium | Message batching for efficient delivery | +| `src/Simplex/Chat/Call.hs` | PC17 | Medium | Call signaling types | +| `src/Simplex/Chat/Files.hs` | PC10 | Medium | File transfer orchestration | +| `src/Simplex/Chat/Delivery.hs` | PC2, PC3 | Medium | Message delivery engine | +| `src/Simplex/Chat/Markdown.hs` | PC4 | Low | Markdown parsing for message formatting | +| `src/Simplex/Chat/Store.hs` | PC1 through PC31 | High | Database store interface | +| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC31 | Medium | Shared store utilities | +| `src/Simplex/Chat/Store/Messages.hs` | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | +| `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | +| `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13 | High | Contact persistence | +| `src/Simplex/Chat/Store/Files.hs` | PC10 | Medium | File transfer persistence | +| `src/Simplex/Chat/Store/Profiles.hs` | PC19, PC21 | Medium | User profile persistence | +| `src/Simplex/Chat/Store/Connections.hs` | PC2, PC12 | High | Connection persistence and entity resolution | +| `src/Simplex/Chat/Store/ContactRequest.hs` | PC12 | Medium | Contact request persistence | +| `src/Simplex/Chat/Store/NoteFolders.hs` | PC1 | Low | Note folder (self-chat) persistence | +| `src/Simplex/Chat/Store/Delivery.hs` | PC2, PC3 | Medium | Delivery task persistence | +| `src/Simplex/Chat/Store/AppSettings.hs` | PC25 | Low | App settings persistence | +| `src/Simplex/Chat/Store/Remote.hs` | PC27 | Low | Remote desktop session persistence | +| `src/Simplex/Chat/Archive.hs` | PC26 | Medium | Database export/import for migration | +| `src/Simplex/Chat/Options.hs` | PC23, PC25 | Low | Startup options (DB path, key, etc.) | +| `src/Simplex/Chat/Remote.hs` | PC27 | Medium | Remote desktop protocol handler | +| `src/Simplex/Chat/Remote/Types.hs` | PC27 | Low | Remote desktop data types | +| `src/Simplex/Chat/Remote/Protocol.hs` | PC27 | Medium | Remote desktop wire protocol | +| `src/Simplex/Chat/Remote/Transport.hs` | PC27 | Low | Remote desktop transport layer | +| `src/Simplex/Chat/Remote/RevHTTP.hs` | PC27 | Low | Reverse HTTP for remote desktop | +| `src/Simplex/Chat/Remote/AppVersion.hs` | PC27 | Low | Remote version negotiation | +| `src/Simplex/Chat/ProfileGenerator.hs` | PC20 | Low | Random profile generation for incognito | +| `src/Simplex/Chat/Types/UITheme.hs` | PC24 | Low | Theme data types for UI customization | +| `src/Simplex/Chat/Types/Preferences.hs` | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | +| `src/Simplex/Chat/Types/Shared.hs` | PC3, PC16 | Medium | Shared types including GroupMemberRole | +| `src/Simplex/Chat/Types/MemberRelations.hs` | PC3, PC16, PC30 | Medium | Member relationship state machine | +| `src/Simplex/Chat/Operators.hs` | PC25 | Medium | Server operator management | +| `src/Simplex/Chat/Operators/Presets.hs` | PC25 | Low | Preset server operators | +| `src/Simplex/Chat/Operators/Conditions.hs` | PC25 | Low | Operator usage conditions | +| `src/Simplex/Chat/AppSettings.hs` | PC25 | Low | App settings sync types | +| `src/Simplex/Chat/Mobile.hs` | PC1 through PC31 | High | C FFI exports — JNI bridge target | +| `src/Simplex/Chat/Mobile/File.hs` | PC10 | Medium | Mobile file read/write FFI | +| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC31 | Medium | Shared FFI helpers | +| `src/Simplex/Chat/Mobile/WebRTC.hs` | PC17 | Low | WebRTC FFI helpers | +| `src/Simplex/Chat/View.hs` | PC1 through PC31 | Low | Terminal view rendering (not used by mobile/desktop UI) | +| `src/Simplex/Chat/Stats.hs` | PC25 | Low | Server statistics tracking | +| `src/Simplex/Chat/Util.hs` | — | Low | General Haskell utilities | +| `src/Simplex/Chat/Styled.hs` | — | Low | Terminal styled text (not used by mobile/desktop UI) | +| `src/Simplex/Chat/Help.hs` | — | Low | Terminal help text | +| `src/Simplex/Chat/Bot.hs` | — | Low | Chat bot framework | +| `src/Simplex/Chat/Bot/KnownContacts.hs` | — | Low | Bot known contacts | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md new file mode 100644 index 0000000000..bea1d37f3a --- /dev/null +++ b/apps/multiplatform/spec/services/calls.md @@ -0,0 +1,175 @@ +# WebRTC Calling Service + +## Table of Contents + +1. [Overview](#1-overview) +2. [Call State Machine](#2-call-state-machine) +3. [Android Implementation](#3-android-implementation) +4. [Desktop Implementation](#4-desktop-implementation) +5. [Common Call API](#5-common-call-api) +6. [IncomingCallAlertView](#6-incomingcallalertview) +7. [Source Files](#7-source-files) + +## Executive Summary + +WebRTC calling in SimpleX Chat operates over SMP (SimpleX Messaging Protocol) for signaling, with platform-specific WebRTC media implementations. Android uses a WebView-based approach with a dedicated `CallActivity` and foreground `CallService`, while Desktop opens the system browser and communicates via a NanoWSD WebSocket server on localhost. Both platforms share a common `CallManager` for call lifecycle and a `CallState` enum for state tracking. Call commands and responses are serialized as JSON and exchanged between the native layer and the WebRTC JavaScript layer. + +--- + +## 1. Overview + +Call signaling uses the same SMP protocol on all platforms -- call invitations, offers, answers, ICE candidates, and status updates flow through the chat backend via API commands. The WebRTC media plane, however, is implemented differently per platform: + +- **Android**: WebView loads `call.html` from bundled assets; a `@JavascriptInterface` bridge (`WebRTCInterface`) forwards JSON messages between Kotlin and JavaScript. +- **Desktop**: The system browser opens `http://localhost:50395/simplex/call/`; a NanoWSD HTTP+WebSocket server serves `call.html` from classpath resources and relays JSON commands/responses over WebSocket. + +Both platforms share the [`CallManager`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) class (119 lines), which orchestrates incoming call acceptance, call ending, and notification management. + +--- + + + +## 2. Call State Machine + +Defined in [`WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt#L50): + +``` +enum class CallState { + WaitCapabilities, // Call initiated, waiting for local WebRTC capabilities + InvitationSent, // Invitation sent to peer via SMP + InvitationAccepted, // Peer's invitation accepted locally + OfferSent, // SDP offer sent to peer + OfferReceived, // SDP offer received from peer + AnswerReceived, // SDP answer received from peer + Negotiated, // ICE negotiation in progress + Connected, // Media flowing + Ended; // Call terminated +} +``` + +**Outgoing call flow**: `WaitCapabilities` -> `InvitationSent` -> `OfferSent` -> `AnswerReceived` -> `Negotiated` -> `Connected` -> `Ended` + +**Incoming call flow**: `InvitationAccepted` -> `OfferReceived` -> `Negotiated` -> `Connected` -> `Ended` + +State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`). + +--- + + + +## 3. Android Implementation + +### 3.1 CallActivity.kt (464 lines) + +[`CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) + +A dedicated `ComponentActivity` that hosts the call UI. Key responsibilities: + +- **Intent handling** ([line 64](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L64)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`. +- **Lock screen support** ([line 160](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L160)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings. +- **Picture-in-Picture** ([line 99](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L99)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command. +- **Permission checks** ([line 122](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L122)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions. +- **Service binding** ([line 181](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L181)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions. +- **CallActivityView composable** ([line 208](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L208)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen. + +### 3.2 CallService.kt (207 lines) + +[`CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) + +An Android foreground `Service` that keeps the call alive when the app is backgrounded: + +- **Foreground notification** ([line 131](../../android/src/main/java/chat/simplex/app/CallService.kt#L131)): Shows contact name (respecting `NotificationPreviewMode`), call type (audio/video), a chronometer when connected, and an "End call" action button. +- **WakeLock** ([line 66](../../android/src/main/java/chat/simplex/app/CallService.kt#L66)): Acquires `PARTIAL_WAKE_LOCK` to prevent CPU sleep during calls. +- **Notification channel** ([line 121](../../android/src/main/java/chat/simplex/app/CallService.kt#L121)): Creates `CALL_NOTIFICATION_CHANNEL_ID` with `IMPORTANCE_DEFAULT`. +- **Foreground service type** ([line 100](../../android/src/main/java/chat/simplex/app/CallService.kt#L100)): Uses `MEDIA_PLAYBACK | MICROPHONE` (+ `CAMERA` for video) on API 30+, `REMOTE_MESSAGING` on API 34+ when no active call. +- **Binder** ([line 158](../../android/src/main/java/chat/simplex/app/CallService.kt#L158)): `CallServiceBinder` allows `CallActivity` to call `updateNotification()` when call state changes. +- **CallActionReceiver** ([line 170](../../android/src/main/java/chat/simplex/app/CallService.kt#L170)): `BroadcastReceiver` that handles the `EndCallAction` from the notification. + +### 3.3 CallView.android.kt (891 lines) + +[`CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) + +The `actual` platform implementation of `ActiveCallView()` and supporting composables: + +- **ActiveCallState** ([line 74](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L74)): Manages proximity lock (screen-off wake lock), `CallAudioDeviceManager` for audio routing (earpiece/speaker/bluetooth), `CallSoundsPlayer` for ringtones and vibration. Implements `Closeable` to clean up resources on call end. +- **ActiveCallView** ([line 114](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L114)): Renders `WebRTCView` plus `ActiveCallOverlay`. Handles `WCallResponse` messages and dispatches corresponding API calls. Manages volume control stream (`STREAM_VOICE_CALL`), screen keep-on, and call command lifecycle. +- **WebRTCView** ([line 691](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L691)): Creates/reuses a static `WebView` via `AndroidView`. Configures `WebViewAssetLoader` for local asset loading. Sets up `WebRTCInterface` JavaScript bridge. Loads `file:android_asset/www/android/call.html`. Processes `WCallCommand` queue by evaluating `processCommand()` JavaScript. +- **ActiveCallOverlayLayout** ([line 329](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L329)): Full overlay with mic toggle, speaker/device selector, end call, video toggle, and camera flip buttons. Adapts layout for video vs audio calls. +- **CallPermissionsView** ([line 569](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L569)): Handles runtime permission requests for microphone and camera with a fallback to settings if the system dialog is not shown. + +### 3.4 ActiveCallState + +[`ActiveCallState`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L74) (line 74 of `CallView.android.kt`): + +| Component | Purpose | +|---|---| +| `proximityLock` | `PROXIMITY_SCREEN_OFF_WAKE_LOCK` -- turns screen off when phone is held to ear | +| `callAudioDeviceManager` | Manages audio routing between earpiece, speaker, Bluetooth, wired headset | +| `CallSoundsPlayer` | Plays connecting/ringing sounds and vibration patterns | +| `wasConnected` | Tracks if call ever connected (for end-of-call vibration) | +| `close()` | Stops sounds, vibrates on disconnect, releases proximity lock, clears audio manager overrides | + +--- + +## 4. Desktop Implementation + +### 4.1 CallView.desktop.kt (263 lines) + +[`CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) + +Desktop calls run WebRTC in the system browser, not an embedded WebView: + +- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`. +- **WebSocket communication** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`. +- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Starts the server, then opens `http://localhost:/simplex/call/` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server. +- **SendStateUpdates** ([line 137](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L137)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display. +- **ActiveCallView** ([line 28](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L28)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance. + +--- + +## 5. Common Call API + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Line | Description | +|---|---|---| +| `apiGetCallInvitations` | [L1842](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1842) | Retrieve pending call invitations from the backend | +| `apiSendCallInvitation` | [L1849](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1849) | Send call invitation to a contact with `CallType` | +| `apiRejectCall` | [L1854](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | Reject an incoming call | +| `apiSendCallOffer` | [L1859](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | Send SDP offer with ICE candidates and capabilities | +| `apiSendCallAnswer` | [L1866](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | Send SDP answer with ICE candidates | +| `apiSendCallExtraInfo` | [L1872](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | Send additional ICE candidates discovered after initial exchange | +| `apiEndCall` | [L1878](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | Terminate a call | +| `apiCallStatus` | [L1883](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | Report WebRTC connection status to the backend | + +All functions send commands via `sendCmd()` to the chat core and return `Boolean` success status (except `apiGetCallInvitations` which returns `List`). + +--- + + + +## 6. IncomingCallAlertView + +[`IncomingCallAlertView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt) (128 lines) + +An in-app notification banner shown when a call invitation arrives while the app is in the foreground: + +- **IncomingCallAlertView** ([line 27](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L27)): Starts `SoundPlayer` for the ringtone (suppressed if already in a call view). Shows `IncomingCallAlertLayout`. +- **IncomingCallAlertLayout** ([line 49](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L49)): Colored banner with `ProfilePreview` of the caller, call type icon (audio/video), and three action buttons: Reject (red), Ignore (primary), Accept (green). +- **IncomingCallInfo** ([line 74](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L74)): Shows the user profile image (for multi-user), call media type icon, and call type text (encrypted/unencrypted audio/video). + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `CallView.kt` | [`common/src/commonMain/.../views/call/CallView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt) | 28 | `expect fun ActiveCallView()`, delivery receipt waiting | +| `CallView.android.kt` | [`common/src/androidMain/.../views/call/CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) | 891 | Android WebView WebRTC, overlay, permissions | +| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 263 | Desktop browser WebRTC via NanoWSD | +| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 464 | Android call Activity, PiP, lock screen | +| `CallService.kt` | [`android/src/main/java/.../CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) | 207 | Android foreground service for calls | +| `CallManager.kt` | [`common/src/commonMain/.../views/call/CallManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) | 119 | Call lifecycle management | +| `WebRTC.kt` | [`common/src/commonMain/.../views/call/WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt) | -- | `CallState` enum, `WCallCommand`, `WCallResponse` types | +| `IncomingCallAlertView.kt` | [`common/src/commonMain/.../views/call/IncomingCallAlertView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt) | 128 | In-app incoming call notification banner | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | Call API commands (L1837--L1881) | diff --git a/apps/multiplatform/spec/services/files.md b/apps/multiplatform/spec/services/files.md new file mode 100644 index 0000000000..329e37dbb1 --- /dev/null +++ b/apps/multiplatform/spec/services/files.md @@ -0,0 +1,213 @@ +# File Transfer Service + +## Table of Contents + +1. [Overview](#1-overview) +2. [File Size Constants](#2-file-size-constants) +3. [CryptoFile](#3-cryptofile) +4. [File Storage Paths](#4-file-storage-paths) +5. [API Commands](#5-api-commands) +6. [Auto-Receive Logic](#6-auto-receive-logic) +7. [Source Files](#7-source-files) + +## Executive Summary + +SimpleX Chat uses two file transfer mechanisms: inline SMP transfers for small files (embedded in message bodies) and XFTP (eXtended File Transfer Protocol) for larger files up to 1 GB. Files are optionally encrypted at rest using `CryptoFile` functions backed by the chat core's native crypto library. File storage paths are platform-specific: Android uses `Context.dataDir`-based directories while Desktop uses platform-appropriate data directories (XDG on Linux, AppData on Windows, Application Support on macOS). Auto-receive logic automatically accepts images, voice messages, and videos below configurable size thresholds. + +--- + +## 1. Overview + +File transfer decision logic: + +- **Inline (SMP)**: Files small enough to be base64-encoded and embedded directly in an SMP message body. The practical limit is defined by `MAX_IMAGE_SIZE` (255 KB) for compressed images. The maximum SMP inline size is `MAX_FILE_SIZE_SMP` (~7.6 MB). +- **XFTP**: For files exceeding the inline threshold, up to `MAX_FILE_SIZE_XFTP` (1 GB). XFTP uses dedicated file relay servers with chunked, encrypted transfers. + +The `receiveFile` / `receiveFiles` API commands handle both protocols transparently -- the chat core selects the appropriate transfer mechanism based on file metadata received from the sender. + +--- + + + +## 2. File Size Constants + +Defined in [`Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L118): + +| Constant | Value | Human-Readable | Line | Purpose | +|---|---|---|---|---| +| `MAX_IMAGE_SIZE` | 261,120 | 255 KB | [L118](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L118) | Inline image compression target | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 | 510 KB | [L119](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L119) | Auto-receive threshold for images (`2 * MAX_IMAGE_SIZE`) | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 | 510 KB | [L120](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L120) | Auto-receive threshold for voice messages (`2 * MAX_IMAGE_SIZE`) | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 | 1023 KB | [L121](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L121) | Auto-receive threshold for video | +| `MAX_VOICE_MILLIS_FOR_SENDING` | 300,000 | 5 min | [L123](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L123) | Maximum voice message duration | +| `MAX_FILE_SIZE_SMP` | 8,000,000 | ~7.6 MB | [L125](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L125) | Maximum SMP inline file size | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 | 1 GB | [L127](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L127) | Maximum XFTP transfer size | +| `MAX_FILE_SIZE_LOCAL` | `Long.MAX_VALUE` | Unlimited | [L129](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L129) | Local file protocol (no size limit) | + +The `getMaxFileSize()` function ([`Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L442)) selects the limit based on `FileProtocol`: + +```kotlin +FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP +FileProtocol.SMP -> MAX_FILE_SIZE_SMP +FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL +``` + +--- + +## 3. CryptoFile + +[`CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) (62 lines) + +Provides encrypted file I/O backed by the chat core's native cryptography (via JNI/JNA calls to `chatWriteFile`, `chatReadFile`, `chatEncryptFile`, `chatDecryptFile`). + +### Data types + +```kotlin +@Serializable +sealed class WriteFileResult { + @SerialName("result") data class Result(val cryptoArgs: CryptoFileArgs): WriteFileResult() + @SerialName("error") data class Error(val writeError: String): WriteFileResult() +} +``` + +`CryptoFileArgs` contains `fileKey` and `fileNonce` -- the symmetric encryption key and nonce for AES-GCM encryption. + + + +### Functions + +| Function | Line | Signature | Description | +|---|---|---|---| +| `writeCryptoFile` | [L24](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L24) | `(path: String, data: ByteArray): CryptoFileArgs` | Writes data to an encrypted file via a direct `ByteBuffer`. Returns the generated key and nonce. Requires initialized `ChatController`. | +| `readCryptoFile` | [L36](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L36) | `(path: String, cryptoArgs: CryptoFileArgs): ByteArray` | Reads and decrypts a file given its key and nonce. Returns the plaintext bytes. Throws on error (status != 0). | +| `encryptCryptoFile` | [L47](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L47) | `(fromPath: String, toPath: String): CryptoFileArgs` | Encrypts an existing plaintext file to a new encrypted file. Returns the generated key and nonce. | +| `decryptCryptoFile` | [L57](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L57) | `(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String)` | Decrypts an encrypted file to a plaintext output file. Throws on non-empty error string. | + +All functions delegate to native C library functions through the chat core JNI bridge. + +--- + + + +## 4. File Storage Paths + +### Common expect declarations + +[`Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) (191 lines, commonMain) + +| Property | Line | Description | +|---|---|---| +| `dataDir` | [L18](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L18) | Root application data directory | +| `tmpDir` | [L19](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L19) | Temporary files directory | +| `filesDir` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L20) | Base files directory | +| `appFilesDir` | [L21](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L21) | Application files (chat attachments) | +| `wallpapersDir` | [L22](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L22) | Theme wallpaper images | +| `coreTmpDir` | [L23](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L23) | Temporary files for the chat core | +| `dbAbsolutePrefixPath` | [L24](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L24) | Database file path prefix | +| `preferencesDir` | [L25](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L25) | Preferences/config directory | +| `databaseExportDir` | [L35](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L35) | Temporary DB archive storage for export | +| `remoteHostsDir` | [L37](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L37) | Remote host connection data | + +### Android implementation + +[`Files.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt) (79 lines) + +| Property | Value | +|---|---| +| `dataDir` | `androidAppContext.dataDir` | +| `tmpDir` | `androidAppContext.getDir("temp", MODE_PRIVATE)` | +| `filesDir` | `dataDir/files` | +| `appFilesDir` | `dataDir/files/app_files` | +| `wallpapersDir` | `dataDir/files/assets/wallpapers` | +| `coreTmpDir` | `dataDir/files/temp_files` | +| `dbAbsolutePrefixPath` | `dataDir/files` | +| `preferencesDir` | `dataDir/shared_prefs` | +| `databaseExportDir` | `androidAppContext.cacheDir` | +| `remoteHostsDir` | `tmpDir/remote_hosts` | + +### Desktop implementation + +[`Files.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt) (116 lines) + +| Property | Value | +|---|---| +| `dataDir` | `desktopPlatform.dataPath` (XDG_DATA_HOME on Linux, AppData on Windows, Application Support on macOS) | +| `tmpDir` | `java.io.tmpdir/simplex` (deleted on exit) | +| `filesDir` | `dataDir/simplex_v1_files` | +| `appFilesDir` | Same as `filesDir` | +| `wallpapersDir` | `dataDir/simplex_v1_assets/wallpapers` | +| `coreTmpDir` | `dataDir/tmp` | +| `dbAbsolutePrefixPath` | `dataDir/simplex_v1` | +| `preferencesDir` | `desktopPlatform.configPath` | +| `databaseExportDir` | Same as `tmpDir` | +| `remoteHostsDir` | `dataDir/remote_hosts` | + +### Helper functions (common) + +| Function | Line | Description | +|---|---|---| +| `getAppFilePath` | [L81](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L81) | Resolves file path considering remote hosts | +| `getWallpaperFilePath` | [L91](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L91) | Resolves wallpaper image path, creates parent directories | +| `getLoadedFilePath` | [L105](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L105) | Returns path if file exists and is fully loaded | +| `getLoadedFileSource` | [L115](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L115) | Returns `CryptoFile` source if file is loaded | +| `readThemeOverrides` | [L125](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L125) | Reads theme overrides from `themes.yaml` | +| `writeThemeOverrides` | [L151](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L151) | Atomically writes theme overrides to `themes.yaml` | +| `copyFileToFile` | [L47](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L47) | Copies a `File` to a `URI` destination with toast feedback | +| `copyBytesToFile` | [L63](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L63) | Copies a `ByteArrayInputStream` to a `URI` destination | + +--- + +## 5. API Commands + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Line | Signature | Description | +|---|---|---|---| +| `receiveFiles` | [L1946](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1946) | `(rhId, user, fileIds, userApprovedRelays, auto)` | Receive multiple files. Sends `CC.ReceiveFile` for each ID. Handles relay approval workflow: collects unapproved files, shows alert, re-calls with `userApprovedRelays=true`. Respects `privacyEncryptLocalFiles` preference. | +| `receiveFile` | [L2062](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2062) | `(rhId, user, fileId, userApprovedRelays, auto)` | Delegates to `receiveFiles` with a single-element list. | +| `cancelFile` | [L2072](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2072) | `(rh, user, fileId)` | Cancels an in-progress file transfer (send or receive). Cleans up the local file. | +| `apiCancelFile` | [L2080](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2080) | `(rh, fileId, ctrl?)` | Low-level cancel. Returns `AChatItem?` on success (`SndFileCancelled` or `RcvFileCancelled`). | +| `uploadStandaloneFile` | [L1916](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1916) | `(user, file, ctrl?)` | Upload a standalone file (for database migration). Returns `FileTransferMeta?` with XFTP link. | +| `downloadStandaloneFile` | [L1926](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1926) | `(user, url, file, ctrl?)` | Download a standalone file from an XFTP URL. Returns `RcvFileTransfer?`. | + +--- + +## 6. Auto-Receive Logic + +Located in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2696) within the `CR.NewChatItems` handler: + +```kotlin +if (file != null && + appPrefs.privacyAcceptImages.get() && + ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) + || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) + || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV + && file.fileStatus !is CIFileStatus.RcvAccepted)) +) { + receiveFile(rhId, r.user, file.fileId, auto = true) +} +``` + +**Conditions for auto-receive:** + +1. The `privacyAcceptImages` preference is enabled (user opt-in). +2. The content type and size match one of: + - **Images** (`MCImage`): file size <= 510 KB (`MAX_IMAGE_SIZE_AUTO_RCV`) + - **Video** (`MCVideo`): file size <= 1023 KB (`MAX_VIDEO_SIZE_AUTO_RCV`) + - **Voice** (`MCVoice`): file size <= 510 KB (`MAX_VOICE_SIZE_AUTO_RCV`) AND file is not already accepted +3. The file has a non-null `file` attachment. + +When `auto = true`, relay approval alerts are suppressed (the file is silently received). + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `CryptoFile.kt` | [`common/src/commonMain/.../model/CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) | 62 | Encrypted file read/write via native crypto | +| `Files.kt` | [`common/src/commonMain/.../platform/Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | 191 | Common file path declarations, theme I/O, file helpers | +| `Files.android.kt` | [`common/src/androidMain/.../platform/Files.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt) | 79 | Android file path implementations | +| `Files.desktop.kt` | [`common/src/desktopMain/.../platform/Files.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt) | 116 | Desktop file path implementations | +| `Utils.kt` | [`common/src/commonMain/.../views/helpers/Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt) | -- | File size constants (L117--L128), `getMaxFileSize()` (L442) | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | File transfer API commands (L1911--L2085), auto-receive (L2690) | diff --git a/apps/multiplatform/spec/services/notifications.md b/apps/multiplatform/spec/services/notifications.md new file mode 100644 index 0000000000..6ce4bc9dc1 --- /dev/null +++ b/apps/multiplatform/spec/services/notifications.md @@ -0,0 +1,261 @@ +# Notification System + +## Table of Contents + +1. [Overview](#1-overview) +2. [NtfManager Abstract Class](#2-ntfmanager-abstract-class) +3. [Android Notification Manager](#3-android-notification-manager) +4. [Desktop Notification Manager](#4-desktop-notification-manager) +5. [Android Background Messaging](#5-android-background-messaging) +6. [Notification Privacy](#6-notification-privacy) +7. [Source Files](#7-source-files) + +## Executive Summary + +SimpleX Chat uses platform-specific notification strategies. The common `NtfManager` abstract class defines the notification contract with shared helper methods for message, contact, and call notifications. Android implements a full notification system with channels, grouped summaries, full-screen call intents, and a foreground service (`SimplexService`) or periodic `WorkManager` tasks for background message fetching. Desktop uses the TwoSlices library (with OS-native fallbacks) for system notifications. Notification privacy is controlled via `NotificationPreviewMode` (MESSAGE, CONTACT, HIDDEN). + +--- + +## 1. Overview + +Notifications serve three purposes in SimpleX Chat: + +1. **Message notifications** -- alert users to new messages when the app is not focused on the relevant chat. +2. **Call notifications** -- high-priority alerts for incoming WebRTC calls, with full-screen intent support on Android for lock-screen scenarios. +3. **Contact events** -- notifications for contact connection and contact request events. + +The architecture uses an abstract `NtfManager` in common code with platform-specific `actual` implementations. On Android, background message delivery requires a foreground service or periodic WorkManager tasks since SimpleX does not use push notifications (no Firebase/APNs dependency for privacy). + +--- + + + + +## 2. NtfManager Abstract Class + +[`NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) (139 lines, commonMain) + +The global `ntfManager` instance is declared at [line 17](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L17) and initialized by each platform at startup. + +### Concrete methods + +| Method | Line | Description | +|---|---|---| +| `notifyContactConnected` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | Displays "contact connected" notification for a `Contact` | +| `notifyContactRequestReceived` | [L27](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L27) | Shows contact request notification with an "Accept" action button | +| `notifyMessageReceived` | [L38](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L38) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat | +| `acceptContactRequestAction` | [L51](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L51) | Accepts a contact request from a notification action | +| `openChatAction` | [L59](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L59) | Opens a specific chat from a notification tap, switching user if needed | +| `showChatsAction` | [L74](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L74) | Opens the chat list, switching user if needed | +| `acceptCallAction` | [L88](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L88) | Accepts a call invitation from a notification action | + +### Abstract methods + +| Method | Line | Description | +|---|---|---| +| `notifyCallInvitation` | [L98](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L98) | Show call notification; returns `true` if notification was shown | +| `displayNotification` | [L102](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L102) | Display a message notification with optional image and action buttons | +| `cancelCallNotification` | [L103](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L103) | Cancel the active call notification | +| `hasNotificationsForChat` | [L99](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L99) | Check if notifications exist for a given chat | +| `cancelNotificationsForChat` | [L100](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L100) | Cancel all notifications for a specific chat | +| `cancelNotificationsForUser` | [L101](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L101) | Cancel all notifications for a user profile | +| `cancelAllNotifications` | [L104](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L104) | Cancel all notifications | +| `showMessage` | [L105](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L105) | Show a simple title+text notification | +| `androidCreateNtfChannelsMaybeShowAlert` | [L107](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L107) | Android-only: create notification channels (triggers permission prompt on Android 13+) | + +### Private helpers + +- `awaitChatStartedIfNeeded` ([line 109](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L109)): Waits up to 30 seconds for chat initialization (handles database decryption delay). +- `hideSecrets` ([line 122](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L122)): Replaces `Format.Secret` formatted text with `"..."` in notification previews. + +--- + +## 3. Android Notification Manager + +[`NtfManager.android.kt`](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt) (331 lines) + +Implemented as a Kotlin `object` (singleton) in the Android module. + +### Notification channels + +| Channel | Constant | Importance | Purpose | +|---|---|---|---| +| Messages | `MessageChannel` (`chat.simplex.app.MESSAGE_NOTIFICATION`) | HIGH | All chat message notifications | +| Calls | `CallChannel` (`chat.simplex.app.CALL_NOTIFICATION_2`) | HIGH | Incoming call alerts with custom ringtone and vibration | + +Channel creation happens in `createNtfChannelsMaybeShowAlert()` ([line 298](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L298)). Old channel IDs (`CALL_NOTIFICATION`, `CALL_NOTIFICATION_1`, `LOCK_SCREEN_CALL_NOTIFICATION`) are explicitly deleted. + +### displayNotification (messages) + +[Line 102](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L102): + +- Uses `NotificationCompat.Builder` with `MessageChannel`. +- Groups notifications using `MessageGroup` with `GROUP_ALERT_CHILDREN` behavior. +- Applies rate limiting: silent mode if notification for the same `(userId, chatId)` was shown within 30 seconds (`msgNtfTimeoutMs`). +- Creates a group summary notification ([line 142](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L142)) with `setGroupSummary(true)`. +- Content intent uses `TaskStackBuilder` for proper back stack. +- Supports `NotificationAction.ACCEPT_CONTACT_REQUEST` action buttons via `NtfActionReceiver` broadcast receiver. + +### notifyCallInvitation + +[Line 160](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L160): + +- Returns `false` (no notification) if app is in foreground -- in-app alert is used instead. +- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`, plus `VISIBILITY_PUBLIC`. +- **Foreground / unlocked**: Uses regular notification with Accept/Reject action buttons and a custom ringtone (`ring_once` raw resource). +- Notification flags include `FLAG_INSISTENT` for repeating sound and vibration. +- Call notification channel vibration pattern: `[250, 250, 0, 2600]` ms. + +### Cancel operations + +| Method | Line | Description | +|---|---|---| +| `cancelNotificationsForChat` | [L75](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L75) | Cancels by `chatId.hashCode()`, cleans up group summary if no children remain | +| `cancelNotificationsForUser` | [L88](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L88) | Iterates and cancels all notifications for a given `userId` | +| `cancelCallNotification` | [L261](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L261) | Cancels the singleton call notification (`CallNotificationId = -1`) | +| `cancelAllNotifications` | [L265](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L265) | Cancels all via `NotificationManager.cancelAll()` | + +### NtfActionReceiver + +[Line 311](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L311): A `BroadcastReceiver` that handles notification action intents: +- `ACCEPT_CONTACT_REQUEST` -- calls `ntfManager.acceptContactRequestAction()` +- `RejectCallAction` -- calls `callManager.endCall()` on the invitation + +--- + +## 4. Desktop Notification Manager + +[`NtfManager.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt) (193 lines) + +Implemented as a Kotlin `object` using the [TwoSlices](https://github.com/sshtools/two-slices) library (`Toast` builder API) for cross-platform desktop notifications. + +### displayNotification + +[Line 97](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L97): + +- Suppresses if `!user.showNotifications`. +- Respects `NotificationPreviewMode` for title and content. +- Calls `displayNotificationViaLib()` ([line 114](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L114)) which builds a `Toast` with title, content, icon, action buttons, and default action. +- Icon images are written to a temporary PNG file via `prepareIconPath()` ([line 150](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L150)). +- Default action on click opens the relevant chat via `openChatAction()`. + +### notifyCallInvitation + +[Line 22](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L22): + +- Returns `false` if the SimpleX window is focused (in-app alert used instead). +- Creates a notification with Accept and Reject action buttons. +- Default click action opens the chat. + +### OS-native fallbacks + +[Line 162](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L162): The `displayNotification` private method dispatches based on `desktopPlatform`: + +| Platform | Method | +|---|---| +| Linux | `notify-send` command with optional `-i` icon | +| Windows | `SystemTray` with `TrayIcon.displayMessage()` | +| macOS | `osascript -e 'display notification ...'` | + +### Notification tracking + +Previous notifications are tracked in `prevNtfs: ArrayList, Slice>>` with a `Mutex` for thread safety. Cancel operations remove entries from this list. + +--- + +## 5. Android Background Messaging + +### 5.1 SimplexService.kt (734 lines) + +[`SimplexService.kt`](../../android/src/main/java/chat/simplex/app/SimplexService.kt) + +A foreground `Service` that keeps the app process alive for continuous message receiving. This is SimpleX's privacy-preserving alternative to push notifications. + +**Service lifecycle:** + +- `startService()` ([line 128](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L128)): Waits for database migration, validates DB status, saves service state as STARTED. WakeLock acquisition is commented out -- the app relies on battery optimization whitelisting instead. +- `onDestroy()` ([line 87](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L87)): Releases wakelocks, saves state as STOPPED, sends broadcast to `AutoRestartReceiver` if allowed. +- `onTaskRemoved()` ([line 211](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L211)): Schedules restart via `AlarmManager` when the app is swiped from recents. + +**Notification:** + +- Channel: `SIMPLEX_SERVICE_NOTIFICATION` with `IMPORTANCE_LOW` and badge disabled ([line 165](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L165)). +- Shows a persistent notification with a "Hide notification" action that opens channel settings. +- Service ID: `6789`. + +**Restart mechanisms:** + +| Receiver | Line | Trigger | +|---|---|---| +| `StartReceiver` | [L234](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L234) | Device boot (`BOOT_COMPLETED`) | +| `AutoRestartReceiver` | [L253](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L253) | Service destruction | +| `AppUpdateReceiver` | [L261](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L261) | App update (`MY_PACKAGE_REPLACED`) | +| `ServiceStartWorker` | [L283](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L283) | WorkManager one-time task | + +**Battery optimization:** + +- `isBackgroundAllowed()` ([line 681](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L681)): Checks both `isIgnoringBatteryOptimizations` and `!isBackgroundRestricted`. +- `showBackgroundServiceNoticeIfNeeded()` ([line 430](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L430)): Shows alerts guiding users to disable battery optimization or background restriction. Includes Xiaomi-specific guidance. +- `disableNotifications()` ([line 722](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L722)): Switches mode to OFF, disables receivers, cancels workers. + +### 5.2 MessagesFetcherWorker.kt (100 lines) + +[`MessagesFetcherWorker.kt`](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt) + +A `CoroutineWorker` used in `PERIODIC` notification mode as an alternative to the persistent foreground service: + +- `scheduleWork()` ([line 18](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt#L18)): Schedules a `OneTimeWorkRequest` with a default 600-second (10 minute) initial delay and 60-second duration. Requires `NetworkType.CONNECTED` constraint. +- `doWork()` ([line 53](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt#L53)): Skips if `SimplexService` is already running. Initializes chat controller if needed (self-destruct mode). Waits for DB migration. Runs for up to `durationSec` seconds, polling every 5 seconds until no messages have been received for 10 seconds (`WAIT_AFTER_LAST_MESSAGE`). +- Self-rescheduling: Always calls `reschedule()` at the end (creating a chain of one-time tasks that simulate periodic execution). + + + +### 5.3 Notification modes + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7739): + +```kotlin +enum class NotificationsMode { + OFF, // No background message fetching + PERIODIC, // WorkManager periodic tasks (MessagesFetcherWorker) + SERVICE; // Persistent foreground service (SimplexService) +} +``` + +Default is `SERVICE`. The `requiresIgnoringBattery` property is an Android extension property (defined in `Extensions.kt`, not on the enum itself) whose value depends on the SDK version: `SERVICE` requires ignoring battery optimizations since SDK S (API 31), `PERIODIC` since SDK M (API 23). + +--- + +## 6. Notification Privacy + +Defined in [`ChatModel.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L4823): + +```kotlin +enum class NotificationPreviewMode { + MESSAGE, // Show sender name and message text + CONTACT, // Show sender name, generic "new message" text + HIDDEN; // Show "Somebody" as sender, generic "new message" text +} +``` + +Privacy mode affects: +- **Notification title**: `HIDDEN` uses `"Somebody"` instead of contact name. +- **Notification content**: Only `MESSAGE` mode shows actual message text. +- **Large icon**: `HIDDEN` uses the app icon instead of the contact's profile image. +- **Call notifications**: `HIDDEN` hides the caller's name and profile image. + +Both Android and Desktop implementations check `appPreferences.notificationPreviewMode.get()` before constructing notification content. + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `NtfManager.kt` | [`common/src/commonMain/.../platform/NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) | 139 | Abstract notification manager with shared logic | +| `NtfManager.android.kt` | [`android/src/main/java/.../model/NtfManager.android.kt`](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt) | 331 | Android notification channels, groups, call intents | +| `NtfManager.desktop.kt` | [`common/src/desktopMain/.../model/NtfManager.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt) | 193 | Desktop notifications via TwoSlices/OS-native | +| `SimplexService.kt` | [`android/src/main/java/.../SimplexService.kt`](../../android/src/main/java/chat/simplex/app/SimplexService.kt) | 734 | Android foreground service for background messaging | +| `MessagesFetcherWorker.kt` | [`android/src/main/java/.../MessagesFetcherWorker.kt`](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt) | 100 | WorkManager periodic message fetcher | +| `ChatModel.kt` | [`common/src/commonMain/.../model/ChatModel.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | -- | `NotificationPreviewMode` enum (L4823) | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | `NotificationsMode` enum (L7739) | diff --git a/apps/multiplatform/spec/services/theme.md b/apps/multiplatform/spec/services/theme.md new file mode 100644 index 0000000000..e5839fc193 --- /dev/null +++ b/apps/multiplatform/spec/services/theme.md @@ -0,0 +1,498 @@ +# Theme Engine + +## Table of Contents + +1. [Overview](#1-overview) +2. [ThemeManager](#2-thememanager) +3. [Default Themes](#3-default-themes) +4. [Theme Types](#4-theme-types) +5. [Color System](#5-color-system) +6. [SimpleXTheme Composable](#6-simplextheme-composable) +7. [Platform Theme](#7-platform-theme) +8. [YAML Import/Export](#8-yaml-importexport) +9. [Source Files](#9-source-files) + +## Executive Summary + +The SimpleX Chat theme engine implements a four-level cascade: per-chat theme overrides take precedence over per-user overrides, which take precedence over global (app-settings) overrides, which take precedence over built-in presets. Four preset themes exist (LIGHT, DARK, SIMPLEX, BLACK), each defining a Material `Colors` palette and custom `AppColors` for chat-specific elements. Themes support wallpaper customization (preset patterns or custom images) with background and tint color overrides. Theme configuration is persisted as YAML and can be imported/exported. The `SimpleXTheme` composable wraps `MaterialTheme` with additional `CompositionLocal` providers for app colors and wallpaper. + +--- + +## 1. Overview + +Theme resolution follows a priority chain: + +``` +per-chat override > per-user override > global override > preset default +``` + +At each level, individual color properties can be overridden. Unspecified properties fall through to the next level. The resolution is performed by `ThemeManager.currentColors()`, which merges all levels into a single `ActiveTheme` containing Material `Colors`, `AppColors`, and `AppWallpaper`. + +Wallpapers follow the same cascade, with additional support for preset wallpapers (built-in patterns like `SCHOOL`) and custom images. Wallpaper presets can define their own color overrides that sit between the global override and the base preset. + +--- + +## 2. ThemeManager + +[`ThemeManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) (241 lines) + +A singleton `object` that manages theme state, persistence, and resolution. + +### Core resolution + + + +**`currentColors()`** ([line 57](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L57)): + +```kotlin +fun currentColors( + themeOverridesForType: WallpaperType?, + perChatTheme: ThemeModeOverride?, + perUserTheme: ThemeModeOverrides?, + appSettingsTheme: List +): ActiveTheme +``` + +This is the core resolution function. It: +1. Determines the non-system theme name (resolving `SYSTEM` to light or dark based on `systemInDarkThemeCurrently`). +2. Selects the base theme palette (LIGHT/DARK/SIMPLEX/BLACK). +3. Finds the matching `ThemeOverrides` from `appSettingsTheme` based on wallpaper type and theme name. +4. Selects the `perUserTheme` for the current light/dark mode. +5. Resolves wallpaper preset colors if applicable. +6. Merges all color layers via `toColors()`, `toAppColors()`, and `toAppWallpaper()`. + +Returns `ActiveTheme(name, base, colors, appColors, wallpaper)`. + +### Theme application + +**`applyTheme()`** ([line 105](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L105)): + +Persists the theme name, recalculates `CurrentColors`, and updates Android system bar appearance: + +```kotlin +fun applyTheme(theme: String) { + if (appPrefs.currentTheme.get() != theme) { + appPrefs.currentTheme.set(theme) + } + CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) + platform.androidSetNightModeIfSupported() + val c = CurrentColors.value.colors + platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight) +} +``` + +**`changeDarkTheme()`** ([line 115](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L115)): + +Sets the dark mode variant (DARK, SIMPLEX, or BLACK) and recalculates colors. + +### Color and wallpaper modification + +**`saveAndApplyThemeColor()`** ([line 120](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L120)): + +Persists a single color change to the global theme overrides: +1. Gets or creates `ThemeOverrides` for the current base theme. +2. Calls `withUpdatedColor()` to update the specific `ThemeColor`. +3. Updates `currentThemeIds` mapping. +4. Recalculates `CurrentColors`. + +**`applyThemeColor()`** ([line 132](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L132)): + +In-memory-only color change (for per-chat/per-user theme editing before save). + +**`saveAndApplyWallpaper()`** ([line 136](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L136)): + +Persists wallpaper type change. Finds or creates matching `ThemeOverrides` (matching by wallpaper type + theme name), updates the wallpaper, and persists. + +### Reset + +**`resetAllThemeColors()` (global)** ([line 204](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L204)): + +Resets all custom colors in the current global theme override to defaults. Preserves wallpaper but clears its background and tint overrides. + +**`resetAllThemeColors()` (per-chat/per-user)** ([line 213](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L213)): + +In-memory reset of a `ThemeModeOverride` state. + +### Import/Export + +**`saveAndApplyThemeOverrides()`** ([line 188](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L188)): + +Imports a complete `ThemeOverrides` (from YAML). Handles wallpaper image import (base64 to file), replaces existing override for the same type, and applies. + +**`currentThemeOverridesForExport()`** ([line 92](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L92)): + +Exports the fully resolved current theme as a `ThemeOverrides` with all colors filled and wallpaper image embedded as base64. + +### Utility + +**`colorFromReadableHex()`** ([line 224](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L224)): + +Parses `#AARRGGBB` hex string to `Color`. + +**`toReadableHex()`** ([line 227](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L227)): + +Converts `Color` to `#AARRGGBB` hex string with intelligent alpha handling. + +--- + + + +## 3. Default Themes + +[`Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L26): + +```kotlin +enum class DefaultTheme { + LIGHT, DARK, SIMPLEX, BLACK; + + companion object { + const val SYSTEM_THEME_NAME: String = "SYSTEM" + } +} +``` + +| Theme | `mode` | Description | +|---|---|---| +| `LIGHT` | LIGHT | Standard light theme with white/light gray surfaces | +| `DARK` | DARK | Standard dark theme with dark gray surfaces | +| `SIMPLEX` | DARK | SimpleX branded dark theme with deep blue background and cyan accent | +| `BLACK` | DARK | AMOLED-optimized pure black theme | + +`SYSTEM` is a virtual theme name that resolves to LIGHT or the configured dark variant at runtime. + +`DefaultThemeMode` ([line 46](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L46)): `LIGHT` or `DARK`, serialized as `"light"` / `"dark"`. + +--- + +## 4. Theme Types + + + +### AppColors (line 53) + +[`Theme.kt` L53](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L53): + +```kotlin +@Stable +class AppColors( + title: Color, + primaryVariant2: Color, + sentMessage: Color, + sentQuote: Color, + receivedMessage: Color, + receivedQuote: Color, +) +``` + +Mutable state properties (for efficient recomposition) representing chat-specific colors not covered by Material's `Colors`. + + + +### AppWallpaper (line 106) + +[`Theme.kt` L106](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L106): + +```kotlin +@Stable +class AppWallpaper( + background: Color? = null, + tint: Color? = null, + type: WallpaperType = WallpaperType.Empty, +) +``` + +Represents the active wallpaper state with optional background color, tint overlay, and wallpaper type (Empty, Preset, or Image). + + + +### ThemeColor (line 140) + +Enum of all customizable color slots: + +`PRIMARY`, `PRIMARY_VARIANT`, `SECONDARY`, `SECONDARY_VARIANT`, `BACKGROUND`, `SURFACE`, `TITLE`, `SENT_MESSAGE`, `SENT_QUOTE`, `RECEIVED_MESSAGE`, `RECEIVED_QUOTE`, `PRIMARY_VARIANT2`, `WALLPAPER_BACKGROUND`, `WALLPAPER_TINT` + +Each has a `fromColors()` method to extract the current value and a `text` property for UI display. + + + +### ThemeColors (line 183) + +[`Theme.kt` L183](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L183): + +Serializable data class with optional hex color strings for each slot. Uses `@SerialName` annotations for YAML compatibility (`accent` for `primary`, `accentVariant` for `primaryVariant`, `menus` for `surface`, etc.). + + + +### ThemeWallpaper (line 224) + +[`Theme.kt` L224](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L224): + +```kotlin +@Serializable +data class ThemeWallpaper( + val preset: String? = null, // Preset wallpaper name + val scale: Float? = null, // Wallpaper scale factor + val scaleType: WallpaperScaleType? = null, // Fill/fit mode + val background: String? = null, // Background color hex + val tint: String? = null, // Tint overlay color hex + val image: String? = null, // Base64-encoded image (for import/export) + val imageFile: String? = null, // Local image file name +) +``` + +Key methods: +- `toAppWallpaper()`: Converts to runtime `AppWallpaper`. +- `withFilledWallpaperBase64()`: Embeds the image as base64 for export. +- `importFromString()`: Saves a base64 image to disk and returns a copy with `imageFile` set. +- `from(type, background, tint)`: Factory from `WallpaperType`. + + + +### ThemeOverrides (line 304) + +[`Theme.kt` L304](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L304): + +```kotlin +@Serializable +data class ThemeOverrides( + val themeId: String = UUID.randomUUID().toString(), + val base: DefaultTheme, + val colors: ThemeColors = ThemeColors(), + val wallpaper: ThemeWallpaper? = null, +) +``` + +A complete theme override entry. Multiple can coexist (one per wallpaper type per base theme). The `themeId` is a UUID for identity tracking. Key methods: +- `isSame(type, themeName)`: Matches by wallpaper type and base theme. +- `withUpdatedColor(name, color)`: Returns a copy with one color changed. +- `toColors()`, `toAppColors()`, `toAppWallpaper()`: Merge with base theme and per-user/per-chat overrides. + + + +### ThemeModeOverrides (line 475) + +[`Theme.kt` L475](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L475): + +```kotlin +@Serializable +data class ThemeModeOverrides( + val light: ThemeModeOverride? = null, + val dark: ThemeModeOverride? = null, +) +``` + +Container for per-user or per-chat overrides, with separate light and dark mode variants. Stored on the `User` model as `uiThemes`. + + + +### ThemeModeOverride (line 487) + +[`Theme.kt` L487](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L487): + +```kotlin +@Serializable +data class ThemeModeOverride( + val mode: DefaultThemeMode = CurrentColors.value.base.mode, + val colors: ThemeColors = ThemeColors(), + val wallpaper: ThemeWallpaper? = null, +) +``` + +A single mode's override with colors and wallpaper. Has `withUpdatedColor()` and `removeSameColors()` (strips colors that match base defaults). + +--- + +## 5. Color System + +Four built-in color palettes, each consisting of a Material `Colors` and an `AppColors`: + +### DarkColorPalette ([line 634](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L634)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `SimplexBlue` | `#0088ff` | +| `surface` | `#222222` | | +| `sentMessage` | `#18262E` | Dark blue-gray | +| `receivedMessage` | `#262627` | Neutral dark | + +### LightColorPalette ([line 656](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L656)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `SimplexBlue` | `#0088ff` | +| `surface` | `White` | | +| `sentMessage` | `#E9F7FF` | Light blue | +| `receivedMessage` | `#F5F5F6` | Near-white | + +### SimplexColorPalette ([line 678](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L678)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `#70F0F9` | Cyan | +| `primaryVariant` | `#1298A5` | Dark cyan | +| `background` | `#111528` | Deep navy | +| `surface` | `#121C37` | Dark navy | +| `title` | `#267BE5` | Blue | + +### BlackColorPalette ([line 701](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L701)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `#0077E0` | Darker blue | +| `background` | `#070707` | Near-black | +| `surface` | `#161617` | Very dark | +| `sentMessage` | `#18262E` | Same as Dark | +| `receivedMessage` | `#1B1B1B` | Very dark | + +--- + + + +## 6. SimpleXTheme Composable + +[`Theme.kt` line 773](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L773): + +```kotlin +@Composable +fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) +``` + +The root theme composable that wraps all app content: + +1. **System dark mode tracking** ([line 781](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L781)): Uses `snapshotFlow` on `isSystemInDarkTheme()` to call `reactOnDarkThemeChanges()` when the system theme changes. This triggers `ThemeManager.applyTheme(SYSTEM)` if the app is in system theme mode. + +2. **User theme tracking** ([line 790](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L790)): Monitors `chatModel.currentUser.value?.uiThemes` and re-applies the theme when the active user changes. + +3. **MaterialTheme wrapping** ([line 797](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L797)): Provides `theme.colors` to `MaterialTheme`, plus custom `CompositionLocal` providers: + - `LocalContentColor` -- set to `MaterialTheme.colors.onBackground` + - `LocalAppColors` -- the `AppColors` instance (remembered and updated) + - `LocalAppWallpaper` -- the `AppWallpaper` instance (remembered and updated) + - `LocalDensity` -- scaled by `desktopDensityScaleMultiplier` and `fontSizeMultiplier` + +4. **`SimpleXThemeOverride`** ([line 825](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L825)): A variant that accepts an explicit `ActiveTheme` for per-chat theme previews and overlays. + +### CompositionLocal access + +```kotlin +val MaterialTheme.appColors: AppColors // via LocalAppColors +val MaterialTheme.wallpaper: AppWallpaper // via LocalAppWallpaper +``` + +### Global state + + + +`CurrentColors` ([line 727](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L727)): A `MutableStateFlow` that holds the current resolved theme. Updated by `ThemeManager.applyTheme()` and collected by `SimpleXTheme`. + +`systemInDarkThemeCurrently` ([line 724](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L724)): Tracks the current system dark mode state. + +--- + +## 7. Platform Theme + +### isSystemInDarkTheme + +**Android** ([`Theme.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Theme.android.kt)): + +```kotlin +@Composable +actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme() +``` + +Delegates to the standard Compose function which reads `Configuration.uiMode`. + +**Desktop** ([`Theme.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt)): + +```kotlin +private val detector: OsThemeDetector = OsThemeDetector.getDetector() + .apply { registerListener(::reactOnDarkThemeChanges) } + +@Composable +actual fun isSystemInDarkTheme(): Boolean = try { + detector.isDark +} catch (e: Exception) { + false // Fallback for macOS exceptions +} +``` + +Uses the [jSystemThemeDetector](https://github.com/Dansoftowner/jSystemThemeDetector) library (`OsThemeDetector`). The detector also registers a listener that calls `reactOnDarkThemeChanges()` proactively when the OS theme changes, ensuring the app responds even outside of composition. + +### reactOnDarkThemeChanges + +[`Theme.kt` line 763](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L763): + +```kotlin +fun reactOnDarkThemeChanges(isDark: Boolean) { + systemInDarkThemeCurrently = isDark + if (appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME + && CurrentColors.value.colors.isLight == isDark) { + ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) + } +} +``` + +Only triggers a theme switch if the app is in SYSTEM mode and the current light/dark state disagrees with the OS. + +--- + +## 8. YAML Import/Export + +Theme overrides are persisted in `themes.yaml` (located in `preferencesDir`). + +### readThemeOverrides + +[`Files.kt` line 125](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L125): + +```kotlin +fun readThemeOverrides(): List +``` + +1. Reads `themes.yaml` from `preferencesDir`. +2. Parses the YAML node tree. +3. Extracts the `themes` list. +4. Deserializes each entry as `ThemeOverrides`, skipping entries that fail to parse (with error logging). +5. Calls `skipDuplicates()` to remove entries with the same type+base combination. + +### writeThemeOverrides + +[`Files.kt` line 151](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L151): + +```kotlin +fun writeThemeOverrides(overrides: List): Boolean +``` + +1. Serializes `ThemesFile(themes = overrides)` to YAML string. +2. Writes to a temporary file in `preferencesTmpDir`. +3. Atomically moves the temp file to `themes.yaml` using `Files.move` with `REPLACE_EXISTING`. +4. Thread-safe via `synchronized(lock)`. + +### YAML format + +```yaml +themes: + - themeId: "uuid-string" + base: "LIGHT" + colors: + accent: "#ff0088ff" + background: "#ffffffff" + sentMessage: "#ffe9f7ff" + wallpaper: + preset: "school" + scale: 1.0 + background: "#ccffffff" + tint: "#22000000" +``` + +Uses the [kaml](https://github.com/charleskorn/kaml) YAML library for serialization. `ThemeColors` uses `@SerialName` annotations for cross-platform YAML key compatibility (e.g., `accent` for `primary`, `menus` for `surface`). + +--- + +## 9. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `ThemeManager.kt` | [`common/src/commonMain/.../ui/theme/ThemeManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) | 241 | Theme resolution, persistence, color/wallpaper management | +| `Theme.kt` | [`common/src/commonMain/.../ui/theme/Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt) | 848 | Type definitions, color palettes, `SimpleXTheme` composable | +| `Theme.android.kt` | [`common/src/androidMain/.../ui/theme/Theme.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Theme.android.kt) | 6 | Android `isSystemInDarkTheme` | +| `Theme.desktop.kt` | [`common/src/desktopMain/.../ui/theme/Theme.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt) | 25 | Desktop `isSystemInDarkTheme` via OsThemeDetector | +| `Files.kt` | [`common/src/commonMain/.../platform/Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | 191 | `readThemeOverrides()` (L125), `writeThemeOverrides()` (L151) | diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md new file mode 100644 index 0000000000..09457c4dd3 --- /dev/null +++ b/apps/multiplatform/spec/state.md @@ -0,0 +1,501 @@ +# State Management + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatModel](#2-chatmodel) +3. [ChatsContext](#3-chatscontext) +4. [Chat](#4-chat) +5. [AppPreferences](#5-apppreferences) +6. [Source Files](#6-source-files) + +--- + +## 1. Overview + +SimpleX Chat uses a **singleton-based, Compose-reactive state model**. The primary state holder is `ChatModel`, a Kotlin `object` annotated with `@Stable`. All mutable fields are Compose `MutableState`, `MutableStateFlow`, or `SnapshotStateList`/`SnapshotStateMap` instances, which trigger Compose recomposition on mutation. + +There is no ViewModel layer, no dependency injection framework, and no Redux/MVI pattern. The architecture is: + +``` +ChatModel (singleton, global Compose state) + | + +-- ChatController (command dispatch + event processing) + | | + | +-- sendCmd() -> chatSendCmdRetry() [JNI] + | +-- recvMsg() -> chatRecvMsgWait() [JNI] + | +-- processReceivedMsg() -> mutates ChatModel fields + | + +-- AppPreferences (150+ SharedPreferences via multiplatform-settings) + | + +-- ChatsContext (primary) -- chat list + current chat items + +-- ChatsContext? (secondary) -- optional second context for dual-pane/support chat +``` + +State mutations originate from two sources: +1. **User actions**: Compose UI handlers call `api*()` suspend functions on `ChatController`, which send commands to the Haskell core, receive responses, and update `ChatModel`. +2. **Core events**: The receiver coroutine (`startReceiver`) calls `processReceivedMsg()`, which updates `ChatModel` fields on `Dispatchers.Main`. + +--- + + + +## 2. ChatModel + +Defined at [`ChatModel.kt line 86`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) as `@Stable object ChatModel`. + +### Controller Reference + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`controller`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L87) | `ChatController` | 87 | Reference to the `ChatController` singleton | + +### User State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`currentUser`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L89) | `MutableState` | 89 | Currently active user profile | +| [`users`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L90) | `SnapshotStateList` | 90 | All user profiles (multi-account) | +| [`localUserCreated`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L91) | `MutableState` | 91 | Whether a local user has been created (null = unknown during init) | +| [`setDeliveryReceipts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L88) | `MutableState` | 88 | Trigger for delivery receipts setup dialog | +| [`switchingUsersAndHosts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L100) | `MutableState` | 100 | True while switching active user/remote host | +| [`changingActiveUserMutex`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L193) | `Mutex` | 193 | Prevents concurrent user switches | + +### Chat Runtime State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`chatRunning`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L92) | `MutableState` | 92 | `null` = initializing, `true` = running, `false` = stopped | +| [`chatDbChanged`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L93) | `MutableState` | 93 | Database was changed externally (needs restart) | +| [`chatDbEncrypted`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L94) | `MutableState` | 94 | Whether database is encrypted | +| [`chatDbStatus`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L95) | `MutableState` | 95 | Result of database migration attempt | +| [`ctrlInitInProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L96) | `MutableState` | 96 | Controller initialization in progress | +| [`dbMigrationInProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L97) | `MutableState` | 97 | Database migration in progress | +| [`incompleteInitializedDbRemoved`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L98) | `MutableState` | 98 | Tracks if incomplete DB files were removed (prevents infinite retry) | + +### Current Chat State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`chatId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L103) | `MutableState` | 103 | ID of the currently open chat (null = chat list shown) | +| [`chatAgentConnId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L104) | `MutableState` | 104 | Agent connection ID for current chat | +| [`chatSubStatus`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L105) | `MutableState` | 105 | Subscription status for current chat | +| [`openAroundItemId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L106) | `MutableState` | 106 | Item ID to scroll to when opening chat | +| [`chatsContext`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L107) | `ChatsContext` | 107 | Primary chat context (see [ChatsContext](#3-chatscontext)) | +| [`secondaryChatsContext`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L108) | `MutableState` | 108 | Optional secondary context for dual-pane views | +| [`chats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L110) | `State>` | 110 | Derived from `chatsContext.chats` | +| [`deletedChats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L112) | `MutableState>>` | 112 | Recently deleted chats (rhId, chatId) | + +### Group Members + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`groupMembers`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L113) | `MutableState>` | 113 | Members of currently viewed group | +| [`groupMembersIndexes`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L114) | `MutableState>` | 114 | Index lookup by `groupMemberId` | +| [`membersLoaded`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L115) | `MutableState` | 115 | Whether group members have been loaded | + +### Chat Tags and Filters + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`userTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L118) | `MutableState>` | 118 | User-defined chat tags | +| [`activeChatTagFilter`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L119) | `MutableState` | 119 | Currently active filter in chat list | +| [`presetTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L120) | `SnapshotStateMap` | 120 | Counts for preset tag categories (favorites, groups, contacts, etc.) | +| [`unreadTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L121) | `SnapshotStateMap` | 121 | Unread counts per user-defined tag | + +### Terminal and Developer + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`terminalsVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L125) | `Set` | 125 | Tracks which terminal views are visible (default vs floating) | +| [`terminalItems`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L126) | `MutableState>` | 126 | Command/response log for developer terminal | + +### Calls (WebRTC) + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`callManager`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L161) | `CallManager` | 161 | WebRTC call lifecycle manager | +| [`callInvitations`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L162) | `SnapshotStateMap` | 162 | Pending incoming call invitations keyed by chatId | +| [`activeCallInvitation`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L163) | `MutableState` | 163 | Currently displayed incoming call invitation | +| [`activeCall`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L164) | `MutableState` | 164 | Currently active call | +| [`activeCallViewIsVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L165) | `MutableState` | 165 | Whether call UI is showing | +| [`activeCallViewIsCollapsed`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L166) | `MutableState` | 166 | Whether call UI is in PiP/collapsed mode | +| [`callCommand`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L167) | `SnapshotStateList` | 167 | Pending WebRTC commands | +| [`showCallView`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L168) | `MutableState` | 168 | Call view visibility toggle | +| [`switchingCall`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L169) | `MutableState` | 169 | True during call switching | + +### Compose Draft and Sharing + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`draft`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L176) | `MutableState` | 176 | Saved compose draft for current chat | +| [`draftChatId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L177) | `MutableState` | 177 | Chat ID the draft belongs to | +| [`sharedContent`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L180) | `MutableState` | 180 | Content received via share intent or internal forwarding | + +### Remote Hosts + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`remoteHosts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L199) | `SnapshotStateList` | 199 | Connected remote hosts (for desktop-mobile pairing) | +| [`currentRemoteHost`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L200) | `MutableState` | 200 | Currently selected remote host | +| [`remoteHostPairing`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L203) | `MutableState?>` | 203 | Remote host pairing state | +| [`remoteCtrlSession`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L204) | `MutableState` | 204 | Remote controller session | + +### Miscellaneous UI State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`userAddress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L127) | `MutableState` | 127 | User's public contact address | +| [`chatItemTTL`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L128) | `MutableState` | 128 | Chat item time-to-live setting | +| [`clearOverlays`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L131) | `MutableState` | 131 | Signal to close all overlays/modals | +| [`appOpenUrl`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L137) | `MutableState?>` | 137 | URL opened via deep link (rhId, uri) | +| [`appOpenUrlConnecting`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L138) | `MutableState` | 138 | Whether a deep link connection is in progress | +| [`newChatSheetVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L141) | `MutableState` | 141 | Whether new chat bottom sheet is visible | +| [`fullscreenGalleryVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L144) | `MutableState` | 144 | Fullscreen gallery mode | +| [`notificationPreviewMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L147) | `MutableState` | 147 | Notification content preview level | +| [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState` | 156 | Whether to show authentication screen | +| [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState` | 158 | Whether to show chat preview text in list | +| [`clipboardHasText`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L185) | `MutableState` | 185 | System clipboard has text content | +| [`networkInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L186) | `MutableState` | 186 | Network type and online status | +| [`conditions`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L188) | `MutableState` | 188 | Server operator terms/conditions | +| [`updatingProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L190) | `MutableState` | 190 | Progress indicator for app updates | +| [`simplexLinkMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L183) | `MutableState` | 183 | How SimpleX links are displayed | +| [`migrationState`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L174) | `MutableState` | 174 | Database migration to new device state | +| [`showingInvitation`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L172) | `MutableState` | 172 | Currently displayed invitation | +| [`desktopOnboardingRandomPassword`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L134) | `MutableState` | 134 | Desktop: user skipped password setup | +| [`filesToDelete`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L182) | `MutableSet` | 182 | Temporary files pending cleanup | + +--- + + + +## 3. ChatsContext + +Defined as inner class at [`ChatModel.kt line 339`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L339): + +```kotlin +class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) +``` + +`ChatsContext` holds the chat list and current chat items for a given context. The `ChatModel` maintains a **primary** context (`chatsContext` at line 107) and an optional **secondary** context (`secondaryChatsContext` at line 108). + +The secondary context is used for: +- **Group support chat scope** (`SecondaryContextFilter.GroupChatScopeContext`) -- viewing member support threads alongside the main group chat +- **Message content tag filtering** (`SecondaryContextFilter.MsgContentTagContext`) -- filtering messages by content type + +### Fields + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`secondaryContextFilter`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L339) | `SecondaryContextFilter?` | 339 | Filter type: null = primary, GroupChatScope or MsgContentTag | +| [`chats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L340) | `MutableState>` | 340 | List of all chats in this context | +| [`chatItems`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L345) | `MutableState>` | 345 | Items for the currently open chat in this context | +| [`chatState`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L347) | `ActiveChatState` | 347 | Tracks unread counts, splits, scroll state | + +### Derived Properties + +| Property | Line | Purpose | +|---|---|---| +| [`contentTag`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L353) | 353 | `MsgContentTag?` -- content filter tag if context is MsgContentTag | +| [`groupScopeInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L360) | 360 | `GroupChatScopeInfo?` -- group scope if context is GroupChatScope | +| [`isUserSupportChat`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L367) | 367 | True when viewing own support chat (no specific member) | + +### Key Operations + +- `addChat(chat)` -- adds chat at index 0, triggers pop animation +- `reorderChat(chat, toIndex)` -- reorders chat list (e.g., when a chat receives a new message) +- `updateChatInfo(rhId, cInfo)` -- updates chat metadata while preserving connection stats +- `hasChat(rhId, id)` / `getChat(id)` -- lookup methods + +### ActiveChatState + +Defined at [`ChatItemsMerger.kt line 196`](../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt#L196): + +```kotlin +data class ActiveChatState( + val splits: MutableStateFlow> = MutableStateFlow(emptyList()), + val unreadAfterItemId: MutableStateFlow = MutableStateFlow(-1L), + val totalAfter: MutableStateFlow = MutableStateFlow(0), + val unreadTotal: MutableStateFlow = MutableStateFlow(0), + val unreadAfter: MutableStateFlow = MutableStateFlow(0), + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) +) +``` + +This tracks the scroll position and unread item accounting for the lazy-loaded chat item list: + +| Field | Purpose | +|---|---| +| `splits` | List of item IDs where pagination gaps exist (items not yet loaded) | +| `unreadAfterItemId` | The item ID that marks the boundary of "read" vs "unread after" | +| `totalAfter` | Total items after the unread boundary | +| `unreadTotal` | Total unread items in the chat | +| `unreadAfter` | Unread items after the boundary (exclusive) | +| `unreadAfterNewestLoaded` | Unread items after the newest loaded batch | + +--- + + + +## 4. Chat + +Defined at [`ChatModel.kt line 1328`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1328): + +```kotlin +@Serializable @Stable +data class Chat( + val remoteHostId: Long?, + val chatInfo: ChatInfo, + val chatItems: List, + val chatStats: ChatStats = ChatStats() +) +``` + +### Fields + +| Field | Type | Purpose | +|---|---|---| +| `remoteHostId` | `Long?` | Remote host ID (null = local) | +| `chatInfo` | `ChatInfo` | Sealed class: `Direct`, `Group`, `Local`, `ContactRequest`, `ContactConnection`, `InvalidJSON` | +| `chatItems` | `List` | Latest chat items (summary; full list is in `ChatsContext.chatItems`) | +| `chatStats` | `ChatStats` | Unread counts and stats | + + + +### ChatStats + +Defined at [`ChatModel.kt line 1370`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1370): + +```kotlin +data class ChatStats( + val unreadCount: Int = 0, + val unreadMentions: Int = 0, + val reportsCount: Int = 0, + val minUnreadItemId: Long = 0, + val unreadChat: Boolean = false +) +``` + +### Derived Properties + +| Property | Line | Purpose | +|---|---|---| +| `id` | 1349 | Chat ID derived from `chatInfo.id` | +| `unreadTag` | 1343 | Whether chat counts as "unread" for tag filtering (considers notification settings) | +| `supportUnreadCount` | 1351 | Unread count in support/moderation context | +| `nextSendGrpInv` | 1337 | Whether next message should send group invitation | + + + +### ChatInfo Variants + +`ChatInfo` is a sealed class at [`ChatModel.kt line 1391`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1391): + +| Variant | SerialName | Key Data | +|---|---|---| +| `ChatInfo.Direct` | `"direct"` | `contact: Contact` | +| `ChatInfo.Group` | `"group"` | `groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?` | +| `ChatInfo.Local` | `"local"` | `noteFolder: NoteFolder` | +| `ChatInfo.ContactRequest` | `"contactRequest"` | `contactRequest: UserContactRequest` | +| `ChatInfo.ContactConnection` | `"contactConnection"` | `contactConnection: PendingContactConnection` | +| `ChatInfo.InvalidJSON` | `"invalidJSON"` | `json: String` | + +### RelayStatus (Channels) + +`RelayStatus` is an `enum class` at [`ChatModel.kt line 2288`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L2288) modelling a relay's lifecycle for a channel on the owner's side. Serialized as a lowercase string via `@SerialName`. + +| Case | SerialName | Meaning | +|---|---|---| +| `RsNew` | `"new"` | Allocated locally; not yet sent | +| `RsInvited` | `"invited"` | `XGrpRelayInv` sent, awaiting `XGrpRelayAcpt` | +| `RsAccepted` | `"accepted"` | Accepted, link-data update pending | +| `RsActive` | `"active"` | Listed in channel link data; forwarding | +| `RsInactive` | `"inactive"` | No longer in link data or backend reports it removed | +| `RsRejected` | `"rejected"` | Relay sent `XGrpRelayReject` for the channel link; final on the owner side. Clearable only by the relay operator running `/group allow #`. The owner-side `GroupMember.memberStatus` is also set to `MemLeft` so the relay renders identically to one that explicitly left (`MemRejected` is reserved for the knocking-admission flow). | + +The `text` extension on the enum returns the localized status string (resource key `relay_status_*`, with `relay_status_rejected` = "rejected"). + +--- + + + + +## 5. AppPreferences + +Defined at [`SimpleXAPI.kt line 94`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) as `class AppPreferences`. + +Uses the `multiplatform-settings` library (`com.russhwolf.settings.Settings`) for cross-platform key-value storage (Android `SharedPreferences` / Desktop `java.util.prefs.Preferences`). + +The `AppPreferences` instance is created lazily in `ChatController` at [`SimpleXAPI.kt line 496`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L496): +```kotlin +val appPrefs: AppPreferences by lazy { AppPreferences() } +``` + +### Preference Categories + +#### Notifications (lines 96-103) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `notificationsMode` | `NotificationsMode` | `SERVICE` (if previously enabled) | OFF / SERVICE / PERIODIC | +| `notificationPreviewMode` | `String` | `"message"` | message / contact / hidden | +| `canAskToEnableNotifications` | `Boolean` | `true` | Whether to show notification enable prompt | +| `backgroundServiceNoticeShown` | `Boolean` | `false` | Background service notice already shown | +| `backgroundServiceBatteryNoticeShown` | `Boolean` | `false` | Battery notice already shown | +| `autoRestartWorkerVersion` | `Int` | `0` | Worker version for periodic restart | + +#### Calls (lines 105-111) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `webrtcPolicyRelay` | `Boolean` | `true` | Use TURN relay for WebRTC | +| `callOnLockScreen` | `CallOnLockScreen` | `SHOW` | DISABLE / SHOW / ACCEPT | +| `webrtcIceServers` | `String?` | `null` | Custom ICE servers | +| `experimentalCalls` | `Boolean` | `false` | Enable experimental call features | + +#### Authentication (lines 107-110) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `performLA` | `Boolean` | `false` | Enable local authentication | +| `laMode` | `LAMode` | default | Authentication mode | +| `laLockDelay` | `Int` | `30` | Seconds before re-auth required | +| `laNoticeShown` | `Boolean` | `false` | LA notice shown | + +#### Privacy (lines 112-128) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `privacyProtectScreen` | `Boolean` | `true` | FLAG_SECURE on Android | +| `privacyAcceptImages` | `Boolean` | `true` | Auto-accept images | +| `privacyLinkPreviews` | `Boolean` | `true` | Generate link previews | +| `privacySanitizeLinks` | `Boolean` | `false` | Remove tracking params from links | +| `simplexLinkMode` | `SimplexLinkMode` | `DESCRIPTION` | DESCRIPTION / FULL / BROWSER | +| `privacyShowChatPreviews` | `Boolean` | `true` | Show chat previews in list | +| `privacySaveLastDraft` | `Boolean` | `true` | Save compose draft | +| `privacyDeliveryReceiptsSet` | `Boolean` | `false` | Delivery receipts configured | +| `privacyEncryptLocalFiles` | `Boolean` | `true` | Encrypt local files | +| `privacyAskToApproveRelays` | `Boolean` | `true` | Ask before using relays | +| `privacyMediaBlurRadius` | `Int` | `0` | Blur radius for media | + +#### Network (lines 140-175) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `networkUseSocksProxy` | `Boolean` | `false` | Enable SOCKS proxy | +| `networkProxy` | `NetworkProxy` | localhost:9050 | Proxy host/port | +| `networkSessionMode` | `TransportSessionMode` | default | Session mode | +| `networkSMPProxyMode` | `SMPProxyMode` | default | SMP proxy mode | +| `networkSMPProxyFallback` | `SMPProxyFallback` | default | Proxy fallback policy | +| `networkHostMode` | `HostMode` | default | Host mode (onion routing) | +| `networkRequiredHostMode` | `Boolean` | `false` | Enforce host mode | +| `networkSMPWebPortServers` | `SMPWebPortServers` | default | Web port server config | +| `networkShowSubscriptionPercentage` | `Boolean` | `false` | Show subscription stats | +| `networkTCPConnectTimeout*` | `Long` | varies | TCP connect timeouts (background/interactive) | +| `networkTCPTimeout*` | `Long` | varies | TCP operation timeouts | +| `networkTCPTimeoutPerKb` | `Long` | varies | Per-KB timeout | +| `networkRcvConcurrency` | `Int` | default | Receive concurrency | +| `networkSMPPingInterval` | `Long` | default | SMP ping interval | +| `networkSMPPingCount` | `Int` | default | SMP ping count | +| `networkEnableKeepAlive` | `Boolean` | default | TCP keep-alive | +| `networkTCPKeepIdle` | `Int` | default | Keep-alive idle time | +| `networkTCPKeepIntvl` | `Int` | default | Keep-alive interval | +| `networkTCPKeepCnt` | `Int` | default | Keep-alive count | + +#### Appearance (lines 213-233) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `currentTheme` | `String` | `"SYSTEM"` | Active theme name | +| `systemDarkTheme` | `String` | `"SIMPLEX"` | Theme for system dark mode | +| `currentThemeIds` | `Map` | empty | Theme ID per base theme | +| `themeOverrides` | `List` | empty | Custom theme overrides | +| `profileImageCornerRadius` | `Float` | `22.5f` | Avatar corner radius | +| `chatItemRoundness` | `Float` | `0.75f` | Message bubble roundness | +| `chatItemTail` | `Boolean` | `true` | Show bubble tail | +| `fontScale` | `Float` | `1f` | Font scale factor | +| `densityScale` | `Float` | `1f` | UI density scale | +| `inAppBarsAlpha` | `Float` | varies | Bar transparency | +| `appearanceBarsBlurRadius` | `Int` | 50 or 0 | Bar blur radius (device-dependent) | + +#### Developer (lines 135-139) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `developerTools` | `Boolean` | `false` | Enable developer tools | +| `logLevel` | `LogLevel` | `WARNING` | Log level | +| `showInternalErrors` | `Boolean` | `false` | Show internal errors to user | +| `showSlowApiCalls` | `Boolean` | `false` | Alert on slow API calls | +| `terminalAlwaysVisible` | `Boolean` | `false` | Floating terminal window (desktop) | + +#### Database (lines 188-208) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `onboardingStage` | `OnboardingStage` | `OnboardingComplete` | Current onboarding step | +| `storeDBPassphrase` | `Boolean` | `true` | Store DB passphrase in keystore | +| `initialRandomDBPassphrase` | `Boolean` | `false` | DB was created with random passphrase | +| `encryptedDBPassphrase` | `String?` | null | Encrypted DB passphrase | +| `confirmDBUpgrades` | `Boolean` | `false` | Confirm DB migrations | +| `chatStopped` | `Boolean` | `false` | Chat was explicitly stopped | +| `chatLastStart` | `Instant?` | null | Last chat start timestamp | +| `newDatabaseInitialized` | `Boolean` | `false` | DB successfully initialized at least once | +| `shouldImportAppSettings` | `Boolean` | `false` | Import settings after DB import | +| `selfDestruct` | `Boolean` | `false` | Self-destruct enabled | +| `selfDestructDisplayName` | `String?` | null | Display name for self-destruct profile | + +#### UI Preferences (lines 255-257) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `oneHandUI` | `Boolean` | `true` | One-hand mode | +| `chatBottomBar` | `Boolean` | `true` | Bottom bar in chat | + +#### Remote Access (lines 238-243) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `deviceNameForRemoteAccess` | `String` | device model | Device name shown to paired devices | +| `confirmRemoteSessions` | `Boolean` | `false` | Confirm remote sessions | +| `connectRemoteViaMulticast` | `Boolean` | `false` | Use multicast for discovery | +| `connectRemoteViaMulticastAuto` | `Boolean` | `true` | Auto-connect via multicast | +| `offerRemoteMulticast` | `Boolean` | `true` | Offer multicast connection | + +#### Migration (lines 189-190) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `migrationToStage` | `String?` | null | Migration-to-device progress | +| `migrationFromStage` | `String?` | null | Migration-from-device progress | + +#### Updates and Versioning (lines 184-186, 235-237) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `appUpdateChannel` | `AppUpdatesChannel` | `DISABLED` | DISABLED / STABLE / BETA | +| `appSkippedUpdate` | `String` | `""` | Skipped update version | +| `appUpdateNoticeShown` | `Boolean` | `false` | Update notice shown | +| `whatsNewVersion` | `String?` | null | Last "What's New" version seen | +| `lastMigratedVersionCode` | `Int` | `0` | Last app version code for data migrations | +| `customDisappearingMessageTime` | `Int` | `300` | Custom disappearing message time (seconds) | + +### Preference Utility Types + +The `SharedPreference` wrapper (defined in SimpleXAPI.kt) provides: +- `get(): T` -- read current value +- `set(value: T)` -- write value +- `state: MutableState` -- Compose-observable state (derived lazily) + +Factory methods: `mkBoolPreference`, `mkIntPreference`, `mkLongPreference`, `mkFloatPreference`, `mkStrPreference`, `mkEnumPreference`, `mkSafeEnumPreference`, `mkDatePreference`, `mkMapPreference`, `mkTimeoutPreference`. + +--- + +## 6. Source Files + +| File | Path | Key Contents | +|---|---|---| +| ChatModel.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | `ChatModel` singleton (line 86), `ChatsContext` (line 339), `Chat` (line 1328), `ChatInfo` (line 1391), `ChatStats` (line 1370), helper methods | +| SimpleXAPI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | `AppPreferences` (line 94), `ChatController` (line 493), `startReceiver` (line 660), `sendCmd` (line 804), `recvMsg` (line 829), `processReceivedMsg` (line 2568) | +| ChatItemsMerger.kt | [`common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt`](../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt) | `ActiveChatState` (line 196), chat item merge/diff logic | +| Core.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt) | `initChatController` (line 62), state initialization flow | +| App.kt | [`common/src/commonMain/kotlin/chat/simplex/common/App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt) | `AppScreen` (line 47), `MainScreen` (line 84), top-level UI state reads | diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index d9f091a13a..ff853f403d 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -94,6 +94,5 @@ mkChatOpts BroadcastBotOpts {coreOptions, botDisplayName} = autoAcceptFileSize = 0, muteNotifications = True, markRead = False, - createBot = Just CreateBotOpts {botDisplayName, allowFiles = False}, - maintenance = False + createBot = Just CreateBotOpts {botDisplayName, allowFiles = False} } diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 2091ab444b..33145497ea 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} module Main where @@ -5,17 +6,22 @@ module Main where import Directory.Options import Directory.Service import Directory.Store -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) -import Simplex.Chat.Core +import Directory.Store.Migrate import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do - opts@DirectoryOpts {directoryLog, runCLI} <- welcomeGetOpts - st <- restoreDirectoryStore directoryLog - if runCLI - then directoryServiceCLI st opts - else do - env <- newServiceState opts - let cfg = terminalChatConfig {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} - simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts env + opts@DirectoryOpts {directoryLog, migrateDirectoryLog, runCLI} <- welcomeGetOpts + case migrateDirectoryLog of + Just cmd -> migrate cmd opts terminalChatConfig + Nothing -> do + st <- openDirectoryLog directoryLog + if runCLI + then directoryServiceCLI st opts + else directoryService st opts terminalChatConfig + where + migrate = \case + MLCheck -> checkDirectoryLog + MLImport -> importDirectoryLogToDB + MLExport -> exportDBToDirectoryLog + MLListing -> saveGroupListingFiles diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 8ae7f60b3d..bfbc025a49 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -10,11 +10,13 @@ module Directory.Events ( DirectoryEvent (..), DirectoryCmd (..), + DirectoryCmdTag (..), ADirectoryCmd (..), DirectoryHelpSection (..), DirectoryRole (..), SDirectoryRole (..), crDirectoryEvent, + directoryCmdP, directoryCmdTag, ) where @@ -31,10 +33,10 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Directory.Store import Simplex.Chat.Controller -import Simplex.Chat.Markdown (displayNameTextP) +import Simplex.Chat.Markdown (MarkdownList, displayNameTextP) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent -import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink, MsgContent (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (AgentErrorType (..)) @@ -47,6 +49,7 @@ data DirectoryEvent | DEGroupInvitation {contact :: Contact, groupInfo :: GroupInfo, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember} | DEGroupUpdated {member :: GroupMember, fromGroup :: GroupInfo, toGroup :: GroupInfo} + | DEGroupLinkCheck GroupInfo | DEPendingMember GroupInfo GroupMember | DEPendingMemberMsg GroupInfo GroupMember ChatItemId Text | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed @@ -55,6 +58,8 @@ data DirectoryEvent | DEContactLeftGroup ContactId GroupInfo | DEServiceRemovedFromGroup GroupInfo | DEGroupDeleted GroupInfo + | DEChatLinkReceived {contact :: Contact, chatItemId :: ChatItemId, chatLink :: MsgChatLink, ownerSig :: Maybe LinkOwnerSig} + | DEMemberUpdated {groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} | DEUnsupportedMessage Contact ChatItemId | DEItemEditIgnored Contact | DEItemDeleteIgnored Contact @@ -89,11 +94,14 @@ crDirectoryEvent_ = \case CEvtLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member CEvtDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo CEvtGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo + CEvtUnknownMemberAnnounced {groupInfo, unknownMember, announcedMember} -> Just $ DEMemberUpdated {groupInfo, fromMember = unknownMember, toMember = announcedMember} + CEvtGroupMemberUpdated {groupInfo, fromMember, toMember} -> Just $ DEMemberUpdated {groupInfo, fromMember, toMember} CEvtChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct CEvtChatItemsDeleted {chatItemDeletions = ((ChatItemDeletion (AChatItem _ SMDRcv (DirectChat ct) _) _) : _), byUser = False} -> Just $ DEItemDeleteIgnored ct - CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}) : _} -> + CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, formattedText = ft, meta = CIMeta {itemLive}}) : _} -> Just $ case (mc, itemLive) of - (MCText t, Nothing) -> DEContactCommand ct ciId $ fromRight err $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.dropWhileEnd isSpace t + (MCText t, Nothing) -> DEContactCommand ct ciId $ fromRight err $ A.parseOnly (directoryCmdP ft <* A.endOfInput) $ T.dropWhileEnd isSpace t + (MCChat {chatLink, ownerSig}, Nothing) -> DEChatLinkReceived {contact = ct, chatItemId = ciId, chatLink, ownerSig} _ -> DEUnsupportedMessage ct ciId where ciId = chatItemId' ci @@ -135,6 +143,7 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin -- DCAddBlockedWord_ :: DirectoryCmdTag 'DRAdmin -- DCRemoveBlockedWord_ :: DirectoryCmdTag 'DRAdmin + DCPromoteGroup_ :: DirectoryCmdTag 'DRSuperUser DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser deriving instance Show (DirectoryCmdTag r) @@ -146,7 +155,7 @@ data DirectoryHelpSection = DHSRegistration | DHSCommands data DirectoryCmd (r :: DirectoryRole) where DCHelp :: DirectoryHelpSection -> DirectoryCmd 'DRUser - DCSearchGroup :: Text -> DirectoryCmd 'DRUser + DCSearchGroup :: Text -> Maybe MarkdownList -> DirectoryCmd 'DRUser DCSearchNext :: DirectoryCmd 'DRUser DCAllGroups :: DirectoryCmd 'DRUser DCRecentGroups :: DirectoryCmd 'DRUser @@ -157,7 +166,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCMemberRole :: UserGroupRegId -> Maybe GroupName -> Maybe GroupMemberRole -> DirectoryCmd 'DRUser DCGroupFilter :: UserGroupRegId -> Maybe GroupName -> Maybe DirectoryMemberAcceptance -> DirectoryCmd 'DRUser DCShowUpgradeGroupLink :: GroupId -> Maybe GroupName -> DirectoryCmd 'DRUser - DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRAdmin + DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId, promote :: Maybe Bool} -> DirectoryCmd 'DRAdmin DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCResumeGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin @@ -167,6 +176,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin -- DCAddBlockedWord :: Text -> DirectoryCmd 'DRAdmin -- DCRemoveBlockedWord :: Text -> DirectoryCmd 'DRAdmin + DCPromoteGroup :: GroupId -> GroupName -> Bool -> DirectoryCmd 'DRSuperUser DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCUnknownCommand :: DirectoryCmd 'DRUser DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r @@ -177,11 +187,11 @@ data ADirectoryCmd = forall r. ADC (SDirectoryRole r) (DirectoryCmd r) deriving instance Show ADirectoryCmd -directoryCmdP :: Parser ADirectoryCmd -directoryCmdP = +directoryCmdP :: Maybe MarkdownList -> Parser ADirectoryCmd +directoryCmdP ft = (A.char '/' *> cmdStrP) <|> (A.char '.' $> ADC SDRUser DCSearchNext) - <|> (ADC SDRUser . DCSearchGroup <$> A.takeText) + <|> (ADC SDRUser . (`DCSearchGroup` ft) <$> A.takeText) where cmdStrP = (tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t))) @@ -211,6 +221,7 @@ directoryCmdP = "invite" -> au DCInviteOwnerToGroup_ -- "block_word" -> au DCAddBlockedWord_ -- "unblock_word" -> au DCRemoveBlockedWord_ + "promote" -> su DCPromoteGroup_ "exec" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_ _ -> fail "bad command tag" @@ -270,7 +281,8 @@ directoryCmdP = DCApproveGroup_ -> do (groupId, displayName) <- gc (,) groupApprovalId <- A.space *> A.decimal - pure DCApproveGroup {groupId, displayName, groupApprovalId} + promote <- Just <$> (" promote=" *> onOffP) <|> pure Nothing + pure DCApproveGroup {groupId, displayName, groupApprovalId, promote} DCRejectGroup_ -> gc DCRejectGroup DCSuspendGroup_ -> gc DCSuspendGroup DCResumeGroup_ -> gc DCResumeGroup @@ -283,17 +295,22 @@ directoryCmdP = DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup -- DCAddBlockedWord_ -> DCAddBlockedWord <$> wordP -- DCRemoveBlockedWord_ -> DCRemoveBlockedWord <$> wordP + DCPromoteGroup_ -> do + (groupId, displayName) <- gc (,) + promote <- A.space *> onOffP + pure $ DCPromoteGroup groupId displayName promote DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (spacesP *> A.takeText) where gc f = f <$> (spacesP *> A.decimal) <*> (A.char ':' *> displayNameTextP) gc_ f = f <$> (spacesP *> A.decimal) <*> optional (A.char ':' *> displayNameTextP) -- wordP = spacesP *> A.takeTill isSpace spacesP = A.takeWhile1 isSpace + onOffP = (A.string "on" $> True) <|> (A.string "off" $> False) directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case DCHelp _ -> "help" - DCSearchGroup _ -> "search" + DCSearchGroup {} -> "search" DCSearchNext -> "next" DCAllGroups -> "all" DCRecentGroups -> "new" @@ -314,6 +331,7 @@ directoryCmdTag = \case DCInviteOwnerToGroup {} -> "invite" -- DCAddBlockedWord _ -> "block_word" -- DCRemoveBlockedWord _ -> "unblock_word" + DCPromoteGroup {} -> "promote" DCExecuteCommand _ -> "exec" DCUnknownCommand -> "unknown" DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs new file mode 100644 index 0000000000..ef093020bb --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -0,0 +1,171 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Directory.Listing where + +import Control.Applicative ((<|>)) +import Control.Monad +import Crypto.Hash (Digest, MD5) +import qualified Crypto.Hash as CH +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteArray as BA +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64URL +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Int (Int64) +import Data.List (isPrefixOf) +import Data.Maybe (catMaybes, fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Data.Time.Clock +import Data.Time.Clock.System +import Data.Time.Format.ISO8601 (iso8601Show) +import Directory.Store +import Simplex.Chat.Markdown +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) +import System.Directory +import System.FilePath + +directoryDataPath :: String +directoryDataPath = "data" + +listingFileName :: String +listingFileName = "listing.json" + +promotedFileName :: String +promotedFileName = "promoted.json" + +listingImageFolder :: String +listingImageFolder = "images" + +data DirectoryEntryType = DETGroup + { groupType :: Maybe GroupType, + admission :: Maybe GroupMemberAdmission, + summary :: GroupSummary + } + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DET") ''DirectoryEntryType) + +data PublicLink = PublicLink + { connFullLink :: Maybe ConnReqContact, + connShortLink :: Maybe ShortLinkContact + } + +$(JQ.deriveJSON defaultJSON ''PublicLink) + +data DirectoryEntry = DirectoryEntry + { entryType :: DirectoryEntryType, + displayName :: Text, + groupLink :: PublicLink, + shortDescr :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + imageFile :: Maybe String, + activeAt :: Maybe UTCTime, + createdAt :: Maybe UTCTime + } + +$(JQ.deriveJSON defaultJSON ''DirectoryEntry) + +data DirectoryListing = DirectoryListing {entries :: [DirectoryEntry]} + +$(JQ.deriveJSON defaultJSON ''DirectoryListing) + +type ImageFileData = ByteString + +newOrActive :: NominalDiffTime +newOrActive = 30 * nominalDay + +recentRoundedTime :: Int64 -> UTCTime -> UTCTime -> Maybe UTCTime +recentRoundedTime roundTo now t + | diffUTCTime now t > newOrActive = Nothing + | otherwise = + let secs = (systemSeconds (utcToSystemTime t) `div` roundTo) * roundTo + in Just $ systemToUTCTime $ MkSystemTime secs 0 + +groupDirectoryEntry :: UTCTime -> GroupInfo -> Maybe GroupLink -> Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData)) +groupDirectoryEntry now GroupInfo {groupProfile, chatTs, createdAt, groupSummary} gLink_ = + let GroupProfile {displayName, shortDescr, description, image, memberAdmission, publicGroup} = groupProfile + gt = (\PublicGroupProfile {groupType} -> groupType) <$> publicGroup + entryType = DETGroup gt memberAdmission groupSummary + description' = case publicGroup of + Just PublicGroupProfile {groupType = gt', groupLink = sLnk} -> + let gtStr = case gt' of GTChannel -> "channel"; _ -> "group" + linkLine = "Link to join the " <> gtStr <> " " <> displayName <> ": " <> decodeUtf8 (strEncode sLnk) + in Just $ maybe linkLine (<> "\n\n" <> linkLine) description + Nothing -> description + entry groupLink = + let de = + DirectoryEntry + { entryType, + displayName, + groupLink, + shortDescr = toFormattedText <$> shortDescr, + welcomeMessage = toFormattedText <$> description', + imageFile = fst <$> imgData, + activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, + createdAt = recentRoundedTime 86400 now createdAt + } + imgData = imgFileData groupLink =<< image + in (de, imgData) + in case publicGroup of + Just PublicGroupProfile {groupLink = sLnk} -> + Just $ entry $ PublicLink Nothing (Just sLnk) + Nothing -> + entry . toPublicLink . connLinkContact <$> gLink_ + where + toPublicLink (CCLink fullLink shortLink) = PublicLink (Just fullLink) shortLink + imgFileData :: PublicLink -> ImageData -> Maybe (FilePath, ByteString) + imgFileData PublicLink {connFullLink, connShortLink} (ImageData img) = + let (img', imgExt) = + fromMaybe (img, ".jpg") $ + (,".jpg") <$> T.stripPrefix "data:image/jpg;base64," img + <|> (,".png") <$> T.stripPrefix "data:image/png;base64," img + linkHash = case connFullLink of + Just fl -> strEncode fl + Nothing -> maybe "" strEncode connShortLink + imgName = B.unpack $ B64URL.encodeUnpadded $ BA.convert $ (CH.hash :: ByteString -> Digest MD5) linkHash + imgFile = listingImageFolder imgName <> imgExt + in case B64.decode $ encodeUtf8 img' of + Right img'' -> Just (imgFile, img'') + Left _ -> Nothing + +generateListing :: FilePath -> [(GroupInfo, GroupReg, Maybe GroupLink)] -> IO () +generateListing dir gs = do + createDirectoryIfMissing True dir + oldDirs <- filter ((directoryDataPath <> ".") `isPrefixOf`) <$> listDirectory dir + ts <- getCurrentTime + let newDirPath = directoryDataPath <> "." <> iso8601Show ts <> "/" + newDir = dir newDirPath + createDirectoryIfMissing True (newDir listingImageFolder) + gs' <- + fmap catMaybes $ forM gs $ \(g, gr, link_) -> + forM (groupDirectoryEntry ts g link_) $ \(g', img) -> do + forM_ img $ \(imgFile, imgData) -> B.writeFile (newDir imgFile) imgData + pure (g', gr) + saveListing newDir listingFileName gs' + saveListing newDir promotedFileName $ filter (\(_, GroupReg {promoted}) -> promoted) gs' + -- atomically update the link + let newSymLink = newDir <> ".link" + symLink = dir directoryDataPath + createDirectoryLink newDirPath newSymLink + renamePath newSymLink symLink + mapM_ (removePathForcibly . (dir )) oldDirs + where + saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst + +toFormattedText :: Text -> MarkdownList +toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 7ad3512fe9..f566ed5ded 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -7,35 +7,47 @@ module Directory.Options ( DirectoryOpts (..), + MigrateLog (..), getDirectoryOpts, + directoryOpts, mkChatOpts, ) where +import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Options.Applicative import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) +import Simplex.Messaging.Parsers (parseAll) data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, adminUsers :: [KnownContact], superUsers :: [KnownContact], ownersGroup :: Maybe KnownGroup, + noAddress :: Bool, -- skip creating address blockedWordsFile :: Maybe FilePath, blockedFragmentsFile :: Maybe FilePath, blockedExtensionRules :: Maybe FilePath, nameSpellingFile :: Maybe FilePath, profileNameLimit :: Int, captchaGenerator :: Maybe FilePath, + voiceCaptchaGenerator :: Maybe FilePath, directoryLog :: Maybe FilePath, + migrateDirectoryLog :: Maybe MigrateLog, serviceName :: T.Text, runCLI :: Bool, searchResults :: Int, + webFolder :: Maybe FilePath, + linkCheckInterval :: Int, testing :: Bool } +data MigrateLog = MLCheck | MLImport | MLExport | MLListing + directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts directoryOpts appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName @@ -62,6 +74,11 @@ directoryOpts appDir defaultDbName = do <> metavar "OWNERS_GROUP" <> help "The group of group owners in the format GROUP_ID:DISPLAY_NAME - owners of listed groups will be invited automatically" ) + noAddress <- + switch + ( long "no-address" + <> help "skip checking and creating service address" + ) blockedWordsFile <- optional $ strOption @@ -105,13 +122,28 @@ directoryOpts appDir defaultDbName = do <> metavar "CAPTCHA_GENERATOR" <> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes" ) + voiceCaptchaGenerator <- + optional $ + strOption + ( long "voice-captcha-generator" + <> metavar "VOICE_CAPTCHA_GENERATOR" + <> help "Executable to generate voice captcha, accepts text as parameter, writes audio file, outputs file_path and duration_seconds to stdout" + ) directoryLog <- - Just - <$> strOption + optional $ + strOption ( long "directory-file" <> metavar "DIRECTORY_FILE" <> help "Append only log for directory state" ) + migrateDirectoryLog <- + optional $ + option + parseMigrateLog + ( long "migrate-directory-file" + <> metavar "MIGRATE_COMMAND" + <> help "Command to import/export directory log file" + ) serviceName <- strOption ( long "service-name" @@ -124,22 +156,42 @@ directoryOpts appDir defaultDbName = do ( long "run-cli" <> help "Run directory service as CLI" ) + webFolder <- + optional $ + strOption + ( long "web-folder" + <> metavar "WEB_FOLDER" + <> help "Folder to store static web assets" + ) + linkCheckInterval <- + option + auto + ( long "link-check-interval" + <> metavar "SECONDS" + <> help "Interval in seconds to check public group link data (default: 1800)" + <> value 1800 + ) pure DirectoryOpts { coreOptions, adminUsers, superUsers, ownersGroup, + noAddress, blockedWordsFile, blockedFragmentsFile, blockedExtensionRules, nameSpellingFile, profileNameLimit, captchaGenerator, + voiceCaptchaGenerator, directoryLog, + migrateDirectoryLog, serviceName = T.pack serviceName, runCLI, searchResults = 10, + webFolder, + linkCheckInterval, testing = False } @@ -169,6 +221,16 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName} = autoAcceptFileSize = 0, muteNotifications = True, markRead = False, - createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False}, - maintenance = False + createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False} } + +parseMigrateLog :: ReadM MigrateLog +parseMigrateLog = eitherReader $ parseAll mlP . encodeUtf8 . T.pack + where + mlP = + A.takeTill (== ' ') >>= \case + "check" -> pure MLCheck + "import" -> pure MLImport + "export" -> pure MLExport + "listing" -> pure MLListing + _ -> fail "bad MigrateLog" diff --git a/apps/simplex-directory-service/src/Directory/Search.hs b/apps/simplex-directory-service/src/Directory/Search.hs index 5b0d650444..d71c128370 100644 --- a/apps/simplex-directory-service/src/Directory/Search.hs +++ b/apps/simplex-directory-service/src/Directory/Search.hs @@ -1,12 +1,5 @@ -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE NamedFieldPuns #-} - module Directory.Search where -import Data.List (sortOn) -import Data.Ord (Down (..)) -import Data.Set (Set) -import qualified Data.Set as S import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Types @@ -14,19 +7,7 @@ import Simplex.Chat.Types data SearchRequest = SearchRequest { searchType :: SearchType, searchTime :: UTCTime, - sentGroups :: Set GroupId + lastGroup :: GroupId -- cursor for search } data SearchType = STAll | STRecent | STSearch Text - -takeTop :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeTop n = take n . sortOn (\(GIS _ GroupSummary {currentMembers}) -> Down currentMembers) - -takeRecent :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeRecent n = take n . sortOn (\(GIS GroupInfo {createdAt} _) -> Down createdAt) - -groupIds :: [GroupInfoSummary] -> Set GroupId -groupIds = S.fromList . map (\(GIS GroupInfo {groupId} _) -> groupId) - -filterNotSent :: Set GroupId -> [GroupInfoSummary] -> [GroupInfoSummary] -filterNotSent sentGroups = filter (\(GIS GroupInfo {groupId} _) -> groupId `S.notMember` sentGroups) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index f95b04dee1..6e414ef011 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -8,30 +8,30 @@ {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Service ( welcomeGetOpts, directoryService, directoryServiceCLI, - newServiceState, - acceptMemberHook ) where -import Control.Concurrent (forkIO) -import Control.Concurrent.Async +import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.STM -import qualified Control.Exception as E +import Control.Exception (SomeException, try) import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import qualified Data.Attoparsec.Text as A +import Data.Bifunctor (first) +import Data.Either (fromRight) import Data.List (find, intercalate) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) -import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T @@ -41,20 +41,22 @@ import Data.Time.LocalTime (getCurrentTimeZone) import Directory.BlockedWords import Directory.Captcha import Directory.Events +import Directory.Listing import Directory.Options import Directory.Search import Directory.Store +import Directory.Store.Migrate +import Directory.Util import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core -import Simplex.Chat.Markdown (FormattedText (..), Format (..), parseMaybeMarkdownList, viewName) +import Simplex.Chat.Markdown (Format (..), FormattedText (..), SimplexLinkType (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgContent (..)) -import Simplex.Chat.Store (GroupLink (..)) +import Simplex.Chat.Protocol (GroupShortLinkData (..), LinkOwnerSig (..), MsgChatLink (..), MsgContent (..), memberSupportVoiceVersion) import Simplex.Chat.Store.Direct (getContact) -import Simplex.Chat.Store.Groups (getGroupInfo, getGroupLink, getGroupSummary, setGroupCustomData) +import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, getGroupMemberByMemberId, setGroupCustomData) -- TODO remove setGroupCustomData import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Terminal (terminalChatConfig) @@ -63,17 +65,17 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) -import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..)) -import Simplex.Messaging.Agent.Store.Common (withTransaction) -import Simplex.Messaging.Agent.Protocol (SConnectionMode (..), sameConnReqContact, sameShortLinkContact) -import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ACreatedConnLink (..), AgentErrorType (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), sameConnReqContact, sameShortLinkContact) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Protocol (ErrorType (..)) import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) -import System.Directory (getAppUserDataDirectory) +import Simplex.Messaging.Util (eitherToMaybe, raceAny_, safeDecodeUtf8, tshow, unlessM, (<$$>)) +import System.Directory (getAppUserDataDirectory, removeFile) import System.Exit (exitFailure) import System.Process (readProcess) +import Text.Read (readMaybe) data GroupProfileUpdate = GPNoServiceLink @@ -97,13 +99,19 @@ data GroupRolesStatus data ServiceState = ServiceState { searchRequests :: TMap ContactId SearchRequest, blockedWordsCfg :: BlockedWordsConfig, - pendingCaptchas :: TMap GroupMemberId PendingCaptcha + pendingCaptchas :: TMap GroupMemberId PendingCaptcha, + serviceCC :: TMVar ChatController, + eventQ :: TQueue DirectoryEvent, + updateListingsJob :: TMVar () } +data CaptchaMode = CMText | CMAudio + data PendingCaptcha = PendingCaptcha { captchaText :: Text, sentAt :: UTCTime, - attempts :: Int + attempts :: Int, + captchaMode :: CaptchaMode } captchaLength :: Int @@ -120,7 +128,10 @@ newServiceState opts = do searchRequests <- TM.emptyIO blockedWordsCfg <- readBlockedWordsConfig opts pendingCaptchas <- TM.emptyIO - pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas} + serviceCC <- newEmptyTMVarIO + eventQ <- newTQueueIO + updateListingsJob <- newEmptyTMVarIO + pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, serviceCC, eventQ, updateListingsJob} welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do @@ -142,32 +153,84 @@ welcomeGetOpts = do knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n knownName i n = show i <> ":" <> T.unpack (viewName n) -directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () +directoryServiceCLI :: DirectoryLog -> DirectoryOpts -> IO () directoryServiceCLI st opts = do - env <- newServiceState opts - eventQ <- newTQueueIO - let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) - chatHooks = defaultChatHooks {postStartHook = Just postStartHook, eventHook = Just eventHook, acceptMember = Just $ acceptMemberHook opts env} - race_ - (simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing) - (processEvents eventQ env) + env@ServiceState {eventQ} <- newServiceState opts + let eventHook _cc resp = atomically $ resp <$ mapM_ (writeTQueue eventQ) (crDirectoryEvent resp) + chatHooks = + defaultChatHooks + { preStartHook = Just $ directoryPreStartHook opts, + postStartHook = Just $ directoryPostStartHook opts env, + eventHook = Just eventHook, + acceptMember = Just $ acceptMemberHook opts env + } + raceAny_ $ + [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, + processEvents env + ] + <> maybeToList (updateListingsThread_ opts env) + <> maybeToList (linkCheckThread_ opts env) where - processEvents eventQ env = forever $ do - (cc, resp) <- atomically $ readTQueue eventQ + processEvents env@ServiceState {eventQ} = do + cc <- atomically $ readTMVar $ serviceCC env u_ <- readTVarIO (currentUser cc) - forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp - postStartHook cc = - readTVarIO (currentUser cc) >>= \case - Nothing -> putStrLn "No current user" >> exitFailure - Just User {userId, profile = p@LocalProfile {preferences}} -> do - let cmds = fromMaybe [] $ preferences >>= commands_ - unless (cmds == directoryCommands) $ do - let prefs = (fromMaybe emptyChatPrefs preferences) {files = Just FilesPreference {allow = FANo}, commands = Just directoryCommands} :: Preferences - p' = (fromLocalProfile p) {displayName = serviceName opts, peerType = Just CPTBot, preferences = Just prefs} :: Profile - liftIO $ sendChatCmd cc (APIUpdateProfile userId p') >>= \case - Right CRUserProfileUpdated {} -> putStrLn "Updated directory commands" - Right r -> putStrLn ("Error: unexpected response " <> show r) >> exitFailure - Left e -> putStrLn ("Error: " <> show e) >> exitFailure + forM_ u_ $ \user -> + forever $ do + event <- atomically $ readTQueue eventQ + directoryServiceEvent st opts env user cc event + +updateListingDelay :: Int +updateListingDelay = 5 * 60 * 1000000 -- update every 5 minutes + +updateListingsThread_ :: DirectoryOpts -> ServiceState -> Maybe (IO ()) +updateListingsThread_ opts env = updateListingsThread <$> webFolder opts + where + updateListingsThread f = do + cc <- atomically $ readTMVar $ serviceCC env + forever $ do + u <- readTVarIO $ currentUser cc + forM_ u $ \user -> updateGroupListingFiles cc user f + delay <- registerDelay updateListingDelay + atomically $ void (takeTMVar $ updateListingsJob env) `orElse` unlessM (readTVar delay) retry + +listingsUpdated :: ServiceState -> IO () +listingsUpdated env = void $ atomically $ tryPutTMVar (updateListingsJob env) () + +linkCheckThread_ :: DirectoryOpts -> ServiceState -> Maybe (IO ()) +linkCheckThread_ opts env@ServiceState {eventQ} + | linkCheckInterval opts > 0 = Just $ do + cc <- atomically $ readTMVar $ serviceCC env + forever $ do + threadDelay $ linkCheckInterval opts * 1000000 + u <- readTVarIO $ currentUser cc + forM_ u $ \user -> + withDB' "linkCheckThread" cc (\db -> getAllGroupRegs_ db user) >>= \case + Left e -> logError $ "linkCheckThread error: " <> T.pack e + Right grs -> forM_ grs $ \(gInfo, gr) -> + unless (groupRemoved $ groupRegStatus gr) $ + atomically $ writeTQueue eventQ $ DEGroupLinkCheck gInfo + | otherwise = Nothing + +directoryPreStartHook :: DirectoryOpts -> ChatController -> IO () +directoryPreStartHook opts ChatController {config, chatStore} = runDirectoryMigrations opts config chatStore + +directoryPostStartHook :: DirectoryOpts -> ServiceState -> ChatController -> IO () +directoryPostStartHook opts@DirectoryOpts {noAddress, testing} env cc = + readTVarIO (currentUser cc) >>= \case + Nothing -> putStrLn "No current user" >> exitFailure + Just User {userId, profile = p@LocalProfile {preferences}} -> do + unless noAddress $ initializeBotAddress' (not testing) cc + void $ atomically $ tryPutTMVar (serviceCC env) cc + listingsUpdated env + let cmds = fromMaybe [] $ preferences >>= commands_ + unless (cmds == directoryCommands) $ do + let prefs = (fromMaybe emptyChatPrefs preferences) {files = Just FilesPreference {allow = FANo}, commands = Just directoryCommands} :: Preferences + p' = (fromLocalProfile p) {displayName = serviceName opts, peerType = Just CPTBot, preferences = Just prefs} :: Profile + liftIO $ + sendChatCmd cc (APIUpdateProfile userId p') >>= \case + Right CRUserProfileUpdated {} -> putStrLn "Updated directory commands" + Right r -> putStrLn ("Error: unexpected response " <> show r) >> exitFailure + Left e -> putStrLn ("Error: " <> show e) >> exitFailure directoryCommands :: [ChatBotCommand] directoryCommands = @@ -185,12 +248,26 @@ directoryCommands = where idParam = Just "" -directoryService :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> IO () -directoryService st opts@DirectoryOpts {testing} env user cc = do - initializeBotAddress' (not testing) cc - race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc - directoryServiceEvent st opts env user cc resp +directoryService :: DirectoryLog -> DirectoryOpts -> ChatConfig -> IO () +directoryService st opts cfg = do + env@ServiceState {eventQ} <- newServiceState opts + let chatHooks = + defaultChatHooks + { preStartHook = Just $ directoryPreStartHook opts, + postStartHook = Just $ directoryPostStartHook opts env, + acceptMember = Just $ acceptMemberHook opts env + } + simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc -> + raceAny_ $ + [ forever $ do + (_, resp) <- atomically . readTBQueue $ outputQ cc + mapM_ (atomically . writeTQueue eventQ) $ crDirectoryEvent resp, + forever $ do + event <- atomically $ readTQueue eventQ + directoryServiceEvent st opts env user cc event + ] + <> maybeToList (updateListingsThread_ opts env) + <> maybeToList (linkCheckThread_ opts env) acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) acceptMemberHook @@ -215,7 +292,7 @@ acceptMemberHook when (hasBlockedWords blockedWordsCfg displayName) $ throwError GRRBlockedName groupMemberAcceptance :: GroupInfo -> DirectoryMemberAcceptance -groupMemberAcceptance GroupInfo {customData} = memberAcceptance $ fromCustomData customData +groupMemberAcceptance GroupInfo {customData} = (\DirectoryGroupData {memberAcceptance = ma} -> ma) $ fromCustomData customData useMemberFilter :: Maybe ImageData -> Maybe ProfileCondition -> Bool useMemberFilter img_ = \case @@ -233,13 +310,13 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} -directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO () -directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event = - forM_ (crDirectoryEvent event) $ \case +directoryServiceEvent :: DirectoryLog -> DirectoryOpts -> ServiceState -> User -> ChatController -> DirectoryEvent -> IO () +directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc = \case DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner DEGroupUpdated {member, fromGroup, toGroup} -> deGroupUpdated member fromGroup toGroup + DEGroupLinkCheck g -> deGroupLinkCheck g DEPendingMember g m -> dePendingMember g m DEPendingMemberMsg g m ciId t -> dePendingMemberMsg g m ciId t DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role @@ -248,6 +325,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName DEContactLeftGroup ctId g -> deContactLeftGroup ctId g DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g DEGroupDeleted g -> deGroupDeleted g + DEChatLinkReceived {contact = ct, chatLink, ownerSig} -> deChatLinkReceived ct chatLink ownerSig + DEMemberUpdated {groupInfo = g, fromMember, toMember} -> deMemberUpdated g fromMember toMember DEUnsupportedMessage _ct _ciId -> pure () DEItemEditIgnored _ct -> pure () DEItemDeleteIgnored _ct -> pure () @@ -265,13 +344,33 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName forM_ adminUsers $ \KnownContact {contactId} -> action contactId withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId notifyAdminUsers s = withAdminUsers $ \contactId -> sendMessage' cc contactId s - notifyOwner GroupReg {dbContactId} = sendMessage' cc dbContactId + notifyOwner = sendMessage' cc . dbContactId ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId - withGroupReg GroupInfo {groupId, localDisplayName} err action = do - getGroupReg st groupId >>= \case - Just gr -> action gr - Nothing -> logError $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId - groupInfoText p@GroupProfile {description = d} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d + withGroupReg :: GroupInfo -> Text -> (GroupReg -> IO ()) -> IO () + withGroupReg GroupInfo {groupId, localDisplayName} err action = + getGroupReg cc groupId >>= \case + Right gr -> action gr + Left e -> do + let msg = "Error: " <> err <> ", group: " <> tshow groupId <> " " <> localDisplayName <> ", " <> T.pack e + notifyAdminUsers msg + logError msg + groupInfoText p@GroupProfile {description = d, publicGroup} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d <> linkToJoin + where + linkToJoin = case publicGroup of + Just pg@PublicGroupProfile {groupLink} -> + "\nLink to join " <> groupTypeStr' pg <> ": " <> strEncodeTxt groupLink + <> "\nYou need SimpleX Chat app v6.5 to join." + Nothing -> "" + membersCountStr GroupProfile {publicGroup} GroupSummary {currentMembers, publicMemberCount} = + let count = fromMaybe currentMembers publicMemberCount + label = case publicGroup of + Just PublicGroupProfile {groupType = GTChannel} -> " subscribers" + _ -> " members" + in tshow count <> label + knockingStr :: Maybe GroupMemberAdmission -> [Text] + knockingStr = \case + Just GroupMemberAdmission {review = Just MCAll} -> ["New members are reviewed by admins"] + _ -> [] groupNameDescr GroupProfile {displayName = n, fullName = fn, shortDescr = sd_} = n <> maybe "" (\d' -> " (" <> d' <> ")") descr where @@ -284,47 +383,35 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName groupReference' groupId displayName = "ID " <> tshow groupId <> " (" <> displayName <> ")" groupAlreadyListed GroupInfo {groupProfile = p} = "The group " <> groupNameDescr p <> " is already listed in the directory, please choose another name." + ifPublicGroup :: GroupInfo -> IO () -> IO () -> IO () + ifPublicGroup GroupInfo {groupProfile = GroupProfile {publicGroup}} reject action = + if isJust publicGroup then reject else action - getGroups :: Text -> IO (Maybe [GroupInfoSummary]) - getGroups = getGroups_ . Just - - getGroups_ :: Maybe Text -> IO (Maybe [GroupInfoSummary]) - getGroups_ search_ = - sendChatCmd cc (APIListGroups userId Nothing $ T.unpack <$> search_) >>= \case - Right CRGroupsList {groups} -> pure $ Just groups - _ -> pure Nothing - - getDuplicateGroup :: GroupInfo -> IO (Maybe DuplicateGroup) - getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} = - getGroups fullName >>= mapM duplicateGroup + getDuplicateGroup :: GroupInfo -> IO (Either String DuplicateGroup) + getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = + duplicateGroup <$$> getDuplicateGroupRegs cc user displayName where - sameGroupNotRemoved (GIS g@GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}} _) = - gId /= groupId && n == displayName && fn == fullName && not (memberRemoved $ membership g) - duplicateGroup [] = pure DGUnique - duplicateGroup groups = do - let gs = filter sameGroupNotRemoved groups - if null gs - then pure DGUnique - else do - (lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st) - let reserved = any (\(GIS GroupInfo {groupId = gId} _) -> gId `S.member` lgs || gId `S.member` rgs) gs - if reserved - then pure DGReserved - else do - removed <- foldM (\r -> fmap (r &&) . isGroupRemoved) True gs - pure $ if removed then DGUnique else DGRegistered - isGroupRemoved (GIS GroupInfo {groupId = gId} _) = - getGroupReg st gId >>= \case - Just GroupReg {groupRegStatus} -> groupRemoved <$> readTVarIO groupRegStatus - Nothing -> pure True + duplicateGroup [] = DGUnique + duplicateGroup ((GroupInfo {groupId = gId, membership}, GroupReg {groupRegStatus = status}) : groups) + | gId == groupId || memberRemoved membership = duplicateGroup groups + | otherwise = case grDirectoryStatus status of + DSListed -> DGReserved + DSReserved -> DGReserved + DSRegistered -> case duplicateGroup groups of + DGReserved -> DGReserved + _ -> DGRegistered + DSRemoved -> duplicateGroup groups - processInvitation :: Contact -> GroupInfo -> IO () - processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do - void $ addGroupReg st ct g GRSProposed - r <- sendChatCmd cc $ APIJoinGroup groupId MFNone - sendMessage cc ct $ case r of - Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" - _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" + processInvitation :: Contact -> GroupInfo -> Maybe GroupReg -> IO () + processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = \case + Nothing -> addGroupReg notifyAdminUsers st cc ct g GRSProposed joinGroup + Just _gr -> setGroupStatus notifyAdminUsers st env cc groupId GRSProposed joinGroup + where + joinGroup _ = do + r <- sendChatCmd cc $ APIJoinGroup groupId MFNone + sendMessage cc ct $ case r of + Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" + _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" deContactConnected :: Contact -> IO () deContactConnected ct = when (contactDirect ct) $ do @@ -332,7 +419,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName sendMessage cc ct $ ("Welcome to " <> serviceName <> "!\n\n") <> "🔍 Send search string to find groups - try _security_.\n\ - \/help - how to submit your group.\n\ + \/help - how to submit your group or channel.\n\ \/new - recent groups.\n\n\ \[Directory rules](https://simplex.chat/docs/directory.html)." @@ -343,15 +430,15 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just msg -> sendMessage cc ct msg Nothing -> getDuplicateGroup g >>= \case - Just DGUnique -> processInvitation ct g - Just DGRegistered -> askConfirmation - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." + Right DGUnique -> processInvitation ct g Nothing + Right DGRegistered -> askConfirmation + Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g + Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e where - askConfirmation = do - ugrId <- addGroupReg st ct g GRSPendingConfirmation - sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:" - sendMessage cc ct $ "/confirm " <> tshow ugrId <> ":" <> viewName displayName + askConfirmation = + addGroupReg notifyAdminUsers st cc ct g GRSPendingConfirmation $ \GroupReg {userGroupRegId} -> do + sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:" + sendMessage cc ct $ "/confirm " <> tshow userGroupRegId <> ":" <> viewName displayName badRolesMsg :: GroupRolesStatus -> Maybe Text badRolesMsg = \case @@ -360,9 +447,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GRSContactNotOwner -> Just "You must have a group *owner* role to register the group" GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group" - getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Maybe GroupRolesStatus) - getGroupRolesStatus GroupInfo {membership = GroupMember {memberRole = serviceRole}} gr = - rStatus <$$> getGroupMember gr + getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Either String GroupRolesStatus) + getGroupRolesStatus GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} gr = + rStatus <$$> getOwnerGroupMember groupId gr where rStatus GroupMember {memberRole} = groupRolesStatus memberRole serviceRole @@ -373,107 +460,139 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName (GROwner, _) -> GRSServiceNotAdmin _ -> GRSBadRoles - getGroupMember :: GroupReg -> IO (Maybe GroupMember) - getGroupMember GroupReg {dbGroupId, dbOwnerMemberId} = - readTVarIO dbOwnerMemberId - $>>= \mId -> resp <$> sendChatCmd cc (APIGroupMemberInfo dbGroupId mId) - where - resp = \case - Right CRGroupMemberInfo {member} -> Just member - _ -> Nothing + getOwnerGroupMember :: GroupId -> GroupReg -> IO (Either String GroupMember) + getOwnerGroupMember gId GroupReg {dbOwnerMemberId} = case dbOwnerMemberId of + Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (vr cc) user gId mId + Nothing -> pure $ Left "no owner member in group registration" deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () - deServiceJoinedGroup ctId g owner = do + deServiceJoinedGroup ctId g@GroupInfo {groupId} owner = do logInfo $ "service joined group " <> viewGroupName g withGroupReg g "joined group" $ \gr -> when (ctId `isOwner` gr) $ do - setGroupRegOwner st gr owner - let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g - notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" - sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case - Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner - gr - "Created the public link to join the group via this directory service that is always online.\n\n\ - \Please add it to the group welcome message.\n\ - \For example, add:" - notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink - Left (ChatError e) -> case e of - CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." - CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." - CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" - CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" - _ -> notifyOwner gr $ unexpectedError "can't create group link" - _ -> notifyOwner gr $ unexpectedError "can't create group link" + let GroupInfo {groupProfile = GroupProfile {displayName}} = g + setGroupRegOwner cc groupId owner >>= \case + Left e -> do + let msg = "Error updating group " <> tshow groupId <> " owner: " <> T.pack e + logError msg + notifyOwner gr msg + Right () -> do + logGUpdateOwner st groupId $ groupMemberId' owner + notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" + sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case + Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> + setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner + gr' + "Created the public link to join the group via this directory service that is always online.\n\n\ + \Please add it to the group welcome message.\n\ + \For example, add:" + notifyOwner gr' $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink + Left (ChatError e) -> case e of + CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." + CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." + CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" + CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" + _ -> notifyOwner gr $ unexpectedError "can't create group link" + _ -> notifyOwner gr $ unexpectedError "can't create group link" deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO () deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do - withGroupReg toGroup "group updated" $ \gr -> do + withGroupReg toGroup "group updated" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr toGroup byMember = case memberContactId m of Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner. _ -> " by " <> mName -- owner notification from directory will include the name. - readTVarIO (groupRegStatus gr) >>= \case - GRSPendingConfirmation -> pure () - GRSProposed -> pure () - GRSPendingUpdate -> - groupProfileUpdate >>= \case - GPNoServiceLink -> - notifyOwner gr $ "The profile updated for " <> userGroupRef <> byMember <> ", but the group link is not added to the welcome message." - GPServiceLinkAdded _ -> groupLinkAdded gr byMember - GPServiceLinkRemoved -> - notifyOwner gr $ - "The group link of " <> userGroupRef <> " is removed from the welcome message" <> byMember <> ", please add it." - GPHasServiceLink {} -> groupLinkAdded gr byMember - GPServiceLinkError -> do - notifyOwner gr $ - ("Error: " <> serviceName <> " has no group link for " <> userGroupRef) - <> " after profile was updated" <> byMember <> ". Please report the error to the developers." - logError $ "Error: no group link for " <> userGroupRef - GRSPendingApproval n -> processProfileChange gr byMember False $ n + 1 - GRSActive -> processProfileChange gr byMember True 1 - GRSSuspended -> processProfileChange gr byMember False 1 - GRSSuspendedBadRoles -> processProfileChange gr byMember False 1 - GRSRemoved -> pure () + case publicGroup p' of + Just pg -> case groupRegStatus of + GRSPendingApproval n -> publicGroupProfileChange pg gr byMember $ n + 1 + GRSActive -> publicGroupProfileChange pg gr byMember 1 + _ -> pure () + Nothing -> case groupRegStatus of + GRSPendingConfirmation -> pure () + GRSProposed -> pure () + GRSPendingUpdate -> + groupProfileUpdate >>= \case + GPNoServiceLink -> + notifyOwner gr $ "The profile updated for " <> userGroupRef <> byMember <> ", but the group link is not added to the welcome message." + GPServiceLinkAdded _ -> groupLinkAdded gr byMember + GPServiceLinkRemoved -> + notifyOwner gr $ + "The group link of " <> userGroupRef <> " is removed from the welcome message" <> byMember <> ", please add it." + GPHasServiceLink {} -> groupLinkAdded gr byMember + GPServiceLinkError -> do + notifyOwner gr $ + ("Error: " <> serviceName <> " has no group link for " <> userGroupRef) + <> " after profile was updated" + <> byMember + <> ". Please report the error to the developers." + logError $ "Error: no group link for " <> userGroupRef + GRSPendingApproval n -> processProfileChange gr byMember False $ n + 1 + GRSActive -> processProfileChange gr byMember True 1 + GRSSuspended -> processProfileChange gr byMember False 1 + GRSSuspendedBadRoles -> processProfileChange gr byMember False 1 + GRSRemoved -> pure () where GroupInfo {groupId, groupProfile = p} = fromGroup GroupInfo {groupProfile = p'} = toGroup sameProfile - GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d} - GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d'} = - n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') - groupLinkAdded gr byMember = do + GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma, publicGroup = pg} + GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma', publicGroup = pg'} = + n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') && ma == ma' && pg == pg' + publicGroupProfileChange pg@PublicGroupProfile {groupLink} gr byMember n' = do + let gt = groupTypeStr' pg + userGroupRef = userGroupReference gr toGroup + groupRef = groupReference toGroup + link = ACL SCMContact $ CLShort groupLink + updatedNotification gr' g' = do + notifyOwner gr' $ + ("The " <> gt <> " " <> userGroupRef <> " is updated" <> byMember) + <> ".\nIt is hidden from the directory until approved." + notifyAdminUsers $ "The " <> gt <> " " <> groupRef <> " is updated" <> byMember <> "." + sendToApprove g' gr' n' + sendChatCmd cc (APIConnectPlan userId (Just link) True Nothing) >>= \case + Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) -> + case dbOwnerMemberId gr of + Just ownerGMId -> + withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case + Right ownerMember + | let GroupMember {memberRole = role} = ownerMember, role >= GROwner -> + setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` g') + | otherwise -> do + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \_ -> pure () + notifyOwner gr $ "The registration owner is no longer an owner. Registration suspended." + Left _ -> logError $ "could not find owner member for " <> groupRef + Nothing -> logError $ "no owner member set for " <> groupRef + _ -> + setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` toGroup) + groupLinkAdded gr byMember = getDuplicateGroup toGroup >>= \case - Nothing -> notifyOwner gr "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup - _ -> do - let gaId = 1 - setGroupStatus st gr $ GRSPendingApproval gaId - notifyOwner gr $ - ("Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message" <> byMember) + Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup + _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval gaId) $ \gr' -> do + notifyOwner gr' $ + ("Thank you! The group link for " <> userGroupReference gr' toGroup <> " is added to the welcome message" <> byMember) <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." - checkRolesSendToApprove gr gaId + checkRolesSendToApprove gr' gaId + where + gaId = 1 processProfileChange gr byMember isActive n' = do let userGroupRef = userGroupReference gr toGroup groupRef = groupReference toGroup groupProfileUpdate >>= \case - GPNoServiceLink -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner gr $ + GPNoServiceLink -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner gr' $ ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." - GPServiceLinkRemoved -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner gr $ + GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner gr' $ ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." - GPServiceLinkAdded _ -> do - setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ + GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ ("The group link is added to " <> userGroupRef <> byMember) <> "!\nIt is hidden from the directory until approved." notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "." @@ -484,18 +603,17 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nThe group is listed in directory." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." - | otherwise -> do - setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ + | otherwise -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nIt is hidden from the directory until approved." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "." - checkRolesSendToApprove gr n' + checkRolesSendToApprove gr' n' where onlyLinkChanged - GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d} - GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d'} = - dn == dn' && fn == fn' && i == i' && sd == sd' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d') + GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} + GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = + dn == dn' && fn == fn' && i == i' && sd == sd' && ma == ma' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d') GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval." groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where @@ -518,42 +636,71 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> GPServiceLinkError checkRolesSendToApprove gr gaId = do (badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case - Nothing -> notifyOwner gr "Error: getGroupRolesStatus. Please notify the developers." - Just (Just msg) -> notifyOwner gr msg - Just Nothing -> sendToApprove toGroup gr gaId + Left e -> notifyOwner gr $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e + Right (Just msg) -> notifyOwner gr msg + Right Nothing -> sendToApprove toGroup gr gaId dePendingMember :: GroupInfo -> GroupMember -> IO () - dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m - | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 + dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m + | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 CMText | otherwise = approvePendingMember a g m where a = groupMemberAcceptance g - captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + captchaNotice = + "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + <> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" - sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> IO () - sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts = do + sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO () + sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do s <- getCaptchaStr captchaLength "" - mc <- getCaptcha s sentAt <- getCurrentTime - let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1} + let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1, captchaMode = mode} atomically $ TM.insert gmId captcha $ pendingCaptchas env - sendCaptcha mc + case mode of + CMAudio -> do + mc <- getCaptchaContent s + sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] + sendVoiceCaptcha sendRef s + CMText -> do + mc <- getCaptchaContent s + sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] where - getCaptcha s = case captchaGenerator opts of - Nothing -> pure textMsg - Just script -> content <$> readProcess script [s] "" - where - textMsg = MCText $ T.pack s - content r = case T.lines $ T.pack r of - [] -> textMsg - "" : _ -> textMsg - img : _ -> MCImage "" $ ImageData img - sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(quotedId, MCText noticeText), (Nothing, mc)] + sendRef = SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False gmId = groupMemberId' m + sendVoiceCaptcha :: SendRef -> String -> IO () + sendVoiceCaptcha sendRef s = + forM_ (voiceCaptchaGenerator opts) $ \script -> + void . forkIO $ do + voiceResult <- try $ readProcess script [s] "" :: IO (Either SomeException String) + case voiceResult of + Right r -> case lines r of + (filePath : durationStr : _) + | not (null filePath), Just duration <- readMaybe durationStr -> do + sendComposedMessageFile cc sendRef Nothing (MCVoice "" duration) (CF.plain filePath) + void (try $ removeFile filePath :: IO (Either SomeException ())) + _ -> logError "voice captcha generator: unexpected output" + Left e -> logError $ "voice captcha generator error: " <> tshow e + + getCaptchaContent :: String -> IO MsgContent + getCaptchaContent s = case captchaGenerator opts of + Nothing -> pure $ MCText $ T.pack s + Just script -> content <$> readProcess script [s] "" + where + content r = case T.lines $ T.pack r of + [] -> textMsg + "" : _ -> textMsg + img : _ -> MCImage "" $ ImageData img + textMsg = MCText $ T.pack s + + canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool + canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) + approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do - gli_ <- join <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) + gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_ gmId = groupMemberId' m sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case @@ -567,32 +714,64 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText | memberRequiresCaptcha a m = do - ts <- getCurrentTime - atomically (TM.lookup (groupMemberId' m) $ pendingCaptchas env) >>= \case - Just PendingCaptcha {captchaText, sentAt, attempts} - | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 - | matchCaptchaStr captchaText msgText -> do - sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just $ groupMemberId' m)) [(Just ciId, MCText $ "Correct, you joined the group " <> n)] - approvePendingMember a g m - | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts - | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts - Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 + let gmId = groupMemberId' m + sendRef = SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False + -- /audio is matched as text, not as DirectoryCmd, because it is only valid + -- in group context at captcha stage, while DirectoryCmd is for DM commands. + isAudioCmd = T.strip msgText == "/audio" + cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP Nothing <* A.endOfInput) $ T.strip msgText + atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case + Nothing + | isAudioCmd && canSendVoiceCaptcha g m -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + | isAudioCmd -> sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] + | otherwise -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMText + Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} + | isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] + | otherwise -> case cmd of + ADC SDRUser (DCSearchGroup {}) -> do + ts <- getCurrentTime + if + | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired (attempts - 1) captchaMode + | matchCaptchaStr captchaText msgText -> do + sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + approvePendingMember a g m + | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts + | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts captchaMode + _ -> sendComposedMessages_ cc sendRef [(Just ciId, MCText unknownCommand)] | otherwise = approvePendingMember a g m where a = groupMemberAcceptance g rejectPendingMember rjctNotice = do let gmId = groupMemberId' m - sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendComposedMessages cc (SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False) [MCText rjctNotice] sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case - Right (CRUserDeletedMembers _ _ (_ : _) _) -> do + Right (CRUserDeletedMembers _ _ (_ : _) _ _) -> do atomically $ TM.delete gmId $ pendingCaptchas env logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g r -> logError $ "unexpected remove member response: " <> tshow r + captchaExpired :: Text captchaExpired = "Captcha expired, please try again." + wrongCaptcha :: Int -> Text wrongCaptcha attempts | attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt." | otherwise = "Incorrect text, please try again." + noCaptcha :: Text noCaptcha = "Unexpected message, please try again." + audioAlreadyEnabled :: Text + audioAlreadyEnabled = "Audio captcha is already enabled." + voiceCaptchaUnavailable :: Text + voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." + unknownCommand :: Text + unknownCommand = "Unknown command, please enter captcha text." + tooManyAttempts :: Text tooManyAttempts = "Too many failed attempts, you can't join group." memberRequiresCaptcha :: DirectoryMemberAcceptance -> GroupMember -> Bool @@ -600,196 +779,384 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName useMemberFilter image $ passCaptcha a sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO () - sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId} gaId = do + sendToApprove GroupInfo {groupId, groupProfile = p@GroupProfile {displayName, image = image', publicGroup = pg_}, groupSummary} GroupReg {dbContactId, promoted} gaId = do ct_ <- getContact' cc user dbContactId - gr_ <- getGroupAndSummary cc user dbGroupId - let membersStr = maybe "" (\(_, s) -> "_" <> tshow (currentMembers s) <> " members_\n") gr_ + let gt = maybe "group" groupTypeStr' pg_ + membersStr = "_" <> membersCountStr p groupSummary <> "_\n" text = - maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ + either (\_ -> "The " <> gt <> " ID " <> tshow groupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the " <> gt <> " ID " <> tshow groupId <> ": ") ct_ <> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:") msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withAdminUsers $ \cId -> do - sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> tshow dbGroupId <> ":" <> viewName displayName <> " " <> tshow gaId + let approveCmd = MCText $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" + sendComposedMessages cc (SRDirect cId) [msg, approveCmd] + + deGroupLinkCheck :: GroupInfo -> IO () + deGroupLinkCheck gInfo@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, groupSummary = summary} = + withGroupReg gInfo "link check" $ \gr@GroupReg {groupRegStatus, dbOwnerMemberId} -> + forM_ pg_ $ \pg@PublicGroupProfile {groupLink} -> + when (groupRegStatus == GRSActive || pendingApproval groupRegStatus) $ do + let link = ACL SCMContact $ CLShort groupLink + sendChatCmd cc (APIConnectPlan userId (Just link) True Nothing) >>= \case + Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g', groupUpdated = BoolDef updated, linkOwners = ListDef owners}))) -> + checkValidOwner dbOwnerMemberId owners $ do + when updated $ reapprove pg gr groupRegStatus g' + when (updated || summary /= groupSummary g') $ listingsUpdated env + Left (ChatErrorAgent {agentError = SMP _ err}) | linkDeleted err -> + setGroupStatus logError st env cc groupId GRSRemoved $ \gr' -> + notifyOwner gr' "The channel link is no longer valid.\nThe channel is removed from the directory." + _ -> pure () + where + linkDeleted = \case + AUTH -> True + BLOCKED {} -> True + _ -> False + checkValidOwner dbOwnerMemberId owners onValid = case dbOwnerMemberId of + Just ownerGMId -> + withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case + Right GroupMember {memberId, memberPubKey} + | any (\GroupLinkOwner {memberId = mId, memberKey} -> memberId == mId && memberPubKey == Just memberKey) owners -> onValid + _ -> setGroupStatus logError st env cc groupId GRSSuspendedBadRoles $ \gr' -> + notifyOwner gr' "The registration owner is no longer a channel owner.\nThe channel is no longer listed in the directory." + Nothing -> onValid + reapprove pg gr groupRegStatus g' = do + let gt = groupTypeStr' pg + groupRef = groupReference gInfo + notifyAdminUsers $ "The " <> gt <> " " <> groupRef <> " profile changed." + case groupRegStatus of + GRSActive -> + setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval 1) $ \gr' -> do + notifyOwner gr' $ "The " <> gt <> " profile has changed.\nIt is hidden from the directory until approved." + sendToApprove g' gr' 1 + GRSPendingApproval n -> + sendToApprove g' gr (n + 1) + _ -> pure () deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () - deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do + deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do logInfo $ "contact ID " <> tshow ctId <> " role changed in group " <> viewGroupName g <> " to " <> tshow contactRole - withGroupReg g "contact role changed" $ \gr -> do + withGroupReg g "contact role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole - when (ctId `isOwner` gr) $ do - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspendedBadRoles -> when (rStatus == GRSOk) $ do - setGroupStatus st gr GRSActive - notifyOwner gr $ uCtRole <> ".\n\nThe group is listed in the directory again." - notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole - GRSPendingApproval gaId -> when (rStatus == GRSOk) $ do + when (ctId `isOwner` gr) $ + case groupRegStatus of + GRSSuspendedBadRoles | rStatus == GRSOk -> + setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do + notifyOwner gr' $ uCtRole <> ".\n\nThe group is listed in the directory again." + notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole + GRSPendingApproval gaId | rStatus == GRSOk -> do sendToApprove g gr gaId notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval." - GRSActive -> when (rStatus /= GRSOk) $ do - setGroupStatus st gr GRSSuspendedBadRoles - notifyOwner gr $ uCtRole <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole + GRSActive | rStatus /= GRSOk -> + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do + notifyOwner gr' $ uCtRole <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole _ -> pure () where rStatus = groupRolesStatus contactRole serviceRole groupRef = groupReference g - ctRole = "*" <> strEncodeTxt contactRole <> "*" + ctRole = "*" <> textEncode contactRole <> "*" suCtRole = "(user role is set to " <> ctRole <> ")." deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO () - deServiceRoleChanged g serviceRole = do + deServiceRoleChanged g@GroupInfo {groupId} serviceRole = do logInfo $ "service role changed in group " <> viewGroupName g <> " to " <> tshow serviceRole - withGroupReg g "service role changed" $ \gr -> do + withGroupReg g "service role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uSrvRole = serviceName <> " role in the group " <> userGroupRef <> " is changed to " <> srvRole - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspendedBadRoles -> when (serviceRole == GRAdmin) $ - whenContactIsOwner gr $ do - setGroupStatus st gr GRSActive - notifyOwner gr $ uSrvRole <> ".\n\nThe group is listed in the directory again." - notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole - GRSPendingApproval gaId -> when (serviceRole == GRAdmin) $ + case groupRegStatus of + GRSSuspendedBadRoles | serviceRole == GRAdmin -> + whenContactIsOwner gr $ + setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do + notifyOwner gr' $ uSrvRole <> ".\n\nThe group is listed in the directory again." + notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole + GRSPendingApproval gaId | serviceRole == GRAdmin -> whenContactIsOwner gr $ do sendToApprove g gr gaId notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval." - GRSActive -> when (serviceRole /= GRAdmin) $ do - setGroupStatus st gr GRSSuspendedBadRoles - notifyOwner gr $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole + GRSActive | serviceRole /= GRAdmin -> + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do + notifyOwner gr' $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole _ -> pure () where groupRef = groupReference g - srvRole = "*" <> strEncodeTxt serviceRole <> "*" + srvRole = "*" <> textEncode serviceRole <> "*" suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." whenContactIsOwner gr action = - getGroupMember gr + getOwnerGroupMember groupId gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO () - deContactRemovedFromGroup ctId g = do + deContactRemovedFromGroup ctId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + let gt = maybe "group" groupTypeStr' pg_ logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g - withGroupReg g "contact removed" $ \gr -> do - when (ctId `isOwner` gr) $ do - setGroupStatus st gr GRSRemoved - notifyOwner gr $ "You are removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." + withGroupReg g "contact removed" $ \gr -> + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You are removed from the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." + notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner is removed)." + when (isJust pg_) $ leavePublicGroup g deContactLeftGroup :: ContactId -> GroupInfo -> IO () - deContactLeftGroup ctId g = do + deContactLeftGroup ctId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + let gt = maybe "group" groupTypeStr' pg_ logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g - withGroupReg g "contact left" $ \gr -> do - when (ctId `isOwner` gr) $ do - setGroupStatus st gr GRSRemoved - notifyOwner gr $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." + withGroupReg g "contact left" $ \gr -> + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You left the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." + notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner left)." + when (isJust pg_) $ leavePublicGroup g deServiceRemovedFromGroup :: GroupInfo -> IO () - deServiceRemovedFromGroup g = do + deServiceRemovedFromGroup g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + let gt = maybe "group" groupTypeStr' pg_ logInfo $ "service removed from group " <> viewGroupName g - withGroupReg g "service removed" $ \gr -> do - setGroupStatus st gr GRSRemoved - notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do + notifyOwner gr $ serviceName <> " is removed from the " <> gt <> " " <> userGroupReference gr g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." + notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (directory service is removed)." deGroupDeleted :: GroupInfo -> IO () - deGroupDeleted g = do + deGroupDeleted g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + let gt = maybe "group" groupTypeStr' pg_ logInfo $ "group removed " <> viewGroupName g - withGroupReg g "group removed" $ \gr -> do - setGroupStatus st gr GRSRemoved - notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)." + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do + notifyOwner gr $ "The " <> gt <> " " <> userGroupReference gr g <> " is deleted.\n\nThe " <> gt <> " is no longer listed in the directory." + notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " is deleted)." + + deChatLinkReceived :: Contact -> MsgChatLink -> Maybe LinkOwnerSig -> IO () + deChatLinkReceived ct (MCLGroup {connLink, groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}}) (Just ownerSig@LinkOwnerSig {ownerId = Just (B64UrlByteString oIdBytes)}) = + case groupType of + GTUnknown tag -> sendMessage cc ct $ "Unsupported group type: " <> T.pack (show tag) + gt -> do + let link = ACL SCMContact $ CLShort connLink + mId = MemberId oIdBytes + gt' = groupTypeStr gt + sendChatCmd cc (APIConnectPlan userId (Just link) True (Just ownerSig)) >>= \case + Right (CRConnectionPlan _ (ACCL SCMContact ccLink) plan) -> + handleGroupLinkPlan ct ccLink mId ownerSig gt' plan + _ -> sendMessage cc ct "Error: could not connect. Please report it to directory admins." + deChatLinkReceived ct (MCLGroup {groupProfile = GroupProfile {publicGroup = Just pg}}) _ = + sendMessage cc ct $ "To add a " <> groupTypeStr' pg <> " to directory you must be the owner." + deChatLinkReceived ct _ _ = + sendMessage cc ct "Only channels can be added to directory via link." + + groupTypeStr :: GroupType -> Text + groupTypeStr = \case + GTChannel -> "channel" + GTGroup -> "group" + GTUnknown _ -> "group" + + groupTypeStr' :: PublicGroupProfile -> Text + groupTypeStr' PublicGroupProfile {groupType} = groupTypeStr groupType + + leavePublicGroup :: GroupInfo -> IO () + leavePublicGroup GroupInfo {groupId} = + void $ sendChatCmd cc (APILeaveGroup groupId) + + handleGroupLinkPlan :: Contact -> CreatedLinkContact -> MemberId -> LinkOwnerSig -> Text -> ConnectionPlan -> IO () + handleGroupLinkPlan ct ccLink mId ownerSig gt = \case + CPGroupLink glp -> case glp of + GLPOk {groupSLinkData_, ownerVerification} -> case (groupSLinkData_, ownerVerification) of + (Just groupSLinkData, Just OVVerified) -> joinAndRegisterPublicGroup ct ccLink mId gt groupSLinkData + (_, Just (OVFailed reason)) -> sendMessage cc ct $ "Link signature verification failed: " <> reason <> ".\nYou must be the " <> gt <> " owner to register it." + (Nothing, _) -> sendMessage cc ct $ "Error: no " <> gt <> " information available via the link." + _ -> sendMessage cc ct $ "Error: could not verify " <> gt <> " ownership. Please report it to directory admins." + GLPKnown {groupInfo = g, groupUpdated = BoolDef updated, ownerVerification} -> case ownerVerification of + Just OVVerified -> deReregistration ct g updated ownerSig + Just (OVFailed reason) -> sendMessage cc ct $ "Link signature verification failed: " <> reason <> ".\nYou must be the " <> gt <> " owner to register it." + Nothing -> sendMessage cc ct $ "Error: could not verify " <> gt <> " ownership." + GLPConnectingProhibit _ -> sendMessage cc ct $ "Already connecting to this " <> gt <> "." + GLPConnectingConfirmReconnect -> sendMessage cc ct $ "Already connecting to this " <> gt <> "." + GLPNoRelays _ -> sendMessage cc ct $ T.toTitle gt <> " has no active relays. Please try again later." + GLPOwnLink _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins." + _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins." + + joinAndRegisterPublicGroup :: Contact -> CreatedLinkContact -> MemberId -> Text -> GroupShortLinkData -> IO () + joinAndRegisterPublicGroup ct ccLink mId gt groupSLinkData = do + let GroupShortLinkData {groupProfile = GroupProfile {displayName}} = groupSLinkData + ownerContact = GroupOwnerContact {contactId = contactId' ct, memberId = mId} + sendMessage cc ct $ "Joining the " <> gt <> " " <> displayName <> "…" + sendChatCmd cc (APIPrepareGroup userId ccLink False groupSLinkData) >>= \case + Right (CRNewPreparedChat _ (AChat SCTGroup (Chat (GroupChat gInfo _) _ _))) -> do + let gId = groupId' gInfo + addGroupReg notifyAdminUsers st cc ct gInfo GRSProposed $ \_ -> pure () + sendChatCmd cc (APIConnectPreparedGroup gId False (Just ownerContact) Nothing) >>= \case + Right CRStartedConnectionToGroup {groupInfo = gInfo'} -> + withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user gInfo' mId) >>= \case + Right ownerMember -> + void $ setGroupRegOwner cc gId ownerMember + Left e -> do + logError $ "could not find owner member: " <> T.pack e + sendMessage cc ct "Error: could not find owner member after joining. Please report it to directory admins." + _ -> sendMessage cc ct $ "Error joining " <> gt <> " " <> displayName <> ", please re-send the link!" + _ -> sendMessage cc ct $ "Error joining " <> gt <> " " <> displayName <> ", please re-send the link!" + + deReregistration :: Contact -> GroupInfo -> Bool -> LinkOwnerSig -> IO () + deReregistration ct g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} profileChanged LinkOwnerSig {ownerId = Just (B64UrlByteString oIdBytes)} = do + let mId = MemberId oIdBytes + gt = maybe "group" groupTypeStr' pg_ + withDB "getGroupMemberByMemberId" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user g mId) >>= \case + Right ownerMember@GroupMember {memberRole = role, memberStatus} -> + if + | role >= GROwner && memberStatus /= GSMemUnknown -> + getGroupReg cc groupId >>= \case + Right gr + | contactId' ct `isOwner` gr -> sameOwnerReregistration gr gt + | otherwise -> sendMessage cc ct $ "This " <> gt <> " is registered by another owner." + Left _ -> + addGroupReg notifyAdminUsers st cc ct g (GRSPendingApproval 1) $ \gr -> do + void $ setGroupRegOwner cc groupId ownerMember + sendToApprove g gr 1 + | role < GROwner -> sendMessage cc ct $ "You must be the " <> gt <> " owner to register it." + | otherwise -> sendMessage cc ct $ "Waiting for the owner member to be connected to the " <> gt <> "." + Left _ -> sendMessage cc ct $ "Error: could not verify " <> gt <> " ownership. Please report it to directory admins." + where + sameOwnerReregistration gr gt = case groupRegStatus gr of + GRSProposed -> sendMessage cc ct $ "Registration is in progress, waiting for the owner member to be connected to the " <> gt <> "." + GRSPendingConfirmation -> pendingApprovalTransition gr gt 1 + GRSPendingUpdate -> pendingApprovalTransition gr gt 1 + GRSPendingApproval n + | profileChanged -> pendingApprovalTransition gr gt $ n + 1 + | otherwise -> sendMessage cc ct $ T.toTitle gt <> " is already pending approval." + GRSActive + | profileChanged -> pendingApprovalTransition gr gt 1 + | otherwise -> sendMessage cc ct $ T.toTitle gt <> " is already listed in the directory." + GRSSuspended -> sendMessage cc ct $ T.toTitle gt <> " is suspended by admin. Please contact support." + GRSSuspendedBadRoles -> pendingApprovalTransition gr gt 1 + GRSRemoved -> pendingApprovalTransition gr gt 1 + pendingApprovalTransition gr gt n = do + let userGroupRef = userGroupReference gr g + setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n) $ \gr' -> do + notifyOwner gr' $ + "The " <> gt <> " " <> userGroupRef <> " is submitted for approval.\nIt is hidden from the directory until approved." + sendToApprove g gr' n + deReregistration ct _ _ _ = + sendMessage cc ct "Error: could not verify ownership. Please report it to directory admins." + + deMemberUpdated :: GroupInfo -> GroupMember -> GroupMember -> IO () + deMemberUpdated g@GroupInfo {groupId, groupProfile = GroupProfile {displayName, publicGroup}} fromMember toMember = + withGroupReg g "owner member announced" $ \gr@GroupReg {groupRegStatus, dbOwnerMemberId} -> + when (groupRegStatus == GRSProposed && (dbOwnerMemberId == Just (groupMemberId' fromMember) || dbOwnerMemberId == Just (groupMemberId' toMember))) $ + let GroupMember {memberRole = role} = toMember + gt = maybe "group" groupTypeStr' publicGroup + in if role >= GROwner + then setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval 1) $ \gr' -> do + notifyOwner gr' $ "Joined the " <> gt <> " " <> displayName <> ". Registration is pending approval — it may take up to 48 hours." + sendToApprove g gr' 1 + else do + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \_ -> pure () + sendMessage' cc (dbContactId gr) "The signing key does not belong to a current owner. Registration cancelled." deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () deUserCommand ct ciId = \case DCHelp DHSRegistration -> sendMessage cc ct $ - "You must be the group owner to add it to the directory:\n\n\ - \1️⃣ *Invite* " + "You must be the group or channel owner to add it to the directory.\n\n\ + \*To register a channel*, use _Share via chat_ to send its link to " + <> serviceName + <> " bot.\n\n\ + \*To register a group*:\n\ + \1️⃣ *Invite* " <> serviceName <> " bot to your group as *admin* - it will create a link for new members to join.\n\ - \2️⃣ *Add* this link to the group's welcome message.\n\ - \3️⃣ We *review* your group. Once *approved*, anybody can find it.\n\n\ - \_We usually approve within a day, except holidays_. [More details](https://simplex.chat/docs/directory.html#adding-groups-to-the-directory)." + \2️⃣ *Add* this link to the group's welcome message.\n\n\ + \Once your group or channel *approved*, it can be found here or at [simplex.chat/directory](https://simplex.chat/directory).\n\n\ + \_We usually review within a day, except holidays_. [More details](https://simplex.chat/docs/directory.html#adding-groups-to-the-directory)." DCHelp DHSCommands -> sendMessage cc ct $ "/'help commands' - receive this help message.\n\ - \/help - how to register your group to be added to directory.\n\ + \/help - how to register your group or channel to be added to directory.\n\ \/list - list the groups you registered.\n\ \`/role ` - view and set default member role for your group.\n\ \`/filter ` - view and set spam filter settings for group.\n\ \`/link ` - view and upgrade group link.\n\ \`/delete :` - remove the group you submitted from directory, with _ID_ and _name_ as shown by /list command.\n\n\ \To search for groups, send the search text." - DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s + DCSearchGroup s ft -> + sendFoundListedGroups (STSearch s) Nothing notFound $ \gs n -> + let more = if n > length gs then ", sending top " <> tshow (length gs) else "" + in "Found " <> tshow n <> " group(s)" <> more <> "." + where + notFound + | hasSimplexGroupLink ft = "No groups found.\nTo register a group or a channel, please use \"Share via chat\" feature." + | otherwise = "No groups found" + hasSimplexGroupLink = \case + Just fts -> any isGroupLink fts + Nothing -> False + isGroupLink (FormattedText (Just SimplexLink {linkType}) _) = linkType == XLGroup || linkType == XLChannel + isGroupLink _ = False DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case - Just search@SearchRequest {searchType, searchTime} -> do + Just SearchRequest {searchType, searchTime, lastGroup} -> do currentTime <- getCurrentTime if diffUTCTime currentTime searchTime > 300 -- 5 minutes then do atomically $ TM.delete (contactId' ct) searchRequests showAllGroups - else case searchType of - STSearch s -> withFoundListedGroups (Just s) $ sendNextSearchResults takeTop search - STAll -> withFoundListedGroups Nothing $ sendNextSearchResults takeTop search - STRecent -> withFoundListedGroups Nothing $ sendNextSearchResults takeRecent search + else + sendFoundListedGroups searchType (Just lastGroup) "No more groups" $ \gs _ -> + "Sending " <> tshow (length gs) <> " more group(s)." Nothing -> showAllGroups where showAllGroups = deUserCommand ct ciId DCAllGroups - DCAllGroups -> withFoundListedGroups Nothing $ sendAllGroups takeTop "top" STAll - DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent + DCAllGroups -> sendFoundListedGroups STAll Nothing "No groups listed" $ allGroupsReply "top" + DCRecentGroups -> sendFoundListedGroups STRecent Nothing "No groups listed" $ allGroupsReply "the most recent" DCSubmitGroup _link -> pure () DCConfirmDuplicateGroup ugrId gName -> - withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSPendingConfirmation -> - getDuplicateGroup g >>= \case - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - _ -> processInvitation ct g - _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." + withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr@GroupReg {groupRegStatus} -> case groupRegStatus of + GRSPendingConfirmation -> + getDuplicateGroup g >>= \case + Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g + _ -> processInvitation ct g $ Just gr + _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." DCListUserGroups -> - getUserGroupRegs st (contactId' ct) >>= \grs -> do - sendReply $ tshow (length grs) <> " registered group(s)" - void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {dbGroupId, userGroupRegId} -> - let useGroupId = if isAdmin then dbGroupId else userGroupRegId - in sendGroupInfo ct gr useGroupId Nothing + getUserGroupRegs cc user (contactId' ct) >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId isAdmin (gs, length gs) DCDeleteGroup gId gName -> - (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do - delGroupReg st gr - sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" + (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName, publicGroup = pg_}} GroupReg {dbGroupId} -> do + let gt = maybe "group" groupTypeStr' pg_ + delGroupReg cc dbGroupId >>= \case + Right () -> do + logGDelete st dbGroupId + sendReply $ (if isAdmin then "The " <> gt <> " " else "Your " <> gt <> " ") <> displayName <> " is deleted from the directory" + when (isJust pg_) $ leavePublicGroup g + Left e -> sendReply $ "Error deleting " <> gt <> " " <> displayName <> ": " <> T.pack e DCMemberRole gId gName_ mRole_ -> - (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> + ifPublicGroup g (sendReply "This command is not available for public groups.") $ do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g case mRole_ of Nothing -> getGroupLink' cc user g >>= \case - Just GroupLink {connLinkContact = gLink, acceptMemberRole} -> do + Right GroupLink {connLinkContact = gLink, acceptMemberRole} -> do let anotherRole = case acceptMemberRole of GRObserver -> GRMember; _ -> GRObserver sendReply $ initialRole n acceptMemberRole - <> ("Send /'role " <> tshow gId <> " " <> strEncodeTxt anotherRole <> "' to change it.\n\n") + <> ("Send /'role " <> tshow gId <> " " <> textEncode anotherRole <> "' to change it.\n\n") <> onlyViaLink gLink - Nothing -> sendReply $ "Error: failed reading the initial member role for the group " <> n + Left _ -> sendReply $ "Error: failed reading the initial member role for the group " <> n Just mRole -> do setGroupLinkRole cc g mRole >>= \case Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink Nothing -> sendReply $ "Error: the initial member role for the group " <> n <> " was NOT upgated." where - initialRole n mRole = "The initial member role for the group " <> n <> " is set to *" <> strEncodeTxt mRole <> "*\n" + initialRole n mRole = "The initial member role for the group " <> n <> " is set to *" <> textEncode mRole <> "*\n" onlyViaLink gLink = "*Please note*: it applies only to members joining via this link: " <> groupLinkText gLink DCGroupFilter gId gName_ acceptance_ -> - (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> + ifPublicGroup g (sendReply "This command is not available for public groups.") $ do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g a = groupMemberAcceptance g case acceptance_ of Just a' | a /= a' -> do let d = toCustomData $ DirectoryGroupData a' withDB' "setGroupCustomData" cc (\db -> setGroupCustomData db user g $ Just d) >>= \case - Just () -> sendSettigns n a' " set to" - Nothing -> sendReply $ "Error changing spam filter settings for group " <> n + Right () -> sendSettigns n a' " set to" + Left e -> sendReply $ "Error changing spam filter settings for group " <> n <> ": " <> T.pack e _ -> sendSettigns n a "" where sendSettigns n a setTo = @@ -812,35 +1179,42 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just PCAll -> "_enabled_" Just PCNoImage -> "_enabled for profiles without image_" DCShowUpgradeGroupLink gId gName_ -> - (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \GroupInfo {groupId, localDisplayName = gName} _ -> do - let groupRef = groupReference' gId gName - withGroupLinkResult groupRef (sendChatCmd cc $ APIGetGroupLink groupId) $ - \GroupLink {connLinkContact = gLink@(CCLink _ sLnk_), acceptMemberRole, shortLinkDataSet, shortLinkLargeDataSet = BoolDef slLargeDataSet} -> do - let shouldBeUpgraded = isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet - sendReply $ T.unlines $ - [ "The link to join the group " <> groupRef <> ":", - groupLinkText gLink, - "New member role: " <> strEncodeTxt acceptMemberRole - ] - <> ["The link is being upgraded..." | shouldBeUpgraded] - when shouldBeUpgraded $ do - let send = sendComposedMessage cc ct Nothing . MCText . T.unlines - withGroupLinkResult groupRef (sendChatCmd cc $ APIAddGroupShortLink groupId) $ - \GroupLink {connLinkContact = CCLink _ sLnk_'} -> case (sLnk_, sLnk_') of - (Just _, Just _) -> - send ["The group link is upgraded for: " <> groupRef, "No changes to group needed."] - (Nothing, Just sLnk) -> - sendComposedMessages cc (SRDirect $ contactId' ct) - [ MCText $ T.unlines - [ "Please replace the old link in welcome message of your group " <> groupRef, - "If this is the only change, the group will remain listed in directory without re-approval.", - "", - "The new link:" - ], - MCText $ strEncodeTxt sLnk - ] - (_, Nothing) -> - send ["The short link is not created for " <> groupRef, "Please report it to the developers."] + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, localDisplayName = gName} _ -> case pg_ of + Just pg@PublicGroupProfile {groupLink} -> + sendReply $ "The link to join the " <> groupTypeStr' pg <> " " <> groupReference' gId gName <> ":\n" <> strEncodeTxt groupLink + Nothing -> do + let groupRef = groupReference' gId gName + withGroupLinkResult groupRef (sendChatCmd cc $ APIGetGroupLink groupId) $ + \GroupLink {connLinkContact = gLink@(CCLink _ sLnk_), acceptMemberRole, shortLinkDataSet, shortLinkLargeDataSet = BoolDef slLargeDataSet} -> do + let shouldBeUpgraded = isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet + sendReply $ + T.unlines $ + [ "The link to join the group " <> groupRef <> ":", + groupLinkText gLink, + "New member role: " <> textEncode acceptMemberRole + ] + <> ["The link is being upgraded..." | shouldBeUpgraded] + when shouldBeUpgraded $ do + let send = sendComposedMessage cc ct Nothing . MCText . T.unlines + withGroupLinkResult groupRef (sendChatCmd cc $ APIAddGroupShortLink groupId) $ + \GroupLink {connLinkContact = CCLink _ sLnk_'} -> case (sLnk_, sLnk_') of + (Just _, Just _) -> + send ["The group link is upgraded for: " <> groupRef, "No changes to group needed."] + (Nothing, Just sLnk) -> + sendComposedMessages + cc + (SRDirect $ contactId' ct) + [ MCText $ + T.unlines + [ "Please replace the old link in welcome message of your group " <> groupRef, + "If this is the only change, the group will remain listed in directory without re-approval.", + "", + "The new link:" + ], + MCText $ strEncodeTxt sLnk + ] + (_, Nothing) -> + send ["The short link is not created for " <> groupRef, "Please report it to the developers."] where withGroupLinkResult groupRef a cb = a >>= \case @@ -862,104 +1236,93 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName isAdmin = knownCt `elem` adminUsers || knownCt `elem` superUsers withUserGroupReg ugrId = withUserGroupReg_ ugrId . Just withUserGroupReg_ ugrId gName_ action = - getUserGroupReg st (contactId' ct) ugrId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" - Just gr@GroupReg {dbGroupId} -> do - getGroup cc user dbGroupId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" - Just g@GroupInfo {groupProfile = GroupProfile {displayName}} - | maybe True (displayName ==) gName_ -> action g gr - | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName + getUserGroupReg cc user (contactId' ct) ugrId >>= \case + -- TODO differentiate group not found error + Left e -> sendReply $ "Group ID " <> tshow ugrId <> " error:" <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) + | maybe True (displayName ==) gName_ -> action g gr + | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName sendReply = mkSendReply ct ciId - withFoundListedGroups s_ action = - getGroups_ s_ >>= \case - Just groups -> filterListedGroups st groups >>= action - Nothing -> sendReply "Error: getGroups. Please notify the developers." - sendSearchResults s = \case - [] -> sendReply "No groups found" - gs -> do - let gs' = takeTop searchResults gs - moreGroups = length gs - length gs' - more = if moreGroups > 0 then ", sending top " <> tshow (length gs') else "" - reply = "Found " <> tshow (length gs) <> " group(s)" <> more <> "." - updateSearchRequest (STSearch s) $ groupIds gs' - sendFoundGroups reply gs' moreGroups - sendAllGroups takeFirst sortName searchType = \case - [] -> sendReply "No groups listed" - gs -> do - let gs' = takeFirst searchResults gs - moreGroups = length gs - length gs' - more = if moreGroups > 0 then ", sending " <> sortName <> " " <> tshow (length gs') else "" - reply = tshow (length gs) <> " group(s) listed" <> more <> "." - updateSearchRequest searchType $ groupIds gs' - sendFoundGroups reply gs' moreGroups - sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case - [] -> do - sendReply "Sorry, no more groups" - atomically $ TM.delete (contactId' ct) searchRequests - gs -> do - let gs' = takeFirst searchResults $ filterNotSent sentGroups gs - sentGroups' = sentGroups <> groupIds gs' - moreGroups = length gs - S.size sentGroups' - reply = "Sending " <> tshow (length gs') <> " more group(s)." - updateSearchRequest searchType sentGroups' - sendFoundGroups reply gs' moreGroups - updateSearchRequest :: SearchType -> Set GroupId -> IO () - updateSearchRequest searchType sentGroups = do + sendFoundListedGroups searchType lastGroup_ notFound replyStr = + searchListedGroups cc user searchType lastGroup_ searchResults >>= \case + Right ([], _) -> do + atomically $ TM.delete (contactId' ct) searchRequests + sendReply notFound + Right (gs, n) -> do + let moreGroups = n - length gs + updateSearchRequest searchType $ last gs + sendFoundGroups (replyStr gs n) gs moreGroups + Left e -> sendReply $ "Error: searchListedGroups. Please notify the developers.\n" <> T.pack e + allGroupsReply sortName gs n = + let more = if n > length gs then ", sending " <> sortName <> " " <> tshow (length gs) else "" + in tshow n <> " group(s) listed" <> more <> "." + updateSearchRequest :: SearchType -> (GroupInfo, GroupReg) -> IO () + updateSearchRequest searchType (GroupInfo {groupId}, _) = do searchTime <- getCurrentTime - let search = SearchRequest {searchType, searchTime, sentGroups} + let search = SearchRequest {searchType, searchTime, lastGroup = groupId} atomically $ TM.insert (contactId' ct) search searchRequests sendFoundGroups reply gs moreGroups = void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (GIS GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}} GroupSummary {currentMembers}) = - let membersStr = "_" <> tshow currentMembers <> " members_" + foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary}, _) = + let membersStr = "_" <> membersCountStr p groupSummary <> "_" showId = if isAdmin then tshow groupId <> ". " else "" - text = showId <> groupInfoText p <> "\n" <> membersStr + text = T.unlines $ [showId <> groupInfoText p, membersStr] ++ knockingStr memberAdmission in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_) moreMsg = (Nothing, MCText $ "Send /next for " <> tshow moreGroups <> " more result(s).") deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO () deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of - DCApproveGroup {groupId, displayName = n, groupApprovalId} -> - withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId} -> - readTVarIO (groupRegStatus gr) >>= \case + DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} -> + withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} -> + case groupRegStatus gr of GRSPendingApproval gaId | gaId == groupApprovalId -> do + let GroupInfo {groupProfile = GroupProfile {publicGroup = pg_}} = g + isPublicGroup_ = isJust pg_ + gt = maybe "group" groupTypeStr' pg_ getDuplicateGroup g >>= \case - Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." + Left e -> sendReply $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendReply $ "The " <> gt <> " " <> groupRef <> " is already listed in the directory." _ -> do - getGroupRolesStatus g gr >>= \case - Just GRSOk -> do - setGroupStatus st gr GRSActive - let approved = "The group " <> userGroupReference' gr n <> " is approved" - notifyOwner gr $ - (approved <> " and listed in directory - please moderate it!\n") - <> "_Please note_: if you change the group profile it will be hidden from directory until it is re-approved.\n\n" - <> "Supported commands:\n" - <> ("/'filter " <> tshow ugrId <> "' - to configure anti-spam filter.\n") - <> ("/'role " <> tshow ugrId <> "' - to set default member role.\n") - <> ("/'link " <> tshow ugrId <> "' - to view/upgrade group link.") - invited <- - forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do - inviteToOwnersGroup og gr $ \case - Right () -> do - owner <- groupOwnerInfo groupRef $ dbContactId gr - pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName - Left err -> pure err - sendReply $ "Group approved!" <> maybe "" ("\n" <>) invited - notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited - Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin - Just GRSContactNotOwner -> replyNotApproved "user is not an owner." - Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin - Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers." - where - replyNotApproved reason = sendReply $ "Group is not approved: " <> reason - serviceNotAdmin = serviceName <> " is not an admin." + rolesOk <- if isPublicGroup_ then pure (Right GRSOk) else getGroupRolesStatus g gr + case rolesOk of + Right GRSOk -> do + let grPromoted' + | promoted || knownCt `elem` superUsers = fromMaybe promoted promote + | otherwise = False + setGroupStatusPromo sendReply st env cc gr GRSActive grPromoted' $ do + let approved = "The " <> gt <> " " <> userGroupReference' gr n <> " is approved" + let commands + | isPublicGroup_ = "" + | otherwise = + "\n\nSupported commands:\n" + <> ("/'filter " <> tshow ugrId <> "' - to configure anti-spam filter.\n") + <> ("/'role " <> tshow ugrId <> "' - to set default member role.\n") + <> ("/'link " <> tshow ugrId <> "' - to view/upgrade group link.") + notifyOwner gr $ + (approved <> " and listed in directory - please moderate it!\n") + <> "_Please note_: if you change the " <> gt <> " profile it will be hidden from directory until it is re-approved." + <> commands + invited <- + forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + owner <- groupOwnerInfo groupRef $ dbContactId gr + pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName + Left err -> pure err + sendReply $ T.toTitle gt <> " approved" <> (if grPromoted' then " (promoted)" else "") <> "!" <> maybe "" ("\n" <>) invited + notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited + Right GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin + Right GRSContactNotOwner -> replyNotApproved "user is not an owner." + Right GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin + Left e -> sendReply $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e + where + replyNotApproved reason = sendReply $ "Group is not approved: " <> reason + serviceNotAdmin = serviceName <> " is not an admin." | otherwise -> sendReply "Incorrect approval code" _ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval." where @@ -968,32 +1331,36 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName DCSuspendGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSActive -> do - setGroupStatus st gr GRSSuspended + case groupRegStatus gr of + GRSActive -> setGroupStatus sendReply st env cc groupId GRSSuspended $ \gr' -> do let suspended = "The group " <> userGroupReference' gr gName <> " is suspended" - notifyOwner gr $ suspended <> " and hidden from directory. Please contact the administrators." + notifyOwner gr' $ suspended <> " and hidden from directory. Please contact the administrators." sendReply "Group suspended!" notifyOtherSuperUsers $ suspended <> " by " <> viewName (localDisplayName' ct) _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." DCResumeGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspended -> do - setGroupStatus st gr GRSActive + case groupRegStatus gr of + GRSSuspended -> setGroupStatus sendReply st env cc groupId GRSActive $ \gr' -> do let groupStr = "The group " <> userGroupReference' gr gName - notifyOwner gr $ groupStr <> " is listed in the directory again!" + notifyOwner gr' $ groupStr <> " is listed in the directory again!" sendReply "Group listing resumed!" notifyOtherSuperUsers $ groupStr <> " listing resumed by " <> viewName (localDisplayName' ct) _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." - DCListLastGroups count -> listGroups count False - DCListPendingGroups count -> listGroups count True + DCListLastGroups count -> + listLastGroups cc user count >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId True $ first reverse gs + DCListPendingGroups count -> + listPendingGroups cc user count >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId True $ first reverse gs DCSendToGroupOwner groupId gName msg -> do let groupRef = groupReference' groupId gName - withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId} -> do + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do notifyOwner gr msg - owner <- groupOwnerInfo groupRef dbContactId + owner <- groupOwnerInfo groupRef ctId sendReply $ "Forwarded to " <> owner DCInviteOwnerToGroup groupId gName -> case ownersGroup of Just og@KnownGroup {localDisplayName = ogName} -> @@ -1002,7 +1369,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Right () -> do let groupRef = groupReference' groupId gName owner <- groupOwnerInfo groupRef ctId - let invited = " invited " <> owner <> " to owners' group " <> viewName ogName + let invited = " invited " <> owner <> " to owners' group " <> viewName ogName notifyOtherSuperUsers $ viewName (localDisplayName' ct) <> invited sendReply $ "you" <> invited Left err -> sendReply err @@ -1015,17 +1382,6 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName knownCt = knownContact ct sendReply = mkSendReply ct ciId notifyOtherSuperUsers s = withSuperUsers $ \ctId -> unless (ctId == contactId' ct) $ sendMessage' cc ctId s - listGroups count pending = - readTVarIO (groupRegs st) >>= \groups -> do - grs <- - if pending - then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups - else pure groups - sendReply $ tshow (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> tshow count else "") - void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do - ct_ <- getContact' cc user dbContactId - let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ - sendGroupInfo ct gr dbGroupId $ Just ownerStr inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = sendChatCmd cc (APIListMembers ogId) >>= \case @@ -1039,7 +1395,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName r -> contErr r r -> contErr r where - alreadyMember = isJust . find ((Just ctId == ) . memberContactId) + alreadyMember = any (\m -> memberContactId m == Just ctId && memberCurrent m) contErr r = do let err = "error inviting contact ID " <> tshow ctId <> " to owners' group: " <> tshow r putStrLn $ T.unpack err @@ -1048,11 +1404,17 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName owner_ <- getContact' cc user dbContactId let ownerInfo = "the owner of the group " <> groupRef ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " - pure $ maybe "" ownerName owner_ <> ownerInfo + pure $ either (const "") ownerName owner_ <> ownerInfo deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd | knownContact ct `elem` superUsers = case cmd of + DCPromoteGroup groupId gName promote' -> + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {groupRegStatus, promoted} -> do + let notify = sendReply $ "Group promotion " <> (if promote' then "enabled" <> (if groupRegStatus == GRSActive then "." else ", but the group is not listed.") else "disabled.") + if promote' /= promoted + then setGroupPromoted sendReply st env cc gr promote' notify + else notify DCExecuteCommand cmdStr -> sendChatCmdStr cc cmdStr >>= \case Right r -> do @@ -1077,58 +1439,90 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg_ sendReply gId gName_ action = - getGroup cc user gId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow gId <> " not found (getGroup)" - Just g@GroupInfo {groupProfile = GroupProfile {displayName}} + getGroupAndReg cc user gId >>= \case + Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) | maybe False (displayName ==) gName_ -> - getGroupReg st gId >>= \case - Nothing -> sendReply $ "Registration for group ID " <> tshow gId <> " not found (getGroupReg)" - Just gr -> action g gr + action g gr | otherwise -> sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName - sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO () - sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do - grStatus <- readTVarIO $ groupRegStatus gr - let statusStr = "Status: " <> groupRegStatusText grStatus - getGroupAndSummary cc user dbGroupId >>= \case - Just (GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do - let membersStr = "_" <> tshow currentMembers <> " members_" + getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] + getOwnersInfo gs = + fmap (either (\e -> map (,Just (Left e)) gs) id) $ withDB' "getOwnersInfo" cc $ \db -> + mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (vr cc) user $ dbContactId gr) gs + + sendGroupsInfo :: Contact -> ChatItemId -> Bool -> ([(GroupInfo, GroupReg)], Int) -> IO () + sendGroupsInfo ct ciId isAdmin (gs, n) = do + let more = if n > length gs then ", showing the last " <> tshow (length gs) else "" + replyMsg = (Just ciId, MCText $ tshow n <> " registered group(s)" <> more) + gs' <- if isAdmin then getOwnersInfo gs else pure $ map (,Nothing) gs + sendComposedMessages_ cc (SRDirect $ contactId' ct) $ replyMsg :| map groupMessage gs' + where + groupMessage ((g, gr), ct_) = + let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary} = g + GroupReg {userGroupRegId, groupRegStatus} = gr + useGroupId = if isAdmin then groupId else userGroupRegId + statusStr = "Status: " <> groupRegStatusText groupRegStatus + membersStr = "_" <> membersCountStr p groupSummary <> "_" cmds = "/'role " <> tshow useGroupId <> "', /'filter " <> tshow useGroupId <> "'" - text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] <> maybeToList ownerStr_ <> [membersStr, statusStr, cmds] + ownerStr = maybe "" (("Owner: " <>) . either (("getContact error: " <>) . T.pack) localDisplayName') ct_ + text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr] ++ knockingStr memberAdmission ++ [cmds] msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ - sendComposedMessage cc ct Nothing msg - Nothing -> do - let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr] - sendComposedMessage cc ct Nothing $ MCText text + in (Nothing, msg) -getContact' :: ChatController -> User -> ContactId -> IO (Maybe Contact) -getContact' cc user ctId = withDB "getContact" cc $ \db -> getContact db (vr cc) user ctId +setGroupStatusPromo :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> Bool -> IO () -> IO () +setGroupStatusPromo sendReply st env cc GroupReg {dbGroupId = gId} grStatus' grPromoted' continue = do + let status' = grDirectoryStatus grStatus' + setGroupStatusPromoStore cc gId grStatus' grPromoted' >>= \case + Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (status, grPromoted) -> do + when ((status == DSListed || status' == DSListed) && (status /= status' || grPromoted /= grPromoted')) $ + listingsUpdated env + logGUpdateStatus st gId grStatus' + logGUpdatePromotion st gId grPromoted' + continue -getGroup :: ChatController -> User -> GroupId -> IO (Maybe GroupInfo) -getGroup cc user gId = withDB "getGroupInfo" cc $ \db -> getGroupInfo db (vr cc) user gId +addGroupReg :: (Text -> IO ()) -> DirectoryLog -> ChatController -> Contact -> GroupInfo -> GroupRegStatus -> (GroupReg -> IO ()) -> IO () +addGroupReg sendMsg st cc ct g@GroupInfo {groupId} grStatus continue = + addGroupRegStore cc ct g grStatus >>= \case + Left e -> sendMsg $ "Error creating group registation for group " <> tshow groupId <> ": " <> T.pack e + Right gr -> do + logGCreate st gr + continue gr -withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Maybe a) -withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a +setGroupStatus :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupId -> GroupRegStatus -> (GroupReg -> IO ()) -> IO () +setGroupStatus sendMsg st env cc gId grStatus' continue = do + let status' = grDirectoryStatus grStatus' + setGroupStatusStore cc gId grStatus' >>= \case + Left e -> sendMsg $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (grStatus, gr) -> do + let status = grDirectoryStatus grStatus + when ((status == DSListed || status' == DSListed) && status /= status') $ listingsUpdated env + logGUpdateStatus st gId grStatus' + continue gr -withDB :: Text -> ChatController -> (DB.Connection -> ExceptT StoreError IO a) -> IO (Maybe a) -withDB cxt ChatController {chatStore} action = do - r_ :: Either ChatError a <- withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors - case r_ of - Right r -> pure $ Just r - Left e -> Nothing <$ logError ("Database error: " <> cxt <> " " <> tshow e) +setGroupPromoted :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () -> IO () +setGroupPromoted sendReply st env cc GroupReg {dbGroupId = gId} grPromoted' continue = + setGroupPromotedStore cc gId grPromoted' >>= \case + Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (status, grPromoted) -> do + when (status == DSListed && grPromoted' /= grPromoted) $ listingsUpdated env + logGUpdatePromotion st gId grPromoted' + continue -getGroupAndSummary :: ChatController -> User -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) -getGroupAndSummary cc user gId = - withDB "getGroupAndSummary" cc $ \db -> (,) <$> getGroupInfo db (vr cc) user gId <*> liftIO (getGroupSummary db user gId) +updateGroupListingFiles :: ChatController -> User -> FilePath -> IO () +updateGroupListingFiles cc u dir = + getAllListedGroups cc u >>= \case + Right gs -> generateListing dir gs + Left e -> logError $ "generateListing error: failed to read groups: " <> T.pack e -vr :: ChatController -> VersionRangeChat -vr ChatController {config = ChatConfig {chatVRange}} = chatVRange -{-# INLINE vr #-} +getContact' :: ChatController -> User -> ContactId -> IO (Either String Contact) +getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (vr cc) user ctId -getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Maybe GroupLink) +getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupLink) getGroupLink' cc user gInfo = - withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo + withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact) setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 9498fedf95..b5f7220724 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -1,28 +1,48 @@ {-# LANGUAGE BangPatterns #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Store - ( DirectoryStore (..), + ( DirectoryLog (..), GroupReg (..), GroupRegStatus (..), UserGroupRegId, GroupApprovalId, DirectoryGroupData (..), DirectoryMemberAcceptance (..), + DirectoryStatus (..), ProfileCondition (..), - restoreDirectoryStore, - addGroupReg, + DirectoryLogRecord (..), + openDirectoryLog, + readDirectoryLogData, + addGroupRegStore, + insertGroupReg, delGroupReg, - setGroupStatus, + deleteGroupReg, + setGroupStatusStore, + setGroupStatusPromoStore, + setGroupPromotedStore, + grDirectoryStatus, setGroupRegOwner, - getGroupReg, getUserGroupReg, getUserGroupRegs, - filterListedGroups, + getAllGroupRegs_, + getDuplicateGroupRegs, + getGroupReg, + getGroupAndReg, + listLastGroups, + listPendingGroups, + getAllListedGroups, + getAllListedGroups_, + searchListedGroups, groupRegStatusText, pendingApproval, groupRemoved, @@ -31,13 +51,21 @@ module Directory.Store noJoinFilter, basicJoinFilter, moderateJoinFilter, - strongJoinFilter + strongJoinFilter, + groupDBError, + logGCreate, + logGDelete, + logGUpdateOwner, + logGUpdateStatus, + logGUpdatePromotion, ) where -import Control.Concurrent.STM +import Control.Applicative ((<|>)) import Control.Monad -import Data.Aeson ((.=), (.:)) +import Control.Monad.Except +import Control.Monad.IO.Class +import Data.Aeson ((.:), (.=)) import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT @@ -45,41 +73,51 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) -import Data.List (find, foldl', sortOn) +import Data.List (sortOn) import Data.Map (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust) -import Data.Set (Set) -import qualified Data.Set as S import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Clock.System (systemEpochDay) +import Directory.Search +import Directory.Util +import Simplex.Chat.Controller +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Chat.Store +import Simplex.Chat.Store.Groups +import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom) import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.DB (BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) -import Simplex.Messaging.Util (ifM) -import System.Directory (doesFileExist, renameFile) +import Simplex.Messaging.Util (eitherToMaybe, firstRow, maybeFirstRow', safeDecodeUtf8) import System.IO (BufferMode (..), Handle, IOMode (..), hSetBuffering, openFile) -data DirectoryStore = DirectoryStore - { groupRegs :: TVar [GroupReg], - listedGroups :: TVar (Set GroupId), - reservedGroups :: TVar (Set GroupId), - directoryLogFile :: Maybe Handle +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +data DirectoryLog = DirectoryLog + { directoryLogFile :: Maybe Handle } data GroupReg = GroupReg { dbGroupId :: GroupId, userGroupRegId :: UserGroupRegId, dbContactId :: ContactId, - dbOwnerMemberId :: TVar (Maybe GroupMemberId), - groupRegStatus :: TVar GroupRegStatus - } - -data GroupRegData = GroupRegData - { dbGroupId_ :: GroupId, - userGroupRegId_ :: UserGroupRegId, - dbContactId_ :: ContactId, - dbOwnerMemberId_ :: Maybe GroupMemberId, - groupRegStatus_ :: GroupRegStatus + dbOwnerMemberId :: Maybe GroupMemberId, + groupRegStatus :: GroupRegStatus, + promoted :: Bool, + createdAt :: UTCTime } data DirectoryGroupData = DirectoryGroupData @@ -140,7 +178,7 @@ data GroupRegStatus | GRSSuspended | GRSSuspendedBadRoles | GRSRemoved - deriving (Show) + deriving (Eq, Show) pendingApproval :: GroupRegStatus -> Bool pendingApproval = \case @@ -153,6 +191,7 @@ groupRemoved = \case _ -> False data DirectoryStatus = DSListed | DSReserved | DSRegistered | DSRemoved + deriving (Eq) groupRegStatusText :: GroupRegStatus -> Text groupRegStatusText = \case @@ -188,105 +227,249 @@ toCustomData :: DirectoryGroupData -> CustomData toCustomData DirectoryGroupData {memberAcceptance} = CustomData $ JM.fromList ["memberAcceptance" .= memberAcceptance] -addGroupReg :: DirectoryStore -> Contact -> GroupInfo -> GroupRegStatus -> IO UserGroupRegId -addGroupReg st ct GroupInfo {groupId} grStatus = do - grData <- addGroupReg_ - logGCreate st grData - pure $ userGroupRegId_ grData +addGroupRegStore :: ChatController -> Contact -> GroupInfo -> GroupRegStatus -> IO (Either String GroupReg) +addGroupRegStore cc Contact {contactId = dbContactId} GroupInfo {groupId = dbGroupId} groupRegStatus = + withDB' "addGroupRegStore" cc $ \db -> do + createdAt <- getCurrentTime + maxUgrId <- + maybeFirstRow' 0 (fromMaybe 0 . fromOnly) $ + DB.query db "SELECT MAX(user_group_reg_id) FROM sx_directory_group_regs WHERE contact_id = ?" (Only dbContactId) + let gr = GroupReg {dbGroupId, userGroupRegId = maxUgrId + 1, dbContactId, dbOwnerMemberId = Nothing, groupRegStatus, promoted = False, createdAt} + insertGroupReg db gr + pure gr + +insertGroupReg :: DB.Connection -> GroupReg -> IO () +insertGroupReg db GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} = do + DB.execute + db + [sql| + INSERT INTO sx_directory_group_regs + (group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt, createdAt) + +delGroupReg :: ChatController -> GroupId -> IO (Either String ()) +delGroupReg cc gId = withDB' "delGroupReg" cc (`deleteGroupReg` gId) + +deleteGroupReg :: DB.Connection -> GroupId -> IO () +deleteGroupReg db gId = DB.execute db "DELETE FROM sx_directory_group_regs WHERE group_id = ?" (Only gId) + +setGroupStatusStore :: ChatController -> GroupId -> GroupRegStatus -> IO (Either String (GroupRegStatus, GroupReg)) +setGroupStatusStore cc gId grStatus' = + withDB "setGroupStatusStore" cc $ \db -> do + gr <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, updated_at = ? WHERE group_id = ?" (grStatus', ts, gId) + pure (groupRegStatus gr, gr {groupRegStatus = grStatus'}) + +setGroupStatusPromoStore :: ChatController -> GroupId -> GroupRegStatus -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupStatusPromoStore cc gId grStatus' grPromoted' = + withDB "setGroupStatusPromoStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, group_promoted = ?, updated_at = ? WHERE group_id = ?" (grStatus', BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +setGroupPromotedStore :: ChatController -> GroupId -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupPromotedStore cc gId grPromoted' = + withDB "setGroupPromotedStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_promoted = ?, updated_at = ? WHERE group_id = ?" (BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +groupDBError :: StoreError -> String +groupDBError = \case + SEGroupNotFound _ -> "group not found" + e -> show e + +setGroupRegOwner :: ChatController -> GroupId -> GroupMember -> IO (Either String ()) +setGroupRegOwner cc gId owner = do + ts <- getCurrentTime + withDB' "setGroupRegOwner" cc $ \db -> + DB.execute + db + [sql| + UPDATE sx_directory_group_regs + SET owner_member_id = ?, updated_at = ? + WHERE group_id = ? + |] + (groupMemberId' owner, ts, gId) + +getGroupReg :: ChatController -> GroupId -> IO (Either String GroupReg) +getGroupReg cc gId = withDB "getGroupReg" cc (`getGroupReg_` gId) + +getGroupReg_ :: DB.Connection -> GroupId -> ExceptT String IO GroupReg +getGroupReg_ db gId = + ExceptT $ firstRow rowToGroupReg "group registration not found" $ + DB.query + db + [sql| + SELECT group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at + FROM sx_directory_group_regs + WHERE group_id = ? + |] + (Only gId) + +getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) +getGroupAndReg cc user@User {userId, userContactId} gId = + withDB "getGroupAndReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + +getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) +getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = + withDB "getUserGroupReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show ugrId ++ " not found") $ + DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) + +getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) +getUserGroupRegs cc user@User {userId, userContactId} ctId = + withDB' "getUserGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) + +getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) +getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (vr cc) user + +getAllListedGroups_ :: DB.Connection -> VersionRangeChat -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] +getAllListedGroups_ db vr' user@User {userId, userContactId} = + DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) + >>= mapM (withGroupLink . toGroupInfoReg vr' user) where - addGroupReg_ = do - let grData = GroupRegData {dbGroupId_ = groupId, userGroupRegId_ = 1, dbContactId_ = ctId, dbOwnerMemberId_ = Nothing, groupRegStatus_ = grStatus} - gr <- dataToGroupReg grData - atomically $ stateTVar (groupRegs st) $ \grs -> - let ugrId = 1 + foldl' maxUgrId 0 grs - grData' = grData {userGroupRegId_ = ugrId} - gr' = gr {userGroupRegId = ugrId} - in (grData', gr' : grs) - ctId = contactId' ct - maxUgrId mx GroupReg {dbContactId, userGroupRegId} - | dbContactId == ctId && userGroupRegId > mx = userGroupRegId - | otherwise = mx + withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) -delGroupReg :: DirectoryStore -> GroupReg -> IO () -delGroupReg st GroupReg {dbGroupId = gId, groupRegStatus} = do - logGDelete st gId - atomically $ writeTVar groupRegStatus GRSRemoved - atomically $ unlistGroup st gId - atomically $ modifyTVar' (groupRegs st) $ filter ((gId /=) . dbGroupId) - -setGroupStatus :: DirectoryStore -> GroupReg -> GroupRegStatus -> IO () -setGroupStatus st gr grStatus = do - logGUpdateStatus st (dbGroupId gr) grStatus - atomically $ do - writeTVar (groupRegStatus gr) grStatus - updateListing st $ dbGroupId gr +searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = + withDB' "searchListedGroups" cc $ \db -> + case searchType of + STAll -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " + STRecent -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY r.created_at DESC, r.group_reg_id ASC " + STSearch search -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s) + pure (gs, n) + where + s = T.toLower search + countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " where - updateListing = case grDirectoryStatus grStatus of - DSListed -> listGroup - DSReserved -> reserveGroup - DSRegistered -> unlistGroup - DSRemoved -> unlistGroup + groups = (map (toGroupInfoReg (vr cc) user) <$>) + count = maybeFirstRow' 0 fromOnly + listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " + countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " + searchCond = + [sql| + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + |] -setGroupRegOwner :: DirectoryStore -> GroupReg -> GroupMember -> IO () -setGroupRegOwner st gr owner = do - let memberId = groupMemberId' owner - logGUpdateOwner st (dbGroupId gr) memberId - atomically $ writeTVar (dbOwnerMemberId gr) (Just memberId) +getAllGroupRegs_ :: DB.Connection -> User -> IO [(GroupInfo, GroupReg)] +getAllGroupRegs_ db user@User {userId, userContactId} = + map (toGroupInfoReg supportedChatVRange user) + <$> DB.query db groupReqQuery (userId, userContactId) -getGroupReg :: DirectoryStore -> GroupId -> IO (Maybe GroupReg) -getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVarIO (groupRegs st) +getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) +getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = + withDB' "getDuplicateGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) -getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> IO (Maybe GroupReg) -getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVarIO (groupRegs st) +listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listLastGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" + pure (gs, n) -getUserGroupRegs :: DirectoryStore -> ContactId -> IO [GroupReg] -getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVarIO (groupRegs st) +listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listPendingGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" + pure (gs, n) -filterListedGroups :: DirectoryStore -> [GroupInfoSummary] -> IO [GroupInfoSummary] -filterListedGroups st gs = do - lgs <- readTVarIO $ listedGroups st - pure $ filter (\(GIS GroupInfo {groupId} _) -> groupId `S.member` lgs) gs +toGroupInfoReg :: VersionRangeChat -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg vr' User {userContactId} (groupRow :. grRow) = + (toGroupInfo vr' userContactId [] groupRow, rowToGroupReg grRow) -listGroup :: DirectoryStore -> GroupId -> STM () -listGroup st gId = do - modifyTVar' (listedGroups st) $ S.insert gId - modifyTVar' (reservedGroups st) $ S.delete gId +type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) -reserveGroup :: DirectoryStore -> GroupId -> STM () -reserveGroup st gId = do - modifyTVar' (listedGroups st) $ S.delete gId - modifyTVar' (reservedGroups st) $ S.insert gId +rowToGroupReg :: GroupRegRow -> GroupReg +rowToGroupReg (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt) = + GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} -unlistGroup :: DirectoryStore -> GroupId -> STM () -unlistGroup st gId = do - modifyTVar' (listedGroups st) $ S.delete gId - modifyTVar' (reservedGroups st) $ S.delete gId +groupReqQuery :: Query +groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupInfoQueryFrom <> groupRegFromCond + where + groupRegFields = ", r.group_id, r.user_group_reg_id, r.contact_id, r.owner_member_id, r.group_reg_status, r.group_promoted, r.created_at " + groupRegFromCond = " JOIN sx_directory_group_regs r ON r.group_id = g.group_id WHERE g.user_id = ? AND mu.contact_id = ? " data DirectoryLogRecord - = GRCreate GroupRegData + = GRCreate GroupReg | GRDelete GroupId | GRUpdateStatus GroupId GroupRegStatus + | GRUpdatePromotion GroupId Bool | GRUpdateOwner GroupId GroupMemberId data DLRTag = GRCreate_ | GRDelete_ | GRUpdateStatus_ + | GRUpdatePromotion_ | GRUpdateOwner_ -logDLR :: DirectoryStore -> DirectoryLogRecord -> IO () +logDLR :: DirectoryLog -> DirectoryLogRecord -> IO () logDLR st r = forM_ (directoryLogFile st) $ \h -> B.hPutStrLn h (strEncode r) -logGCreate :: DirectoryStore -> GroupRegData -> IO () +logGCreate :: DirectoryLog -> GroupReg -> IO () logGCreate st = logDLR st . GRCreate -logGDelete :: DirectoryStore -> GroupId -> IO () +logGDelete :: DirectoryLog -> GroupId -> IO () logGDelete st = logDLR st . GRDelete -logGUpdateStatus :: DirectoryStore -> GroupId -> GroupRegStatus -> IO () +logGUpdateStatus :: DirectoryLog -> GroupId -> GroupRegStatus -> IO () logGUpdateStatus st gId = logDLR st . GRUpdateStatus gId -logGUpdateOwner :: DirectoryStore -> GroupId -> GroupMemberId -> IO () +logGUpdatePromotion :: DirectoryLog -> GroupId -> Bool -> IO () +logGUpdatePromotion st gId = logDLR st . GRUpdatePromotion gId + +logGUpdateOwner :: DirectoryLog -> GroupId -> GroupMemberId -> IO () logGUpdateOwner st gId = logDLR st . GRUpdateOwner gId instance StrEncoding DLRTag where @@ -294,12 +477,14 @@ instance StrEncoding DLRTag where GRCreate_ -> "GCREATE" GRDelete_ -> "GDELETE" GRUpdateStatus_ -> "GSTATUS" + GRUpdatePromotion_ -> "GPROMOTE" GRUpdateOwner_ -> "GOWNER" strP = A.takeTill (== ' ') >>= \case "GCREATE" -> pure GRCreate_ "GDELETE" -> pure GRDelete_ "GSTATUS" -> pure GRUpdateStatus_ + "GPROMOTE" -> pure GRUpdatePromotion_ "GOWNER" -> pure GRUpdateOwner_ _ -> fail "invalid DLRTag" @@ -308,30 +493,35 @@ instance StrEncoding DirectoryLogRecord where GRCreate gr -> strEncode (GRCreate_, gr) GRDelete gId -> strEncode (GRDelete_, gId) GRUpdateStatus gId grStatus -> strEncode (GRUpdateStatus_, gId, grStatus) + GRUpdatePromotion gId promoted -> strEncode (GRUpdatePromotion_, gId, promoted) GRUpdateOwner gId grOwnerId -> strEncode (GRUpdateOwner_, gId, grOwnerId) strP = strP_ >>= \case GRCreate_ -> GRCreate <$> strP GRDelete_ -> GRDelete <$> strP GRUpdateStatus_ -> GRUpdateStatus <$> A.decimal <*> _strP + GRUpdatePromotion_ -> GRUpdatePromotion <$> A.decimal <*> _strP GRUpdateOwner_ -> GRUpdateOwner <$> A.decimal <* A.space <*> A.decimal -instance StrEncoding GroupRegData where - strEncode GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = - B.unwords - [ "group_id=" <> strEncode dbGroupId_, - "user_group_id=" <> strEncode userGroupRegId_, - "contact_id=" <> strEncode dbContactId_, - "owner_member_id=" <> strEncode dbOwnerMemberId_, - "status=" <> strEncode groupRegStatus_ +instance StrEncoding GroupReg where + strEncode GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted} = + B.unwords $ + [ "group_id=" <> strEncode dbGroupId, + "user_group_id=" <> strEncode userGroupRegId, + "contact_id=" <> strEncode dbContactId, + "owner_member_id=" <> strEncode dbOwnerMemberId, + "status=" <> strEncode groupRegStatus ] + <> ["promoted=" <> strEncode promoted | promoted] strP = do - dbGroupId_ <- "group_id=" *> strP_ - userGroupRegId_ <- "user_group_id=" *> strP_ - dbContactId_ <- "contact_id=" *> strP_ - dbOwnerMemberId_ <- "owner_member_id=" *> strP_ - groupRegStatus_ <- "status=" *> strP - pure GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} + dbGroupId <- "group_id=" *> strP_ + userGroupRegId <- "user_group_id=" *> strP_ + dbContactId <- "contact_id=" *> strP_ + dbOwnerMemberId <- "owner_member_id=" *> strP_ + groupRegStatus <- "status=" *> strP + promoted <- (" promoted=" *> strP) <|> pure False + let createdAt = UTCTime systemEpochDay 0 + pure GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} instance StrEncoding GroupRegStatus where strEncode = \case @@ -355,70 +545,30 @@ instance StrEncoding GroupRegStatus where "removed" -> pure GRSRemoved _ -> fail "invalid GroupRegStatus" -dataToGroupReg :: GroupRegData -> IO GroupReg -dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = do - dbOwnerMemberId <- newTVarIO dbOwnerMemberId_ - groupRegStatus <- newTVarIO groupRegStatus_ - pure - GroupReg - { dbGroupId = dbGroupId_, - userGroupRegId = userGroupRegId_, - dbContactId = dbContactId_, - dbOwnerMemberId, - groupRegStatus - } +instance ToField GroupRegStatus where toField = toField . safeDecodeUtf8 . strEncode -restoreDirectoryStore :: Maybe FilePath -> IO DirectoryStore -restoreDirectoryStore = \case - Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= newDirectoryStore . Just) - Nothing -> newDirectoryStore Nothing +instance FromField GroupRegStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +openDirectoryLog :: Maybe FilePath -> IO DirectoryLog +openDirectoryLog = \case + Just f -> DirectoryLog . Just <$> openLogFile f + Nothing -> pure $ DirectoryLog Nothing where - newFile f = do - h <- openFile f WriteMode + openLogFile f = do + h <- openFile f AppendMode hSetBuffering h LineBuffering pure h - restore f = do - grs <- readDirectoryData f - renameFile f (f <> ".bak") - h <- writeDirectoryData f grs -- compact - mkDirectoryStore h grs -emptyStoreData :: ([GroupReg], Set GroupId, Set GroupId) -emptyStoreData = ([], S.empty, S.empty) - -newDirectoryStore :: Maybe Handle -> IO DirectoryStore -newDirectoryStore = (`mkDirectoryStore_` emptyStoreData) - -mkDirectoryStore :: Handle -> [GroupRegData] -> IO DirectoryStore -mkDirectoryStore h groups = - foldM addGroupRegData emptyStoreData groups >>= mkDirectoryStore_ (Just h) - where - addGroupRegData (!grs, !listed, !reserved) gr@GroupRegData {dbGroupId_ = gId} = do - gr' <- dataToGroupReg gr - let grs' = gr' : grs - pure $ case grDirectoryStatus $ groupRegStatus_ gr of - DSListed -> (grs', S.insert gId listed, reserved) - DSReserved -> (grs', listed, S.insert gId reserved) - DSRegistered -> (grs', listed, reserved) - DSRemoved -> (grs, listed, reserved) - -mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> IO DirectoryStore -mkDirectoryStore_ h (grs, listed, reserved) = do - groupRegs <- newTVarIO grs - listedGroups <- newTVarIO listed - reservedGroups <- newTVarIO reserved - pure DirectoryStore {groupRegs, listedGroups, reservedGroups, directoryLogFile = h} - -readDirectoryData :: FilePath -> IO [GroupRegData] -readDirectoryData f = - sortOn dbGroupId_ . M.elems +readDirectoryLogData :: FilePath -> IO [GroupReg] +readDirectoryLogData f = + sortOn dbGroupId . M.elems <$> (foldM processDLR M.empty . B.lines =<< B.readFile f) where - processDLR :: Map GroupId GroupRegData -> ByteString -> IO (Map GroupId GroupRegData) + processDLR :: Map GroupId GroupReg -> ByteString -> IO (Map GroupId GroupReg) processDLR m l = case strDecode l of Left e -> m <$ putStrLn ("Error parsing log record: " <> e <> ", " <> B.unpack (B.take 80 l)) Right r -> case r of - GRCreate gr@GroupRegData {dbGroupId_ = gId} -> do + GRCreate gr@GroupReg {dbGroupId = gId} -> do when (isJust $ M.lookup gId m) $ putStrLn $ "Warning: duplicate group with ID " <> show gId <> ", group replaced." @@ -426,16 +576,12 @@ readDirectoryData f = GRDelete gId -> case M.lookup gId m of Just _ -> pure $ M.delete gId m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", deletion ignored.") - GRUpdateStatus gId groupRegStatus_ -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {groupRegStatus_} m + GRUpdateStatus gId groupRegStatus -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {groupRegStatus} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", status update ignored.") + GRUpdatePromotion gId promoted -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {promoted} m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", promotion update ignored.") GRUpdateOwner gId grOwnerId -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {dbOwnerMemberId_ = Just grOwnerId} m + Just gr -> pure $ M.insert gId gr {dbOwnerMemberId = Just grOwnerId} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", owner update ignored.") - -writeDirectoryData :: FilePath -> [GroupRegData] -> IO Handle -writeDirectoryData f grs = do - h <- openFile f WriteMode - hSetBuffering h LineBuffering - forM_ grs $ B.hPutStrLn h . strEncode . GRCreate - pure h diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs new file mode 100644 index 0000000000..aa101d7bf7 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -0,0 +1,149 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Migrate where + +import Control.Concurrent.STM +import Control.Monad +import Control.Monad.Except +import qualified Data.ByteString.Char8 as B +import Data.List (find) +import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import Directory.Listing +import Directory.Options +import Directory.Store +import Simplex.Chat (createChatDatabase) +import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Store.Groups (getHostMember) +import Simplex.Chat.Store.Profiles (getUsers) +import Simplex.Chat.Store.Shared (getGroupInfo) +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, migrateDBSchema) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import Simplex.Messaging.Encoding.String +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (whenM) +import System.Directory (doesFileExist, renamePath) +import System.Exit (exitFailure) +import System.IO (IOMode (..), withFile) + +#if defined(dbPostgres) +import Directory.Store.Postgres.Migrations +#else +import Directory.Store.SQLite.Migrations +#endif + +runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO () +runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore = + migrateDBSchema + chatStore + (toDBOpts dbOptions chatSuffix False []) + (Just "sx_directory_migrations") + directorySchemaMigrations + MigrationConfig {confirm, backupPath = Nothing} + >>= either (exit . ("directory migrations " <>) . show) pure + where + DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations}} = opts + confirm = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + +checkDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +checkDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + withActiveUser st $ \user -> withTransaction st $ \db -> do + mapM_ (verifyGroupRegistration db user) gs + putStrLn $ show (length gs) <> " group registrations OK" + +importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO () +importDirectoryLogToDB opts cfg = do + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + ctRegs <- TM.emptyIO + withActiveUser st $ \user -> withTransaction st $ \db -> do + forM_ gs $ \gr -> + whenM (verifyGroupRegistration db user gr) $ do + putStrLn $ "importing group " <> show (dbGroupId gr) + insertGroupReg db =<< fixUserGroupRegId ctRegs gr + renamePath logFile (logFile ++ ".bak") + putStrLn $ show (length gs) <> " group registrations imported" + where + fixUserGroupRegId ctRegs gr@GroupReg {dbGroupId, dbContactId} = do + ugIds <- fromMaybe [] <$> TM.lookupIO dbContactId ctRegs + gr' <- + if userGroupRegId gr `elem` ugIds + then do + let ugId = maximum ugIds + 1 + putStrLn $ "Warning: updating userGroupRegId for group " <> show dbGroupId <> ", contact " <> show dbContactId + pure gr {userGroupRegId = ugId} + else pure gr + atomically $ TM.insert dbContactId (userGroupRegId gr' : ugIds) ctRegs + pure gr' + +exit :: String -> IO a +exit err = putStrLn ("Error: " <> err) >> exitFailure + +exportDBToDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +exportDBToDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + whenM (doesFileExist logFile) $ exit $ "directory log file " ++ logFile ++ " already exists" + runDirectoryMigrations opts cfg st + withActiveUser st $ \user -> do + gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do + gs <- getAllGroupRegs_ db user + forM_ gs $ \(_, gr) -> + whenM (verifyGroupRegistration db user gr) $ + B.hPutStrLn h $ strEncode $ GRCreate gr + pure gs + putStrLn $ show (length gs) <> " group registrations exported" + +saveGroupListingFiles :: DirectoryOpts -> ChatConfig -> IO () +saveGroupListingFiles opts _cfg = case webFolder opts of + Nothing -> exit "use --web-folder to generate listings" + Just dir -> + withChatStore opts $ \st -> withActiveUser st $ \user -> + withTransaction st $ \db -> + getAllListedGroups_ db supportedChatVRange user >>= generateListing dir + +verifyGroupRegistration :: DB.Connection -> User -> GroupReg -> IO Bool +verifyGroupRegistration db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} = + runExceptT (getGroupInfo db supportedChatVRange user gId) >>= \case + Left e -> False <$ putStrLn ("Error: loading group " <> show gId <> " (skipping): " <> show e) + Right GroupInfo {localDisplayName} -> do + let groupRef = show gId <> " " <> T.unpack localDisplayName + runExceptT (getHostMember db supportedChatVRange user gId) >>= \case + Left e -> False <$ putStrLn ("Error: loading host member of group " <> groupRef <> " (skipping): " <> show e) + Right GroupMember {groupMemberId = mId', memberContactId = ctId'} -> case dbOwnerMemberId of + Nothing -> True <$ putStrLn ("Warning: group " <> groupRef <> " has no owner member ID, host member ID is " <> show mId' <> ", registration status: " <> B.unpack (strEncode groupRegStatus)) + Just mId + | mId /= mId' -> False <$ putStrLn ("Error: different host member ID of " <> groupRef <> " (skipping): " <> show mId') + | otherwise -> True <$ unless (Just ctId == ctId') (putStrLn $ "Warning: bad group " <> groupRef <> " contact ID: " <> show ctId') + +withDirectoryLog :: DirectoryOpts -> (FilePath -> IO ()) -> IO () +withDirectoryLog DirectoryOpts {directoryLog} action = + maybe (exit "directory log file not specified") action directoryLog + +withChatStore :: DirectoryOpts -> (DBStore -> IO ()) -> IO () +withChatStore DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations, migrationBackupPath}} action = + createChatDatabase dbOptions migrationConfig >>= \case + Left e -> exit $ show e + Right ChatDatabase {chatStore, agentStore} -> do + action chatStore + closeDBStore chatStore + closeDBStore agentStore + where + migrationConfig = MigrationConfig (if yesToUpMigrations then MCYesUp else MCConsole) migrationBackupPath + +withActiveUser :: DBStore -> (User -> IO ()) -> IO () +withActiveUser st action = withTransaction st getUsers >>= maybe (exit "no active user") action . find activeUser diff --git a/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs b/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs new file mode 100644 index 0000000000..4a801fee74 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs @@ -0,0 +1,52 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Postgres.Migrations where + +import Data.List (sortOn) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) +import Text.RawString.QQ (r) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Text +m20250924_directory_schema = + T.pack + [r| +CREATE TABLE sx_directory_group_regs( + group_reg_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id BIGINT NOT NULL, + contact_id BIGINT NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id BIGINT REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Text +down_m20250924_directory_schema = + T.pack + [r| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs b/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs new file mode 100644 index 0000000000..f35f9e250a --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs @@ -0,0 +1,49 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.SQLite.Migrations (directorySchemaMigrations) where + +import Data.List (sortOn) +import Database.SQLite.Simple (Query (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up = fromQuery up, down = fromQuery <$> down} + +schemaMigrations :: [(String, Query, Maybe Query)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Query +m20250924_directory_schema = + [sql| +CREATE TABLE sx_directory_group_regs( + group_reg_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id INTEGER NOT NULL, + contact_id INTEGER NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id INTEGER REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Query +down_m20250924_directory_schema = + [sql| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/apps/simplex-directory-service/src/Directory/Util.hs b/apps/simplex-directory-service/src/Directory/Util.hs new file mode 100644 index 0000000000..a4b79a1bef --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Util.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Directory.Util where + +import Control.Logger.Simple +import Control.Monad.Except +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Chat.Controller +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Util (catchAll) + +vr :: ChatController -> VersionRangeChat +vr ChatController {config = ChatConfig {chatVRange}} = chatVRange +{-# INLINE vr #-} + +withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) +withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a + +withDB :: Text -> ChatController -> (DB.Connection -> ExceptT String IO a) -> IO (Either String a) +withDB cxt ChatController {chatStore} action = do + r_ <- withTransaction chatStore (runExceptT . action) `catchAll` (pure . Left . show) + case r_ of + Left e -> logError $ "Database error: " <> cxt <> " " <> T.pack e + Right _ -> pure () + pure r_ diff --git a/apps/simplex-support-bot/.gitignore b/apps/simplex-support-bot/.gitignore new file mode 100644 index 0000000000..9f77d70eda --- /dev/null +++ b/apps/simplex-support-bot/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md new file mode 100644 index 0000000000..19b9ab8bd6 --- /dev/null +++ b/apps/simplex-support-bot/README.md @@ -0,0 +1,101 @@ +# SimpleX Support Bot + +A business-address bot that triages incoming support chats, optionally runs them through Grok, and routes handoffs to a team group. + +## Prerequisites + +- Node.js v18 or newer (v24 tested) +- `GROK_API_KEY` env var (xAI) — optional; the bot runs without it +- For the PostgreSQL backend: Linux x86_64, `libpq5` installed on the host, and a reachable PostgreSQL server + +## Install & build + +```bash +cd apps/simplex-support-bot +npm install # downloads native libs + transitive deps +npm run build # tsc +``` + +By default this installs the **SQLite** backend. + +To use **PostgreSQL** instead, drop a `.npmrc` next to `package.json` *before* `npm install`: + +```bash +echo 'simplex_backend=postgres' > .npmrc +npm install # now pulls postgres-flavored native libs +npm run build +``` + +`.npmrc` lives next to the package — npm reads it natively, no extra setup. + +### Switching backends + +`npm install` is a no-op for already-installed deps, so editing `.npmrc` and re-running `npm install` will *not* re-trigger `simplex-chat`'s preinstall. To switch backends, force a clean install: + +```bash +rm -rf node_modules +npm install # download-libs.js re-runs and pulls the right native lib +``` + +## Run + +```bash +mkdir -p data # state file lives here by default + +# SQLite (default) +npm start -- --team-group "Support Team" + +# PostgreSQL +npm start -- --team-group "Support Team" \ + --pg-conn "postgres://user:pass@host/db" +``` + +The bot runs via `npm start` so npm can expose `.npmrc` settings to the process — `detectBackend()` reads `npm_config_simplex_backend` to know which backend was installed. + +## Flags + +Run `npm start -- --help` for the auto-generated reference. Summary: + +| Flag | Backend | Required | Default | Description | +|---|---|---|---|---| +| `--team-group` | both | yes | — | team group display name | +| `--state-file` | both | no | `./data/state.json` | path to bot state JSON | +| `--sqlite-file-prefix` | sqlite | no | `./data/simplex` | DB file prefix (creates `_chat.db`, `_agent.db`) | +| `--sqlite-key` | sqlite | no | (unencrypted) | SQLCipher encryption key | +| `--pg-conn` | postgres | yes | — | PostgreSQL connection string | +| `--pg-schema` | postgres | no | `simplex_v1` | schema prefix used for bot tables | +| `-a` / `--auto-add-team-members` | both | no | | comma-separated `ID:name` pairs (e.g. `1:Alice,2:Bob`) | +| `--timezone` | both | no | `UTC` | IANA zone for weekend detection | +| `--complete-hours` | both | no | `3` | auto-complete chats after N hours idle (`0` disables) | +| `--card-flush-seconds` | both | no | `300` | debounce card state writes | +| `--context-file` | both | required with `GROK_API_KEY` | | text file with Grok system context | +| `-h` / `--help` | both | no | | show usage and exit | + +## Environment variables + +| Var | Purpose | +|---|---| +| `GROK_API_KEY` | xAI API key; enables Grok replies | +| `SIMPLEX_BACKEND` | alternative to `.npmrc` for selecting the install backend (`sqlite` or `postgres`) | + +## Local development against unreleased lib changes + +This package depends on `simplex-chat` from npm. To test against an in-tree version: + +```bash +# In packages/simplex-chat-nodejs +npm link + +# In apps/simplex-support-bot +npm link simplex-chat +``` + +`npm unlink simplex-chat && npm install` reverts to the registry version. + +## Troubleshooting + +- **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string. +- **`libpq5` errors at startup** — install `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu). +- **`ENOENT: no such file or directory, open './data/state.json'`** — the parent directory of `--state-file` must exist; `mkdir -p data` before starting. +- **Wrong backend installed** — check `node_modules/simplex-chat/libs/installed.txt`. Edit `.npmrc`, then `rm -rf node_modules && npm install` to switch (`npm install` alone won't re-run the dep's preinstall). +- **`libpq` connection error** at startup with sqlite-flavored config (or vice versa) — `.npmrc` was changed but libs weren't reinstalled. See "Switching backends" above. diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts new file mode 100644 index 0000000000..a1afd7a660 --- /dev/null +++ b/apps/simplex-support-bot/bot.test.ts @@ -0,0 +1,2706 @@ +import {describe, test, expect, beforeEach, vi} from "vitest" +import {mkdtempSync, writeFileSync} from "fs" +import {tmpdir} from "os" +import {join} from "path" +import {core} from "simplex-chat" +import {SupportBot} from "./src/bot.js" +import {CardManager} from "./src/cards.js" +import {parseConfig} from "./src/config.js" +import {GrokApiClient} from "./src/grok.js" +import {loadGrokContext} from "./src/context.js" +import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage, teamAlreadyInvitedMessage} from "./src/messages.js" + +// Silence console output during tests +vi.spyOn(console, "log").mockImplementation(() => {}) +vi.spyOn(console, "error").mockImplementation(() => {}) + +// ─── Type stubs ─── + +const ChatType = {Direct: "direct" as const, Group: "group" as const, Local: "local" as const} +const GroupMemberRole = {Member: "member" as const, Owner: "owner" as const, Admin: "admin" as const} +const GroupMemberStatus = {Connected: "connected" as const, Complete: "complete" as const, Announced: "announced" as const, Left: "left" as const} +const GroupFeatureEnabled = {On: "on" as const, Off: "off" as const} +const CIDeleteMode = {Broadcast: "broadcast" as const} + +// ─── Mock infrastructure ─── + +let nextItemId = 1000 + +class MockChatApi { + sent: {chat: [string, number]; text: string}[] = [] + added: {groupId: number; contactId: number; role: string}[] = [] + removed: {groupId: number; memberIds: number[]}[] = [] + joined: number[] = [] + deleted: {chatType: string; chatId: number; itemIds: number[]; mode: string}[] = [] + customData = new Map() + roleChanges: {groupId: number; memberIds: number[]; role: string}[] = [] + profileUpdates: {groupId: number; profile: any}[] = [] + + members = new Map() + chatItems = new Map() + groups = new Map() + activeUserId = 1 + + private _addMemberFails = false + private _addMemberError: any = null + private _deleteChatItemsFails = false + + apiAddMemberWillFail(err?: any) { this._addMemberFails = true; this._addMemberError = err } + apiDeleteChatItemsWillFail() { this._deleteChatItemsFails = true } + + async apiSetActiveUser(userId: number) { this.activeUserId = userId; return {userId, profile: {displayName: "test"}} } + async apiSendMessages(chatRef: any, messages: any[]) { + // Normalize chat ref: accept both [type, id] tuples and {chatType, chatId} objects + const chat: [string, number] = Array.isArray(chatRef) + ? chatRef + : [chatRef.chatType, chatRef.chatId] + return messages.map(msg => { + const text = msg.msgContent?.text || "" + this.sent.push({chat, text}) + const itemId = nextItemId++ + return {chatItem: {meta: {itemId}, chatDir: {type: "groupSnd"}, content: {type: "sndMsgContent", msgContent: {type: "text", text}}}} + }) + } + async apiSendTextMessage(chat: [string, number], text: string) { + return this.apiSendMessages(chat, [{msgContent: {type: "text", text}, mentions: {}}]) + } + async apiAddMember(groupId: number, contactId: number, role: string) { + if (this._addMemberFails) { + this._addMemberFails = false + throw this._addMemberError || new Error("apiAddMember failed") + } + this.added.push({groupId, contactId, role}) + const memberId = `member-${contactId}` + const groupMemberId = 5000 + contactId + return {memberId, groupMemberId, memberContactId: contactId, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: `Contact${contactId}`}} + } + async apiRemoveMembers(groupId: number, memberIds: number[]) { + this.removed.push({groupId, memberIds}) + return memberIds.map(id => ({groupMemberId: id})) + } + async apiJoinGroup(groupId: number) { + this.joined.push(groupId) + return {groupId} + } + async apiSetMembersRole(groupId: number, memberIds: number[], role: string) { + this.roleChanges.push({groupId, memberIds, role}) + } + async apiListMembers(groupId: number) { + return this.members.get(groupId) || [] + } + async apiGetChat(chatType: string, chatId: number, _count: number) { + if (chatType === ChatType.Direct) { + // Tests don't exercise direct lookups; throw the same shape production + // would so getContact() resolves to null instead of synthesizing a contact. + throw new core.ChatAPIError("contact not found", { + type: "errorStore", + storeError: {type: "contactNotFound", contactId: chatId}, + } as any) + } + const baseGroupInfo = this.groups.get(chatId) + if (!baseGroupInfo) { + // Mirror production behavior: the real apiGetChat throws "groupNotFound" + // for an unknown id; getGroupInfo() catches and returns null. + throw new core.ChatAPIError("group not found", { + type: "errorStore", + storeError: {type: "groupNotFound", groupId: chatId}, + } as any) + } + const items = this.chatItems.get(chatId) || [] + const groupInfo = {...baseGroupInfo, customData: this.customData.get(chatId)} + return { + chatInfo: {type: "group", groupInfo}, + chatItems: items, + chatStats: {unreadCount: 0, unreadMentions: 0, reportsCount: 0, minUnreadItemId: 0, unreadChat: false}, + } + } + async apiGetChats(_userId: number, _pagination: any, _query?: any, _pcc?: boolean) { + return [...this.groups.values()].map(g => ({ + chatInfo: {type: "group", groupInfo: {...g, customData: this.customData.get(g.groupId)}}, + chatItems: [], + chatStats: {unreadCount: 0, unreadMentions: 0, reportsCount: 0, minUnreadItemId: 0, unreadChat: false}, + })) + } + async apiListGroups(_userId: number) { + return [...this.groups.values()].map(g => ({...g, customData: this.customData.get(g.groupId)})) + } + async apiSetGroupCustomData(groupId: number, data?: any) { + if (data === undefined) this.customData.delete(groupId) + else this.customData.set(groupId, data) + } + async apiDeleteChatItems(chatType: string, chatId: number, itemIds: number[], mode: string) { + if (this._deleteChatItemsFails) { + this._deleteChatItemsFails = false + throw new Error("apiDeleteChatItems failed") + } + this.deleted.push({chatType, chatId, itemIds, mode}) + return [] + } + async apiUpdateGroupProfile(groupId: number, profile: any) { + this.profileUpdates.push({groupId, profile}) + return this.groups.get(groupId) || makeGroupInfo(groupId) + } + + memberContacts: {groupId: number; groupMemberId: number; contactId: number}[] = [] + memberContactInvitations: {contactId: number; text: string}[] = [] + + async apiCreateMemberContact(groupId: number, groupMemberId: number): Promise { + const contactId = nextItemId++ + this.memberContacts.push({groupId, groupMemberId, contactId}) + return {contactId, profile: {displayName: "member"}} + } + async apiSendMemberContactInvitation(contactId: number, message?: any): Promise { + const text = typeof message === "string" ? message : (message?.text ?? "") + this.memberContactInvitations.push({contactId, text}) + this.sent.push({chat: [ChatType.Direct, contactId], text}) + return {contactId, profile: {displayName: "member"}} + } + + rawCmds: string[] = [] + async sendChatCmd(cmd: string) { + this.rawCmds.push(cmd) + return {type: "cmdOk"} + } + + sentTo(groupId: number): string[] { + return this.sent.filter(s => s.chat[0] === ChatType.Group && s.chat[1] === groupId).map(s => s.text) + } + lastSentTo(groupId: number): string | undefined { + const msgs = this.sentTo(groupId) + return msgs[msgs.length - 1] + } + sentDirect(contactId: number): string[] { + return this.sent.filter(s => s.chat[0] === ChatType.Direct && s.chat[1] === contactId).map(s => s.text) + } +} + +class MockGrokApi { + calls: {history: any[]; message: string}[] = [] + private _response = "Grok answer" + private _willFail = false + private _gate: {promise: Promise; release: () => void} | null = null + + willRespond(text: string) { this._response = text; this._willFail = false } + willFail() { this._willFail = true } + + // Block every subsequent chat() call until releaseChat() is invoked. Used to + // observe in-flight concurrency without relying on wall-clock timing. + blockChat() { + let release!: () => void + const promise = new Promise(r => { release = r }) + this._gate = {promise, release} + } + releaseChat() { + this._gate?.release() + this._gate = null + } + + async chat(history: any[], userMessage: string): Promise { + this.calls.push({history, message: userMessage}) + if (this._gate) await this._gate.promise + if (this._willFail) { this._willFail = false; throw new Error("Grok API error") } + return this._response + } +} + +// ─── Factory helpers ─── + +const MAIN_USER_ID = 1 +const GROK_USER_ID = 2 +const TEAM_GROUP_ID = 50 +const CUSTOMER_GROUP_ID = 100 +const GROK_CONTACT_ID = 10 +const TEAM_MEMBER_1_ID = 20 +const TEAM_MEMBER_2_ID = 21 +const GROK_LOCAL_GROUP_ID = 200 +const CUSTOMER_ID = "customer-1" + +// Commands passed into SupportBot; matches what index.ts constructs when +// Grok is enabled. The ctor uses this to decide which `/keyword` messages +// from customers are commands vs. plain text — tests that disable grokApi +// should pass a list that excludes "grok" to mirror production wiring (see +// index.ts where `grokEnabled` gates that entry). +const DESIRED_COMMANDS = [ + {type: "command" as const, keyword: "grok", label: "Ask Grok"}, + {type: "command" as const, keyword: "team", label: "Switch to team"}, +] +const DESIRED_COMMANDS_NO_GROK = [DESIRED_COMMANDS[1]] + +// ─── Member factories ─── + +function makeTeamMember(contactId: number, name = `Contact${contactId}`, groupMemberId?: number) { + return { + memberId: `team-${contactId}`, + groupMemberId: groupMemberId ?? 5000 + contactId, + memberContactId: contactId, + memberStatus: GroupMemberStatus.Connected, + memberProfile: {displayName: name}, + } +} + +function makeGrokMember(groupMemberId = 7777) { + return { + memberId: "grok-member", + groupMemberId, + memberContactId: GROK_CONTACT_ID, + memberStatus: GroupMemberStatus.Connected, + memberProfile: {displayName: "Grok"}, + } +} + +function makeCustomerMember(status = GroupMemberStatus.Connected) { + return { + memberId: CUSTOMER_ID, + groupMemberId: 3000, + memberStatus: status, + memberProfile: {displayName: "Customer"}, + } +} + +function makeConfig(overrides: Partial = {}) { + return { + stateFile: "./test-data/state.json", + db: {type: "sqlite", filePrefix: "./test-data/simplex"}, + teamGroup: {id: TEAM_GROUP_ID, name: "SupportTeam"}, + teamMembers: [ + {id: TEAM_MEMBER_1_ID, name: "Alice"}, + {id: TEAM_MEMBER_2_ID, name: "Bob"}, + ], + groupLinks: "", + timezone: "UTC", + completeHours: 3, + cardFlushSeconds: 300, + grokApiKey: "test-key", + grokContactId: GROK_CONTACT_ID as number | null, + ...overrides, + } +} + +function makeGroupInfo(groupId: number, opts: Partial = {}): any { + return { + groupId, + groupProfile: {displayName: opts.displayName || `Group${groupId}`, fullName: ""}, + businessChat: opts.businessChat !== undefined ? opts.businessChat : { + chatType: "business", + businessId: "bot-1", + customerId: opts.customerId || CUSTOMER_ID, + }, + membership: {memberId: "bot-member"}, + customData: opts.customData, + chatSettings: {enableNtfs: "all", favorite: false}, + fullGroupPreferences: {}, + localDisplayName: `group-${groupId}`, + localAlias: "", + useRelays: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + chatTags: [], + groupSummary: {}, + membersRequireAttention: 0, + } +} + +function makeUser(userId: number) { + return {userId, profile: {displayName: userId === MAIN_USER_ID ? "Ask SimpleX Team" : "Grok"}} +} + +function makeChatItem(opts: { + dir: "groupSnd" | "groupRcv" | "directRcv" + text?: string + memberId?: string + memberContactId?: number + memberDisplayName?: string + msgType?: string + groupId?: number +}): any { + const itemId = nextItemId++ + const now = new Date().toISOString() + const msgContent = opts.msgType + ? {type: opts.msgType, text: opts.text || ""} + : {type: "text", text: opts.text || ""} + + let chatDir: any + if (opts.dir === "groupSnd") { + chatDir = {type: "groupSnd"} + } else if (opts.dir === "groupRcv") { + chatDir = { + type: "groupRcv", + groupMember: { + memberId: opts.memberId || CUSTOMER_ID, + groupMemberId: 3000, + memberContactId: opts.memberContactId, + memberStatus: GroupMemberStatus.Connected, + memberProfile: {displayName: opts.memberDisplayName || "Customer"}, + }, + } + } else { + chatDir = {type: "directRcv"} + } + + return { + chatDir, + meta: {itemId, itemTs: now, createdAt: now, itemText: opts.text || "", itemStatus: {type: "sndSent"}, itemEdited: false}, + content: {type: opts.dir === "groupSnd" ? "sndMsgContent" : "rcvMsgContent", msgContent}, + mentions: {}, + reactions: [], + } +} + +function makeAChatItem(chatItem: any, groupId = CUSTOMER_GROUP_ID): any { + return { + chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId)}, + chatItem, + } +} + +function makeDirectAChatItem(chatItem: any, contactId: number): any { + return { + chatInfo: {type: "direct", contact: {contactId, profile: {displayName: "Someone"}}}, + chatItem, + } +} + +// ─── Shared test state ─── + +let chat: MockChatApi +let grokApi: MockGrokApi +let config: ReturnType +let bot: InstanceType +let cards: InstanceType + +// ─── Setup and helpers ─── + +function setup(configOverrides: Partial = {}) { + nextItemId = 1000 + chat = new MockChatApi() + grokApi = new MockGrokApi() + config = makeConfig(configOverrides) + + // Register team group and customer group in mock + const teamGroupInfo = makeGroupInfo(TEAM_GROUP_ID, {businessChat: null, displayName: "SupportTeam"}) + chat.groups.set(TEAM_GROUP_ID, teamGroupInfo) + chat.groups.set(CUSTOMER_GROUP_ID, makeGroupInfo(CUSTOMER_GROUP_ID)) + + cards = new CardManager(chat as any, config as any, MAIN_USER_ID, 999999999) + bot = new SupportBot(chat as any, grokApi as any, config as any, MAIN_USER_ID, GROK_USER_ID, DESIRED_COMMANDS) + // Replace cards with our constructed one that has a long flush interval + bot.cards = cards +} + +function customerMessage(text: string, groupId = CUSTOMER_GROUP_ID): any { + const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci, groupId)], + } +} + +function customerNonTextMessage(groupId = CUSTOMER_GROUP_ID): any { + const ci = makeChatItem({dir: "groupRcv", text: "", memberId: CUSTOMER_ID, msgType: "image"}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci, groupId)], + } +} + +function teamMemberMessage(text: string, contactId = TEAM_MEMBER_1_ID, groupId = CUSTOMER_GROUP_ID): any { + const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${contactId}`, memberContactId: contactId, memberDisplayName: "Alice"}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci, groupId)], + } +} + +function grokResponseMessage(text: string, groupId = CUSTOMER_GROUP_ID): any { + const ci = makeChatItem({dir: "groupRcv", text, memberId: "grok-member", memberContactId: GROK_CONTACT_ID, memberDisplayName: "Grok"}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci, groupId)], + } +} + +function directMessage(text: string, contactId: number): any { + const ci = makeChatItem({dir: "directRcv", text}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeDirectAChatItem(ci, contactId)], + } +} + +function teamGroupMessage(text: string, senderContactId = TEAM_MEMBER_1_ID): any { + const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${senderContactId}`, memberContactId: senderContactId, memberDisplayName: "Alice"}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null})}, chatItem: ci}], + } +} + +// Simulate bot sending a message to the customer group (adds it to chatItems history) +function addBotMessage(text: string, groupId = CUSTOMER_GROUP_ID) { + const ci = makeChatItem({dir: "groupSnd", text}) + const items = chat.chatItems.get(groupId) || [] + items.push(ci) + chat.chatItems.set(groupId, items) +} + +function addCustomerMessageToHistory(text: string, groupId = CUSTOMER_GROUP_ID) { + const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID}) + const items = chat.chatItems.get(groupId) || [] + items.push(ci) + chat.chatItems.set(groupId, items) +} + +function addTeamMemberMessageToHistory(text: string, contactId = TEAM_MEMBER_1_ID, groupId = CUSTOMER_GROUP_ID) { + const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${contactId}`, memberContactId: contactId}) + const items = chat.chatItems.get(groupId) || [] + items.push(ci) + chat.chatItems.set(groupId, items) +} + +function addGrokMessageToHistory(text: string, groupId = CUSTOMER_GROUP_ID) { + const ci = makeChatItem({dir: "groupRcv", text, memberId: "grok-member", memberContactId: GROK_CONTACT_ID}) + const items = chat.chatItems.get(groupId) || [] + items.push(ci) + chat.chatItems.set(groupId, items) +} + +// State helpers — reach specific states +async function reachQueue(groupId = CUSTOMER_GROUP_ID) { + await bot.onNewChatItems(customerMessage("Hello, I need help", groupId)) + // This should have sent queue message + created card +} + +async function reachGrok(groupId = CUSTOMER_GROUP_ID) { + await reachQueue(groupId) + // Add the queue message to history so state derivation sees it + addBotMessage("The team will reply to your message", groupId) + + // Send /grok command. This triggers activateGrok which needs the join flow. + // We need to simulate Grok join success. + const grokJoinPromise = simulateGrokJoinSuccess(groupId) + await bot.onNewChatItems(customerMessage("/grok", groupId)) + await grokJoinPromise +} + +async function simulateGrokJoinSuccess(mainGroupId = CUSTOMER_GROUP_ID) { + // Wait for apiAddMember to be called, then simulate Grok invitation + join + await new Promise(r => setTimeout(r, 10)) + // Find the pending grok join via the added members + const addedGrok = chat.added.find(a => a.contactId === GROK_CONTACT_ID && a.groupId === mainGroupId) + if (!addedGrok) return + + // Simulate Grok receivedGroupInvitation + const memberId = `member-${GROK_CONTACT_ID}` + await bot.onGrokGroupInvitation({ + type: "receivedGroupInvitation", + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + }) + + // Simulate Grok connectedToGroupMember + await bot.onGrokMemberConnected({ + type: "connectedToGroupMember", + user: makeUser(GROK_USER_ID), + groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), + member: {memberId: "bot-in-grok-view", groupMemberId: 9999, memberContactId: undefined}, + }) +} + +async function reachTeamPending(groupId = CUSTOMER_GROUP_ID) { + await reachQueue(groupId) + addBotMessage("The team will reply to your message", groupId) + await bot.onNewChatItems(customerMessage("/team", groupId)) +} + +async function reachTeam(groupId = CUSTOMER_GROUP_ID) { + await reachTeamPending(groupId) + addBotMessage("We will reply within 24 hours.", groupId) + chat.members.set(groupId, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + // Team member sends a text message (triggers one-way gate) + addTeamMemberMessageToHistory("Hi, how can I help?", TEAM_MEMBER_1_ID, groupId) + await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?", TEAM_MEMBER_1_ID, groupId)) +} + +// ─── Assertion helpers ─── + +function expectSentToGroup(groupId: number, substring: string) { + const msgs = chat.sentTo(groupId) + expect(msgs.some(m => m.includes(substring)), + `Expected message containing "${substring}" sent to group ${groupId}, got:\n${msgs.join("\n")}` + ).toBe(true) +} + +function expectNotSentToGroup(groupId: number, substring: string) { + expect(chat.sentTo(groupId).every(m => !m.includes(substring))).toBe(true) +} + +function expectDmSent(contactId: number, substring: string) { + expect(chat.sentDirect(contactId).some(m => m.includes(substring))).toBe(true) +} + +function expectAnySent(substring: string) { + expect(chat.sent.some(s => s.text.includes(substring))).toBe(true) +} + +function expectMemberAdded(groupId: number, contactId: number) { + expect(chat.added.some(a => a.groupId === groupId && a.contactId === contactId)).toBe(true) +} + +function expectCardDeleted(cardItemId: number) { + expect(chat.deleted.some(d => d.itemIds.includes(cardItemId))).toBe(true) +} + +// ─── Event factories ─── + +function connectedEvent(groupId: number, member: any, memberContact?: any) { + return { + type: "connectedToGroupMember" as const, + user: makeUser(MAIN_USER_ID), + groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), + member, + ...(memberContact !== undefined ? {memberContact} : {}), + } +} + +function leftEvent(groupId: number, member: any) { + return { + type: "leftMember" as const, + user: makeUser(MAIN_USER_ID), + groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), + member: {...member, memberStatus: GroupMemberStatus.Left}, + } +} + +function updatedEvent(groupId: number, chatItem: any, userId = MAIN_USER_ID) { + return { + type: "chatItemUpdated" as const, + user: makeUser(userId), + chatItem: { + chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {})}, + chatItem, + }, + } +} + +function reactionEvent(groupId: number, added: boolean) { + return { + type: "chatItemReaction" as const, + user: makeUser(MAIN_USER_ID), + added, + reaction: { + chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId)}, + chatReaction: {reaction: {type: "emoji", emoji: "👍"}}, + }, + } +} + +function joinedEvent(groupId: number, member: any, userId = MAIN_USER_ID) { + return { + type: "joinedGroupMember" as const, + user: makeUser(userId), + groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), + member, + } +} + +function grokViewCustomerMessage(text: string, msgType?: string) { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID, ...(msgType ? {msgType} : {})}) + return { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], + } +} + +// ═══════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════ + +describe("Welcome & First Message", () => { + beforeEach(() => setup()) + + test("first message → queue reply sent, card created in team group", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBeGreaterThan(0) + expect(teamMsgs[teamMsgs.length - 1]).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) + }) + + test("non-text first message → no queue reply, no card", async () => { + await bot.onNewChatItems(customerNonTextMessage()) + expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) + + test("second message → no duplicate queue reply", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + addBotMessage("The team will reply to your message") + const countBefore = chat.sentTo(CUSTOMER_GROUP_ID).filter(m => m.includes("The team will reply to your message")).length + await bot.onNewChatItems(customerMessage("Second message")) + const countAfter = chat.sentTo(CUSTOMER_GROUP_ID).filter(m => m.includes("The team will reply to your message")).length + expect(countAfter).toBe(countBefore) + }) + + test("unrecognized /command → treated as normal message", async () => { + await bot.onNewChatItems(customerMessage("/unknown")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) +}) + +describe("/grok Activation", () => { + beforeEach(() => setup()) + + test("/grok from QUEUE → Grok invited, grokActivatedMessage sent", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) + }) + + test("/grok as first message → WELCOME→GROK directly, no queue message", async () => { + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) + expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("/grok in TEAM → rejected with teamLockedMessage", async () => { + await reachTeam() + await bot.onNewChatItems(customerMessage("/grok")) + expectSentToGroup(CUSTOMER_GROUP_ID, teamLockedMessage) + }) + + test("/grok when grokContactId is null → grokUnavailableMessage", async () => { + setup({grokContactId: null}) + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") + }) + + test("/grok as first message + Grok join fails → queue message sent as fallback", async () => { + chat.apiAddMemberWillFail() + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) +}) + +describe("Grok Conversation", () => { + beforeEach(() => setup()) + + test("Grok per-message: reads history, calls API, sends response", async () => { + addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) + grokApi.willRespond("To create a group, tap +, then New Group.") + await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I create a group?")) + + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("How do I create a group?") + expectAnySent("To create a group, tap +, then New Group.") + }) + + test("customer non-text in GROK → no Grok API call", async () => { + await bot.onGrokNewChatItems(grokViewCustomerMessage("", "image")) + expect(grokApi.calls.length).toBe(0) + }) + + test("Grok API error → error message in group, stays GROK", async () => { + grokApi.willFail() + await bot.onGrokNewChatItems(grokViewCustomerMessage("A question")) + expectAnySent("couldn't process that") + }) + + test("Grok ignores bot commands from customer", async () => { + await bot.onGrokNewChatItems(grokViewCustomerMessage("/team")) + expect(grokApi.calls.length).toBe(0) + }) + + test("Grok answers messages containing a slash mid-word", async () => { + // Regression: an unanchored regex in ciBotCommand once parsed `/read` + // inside "follow/read" as a command, causing Grok to skip the message. + grokApi.willRespond("We post on X and Mastodon.") + await bot.onGrokNewChatItems(grokViewCustomerMessage( + "What social media do you use? Anything I can follow/read for updates?" + )) + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe( + "What social media do you use? Anything I can follow/read for updates?" + ) + }) + + test("Grok answers an unknown slash-prefixed message", async () => { + // `/help` is not in desiredCommands, so it should be treated as plain + // text and reach Grok rather than being silently dropped. + grokApi.willRespond("Sure, here's what I can do.") + await bot.onGrokNewChatItems(grokViewCustomerMessage("/help me with groups")) + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("/help me with groups") + }) + + test("Grok per-message: history includes prior Grok sent response as assistant", async () => { + addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) + addBotMessage("To create a group, tap + then New Group.", GROK_LOCAL_GROUP_ID) + addCustomerMessageToHistory("How do I invite members?", GROK_LOCAL_GROUP_ID) + grokApi.willRespond("Open the group and tap Invite.") + await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I invite members?")) + + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("How do I invite members?") + expect(grokApi.calls[0].history).toEqual([ + {role: "user", content: "How do I create a group?"}, + {role: "assistant", content: "To create a group, tap + then New Group."}, + ]) + }) + + test("Grok ignores non-customer messages", async () => { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + const ci = makeChatItem({dir: "groupRcv", text: "Team message", memberId: "not-customer", memberContactId: TEAM_MEMBER_1_ID}) + const grokEvt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], + } + await bot.onGrokNewChatItems(grokEvt) + expect(grokApi.calls.length).toBe(0) + }) + + test("Grok ignores own messages (groupSnd)", async () => { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + const ci = makeChatItem({dir: "groupSnd", text: "My own response"}) + const grokEvt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], + } + await bot.onGrokNewChatItems(grokEvt) + expect(grokApi.calls.length).toBe(0) + }) + + test("batch: multiple customer messages in one event → only last triggers Grok API call", async () => { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + addCustomerMessageToHistory("First question", GROK_LOCAL_GROUP_ID) + addCustomerMessageToHistory("Second question", GROK_LOCAL_GROUP_ID) + + const ci1 = makeChatItem({dir: "groupRcv", text: "First question", memberId: CUSTOMER_ID}) + const ci2 = makeChatItem({dir: "groupRcv", text: "Second question", memberId: CUSTOMER_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci1}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci2}, + ], + } + + await bot.onGrokNewChatItems(evt) + + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("Second question") + }) + + test("batch: messages from different groups → each group gets one response", async () => { + const GROK_GROUP_A = 201 + const GROK_GROUP_B = 202 + chat.groups.set(GROK_GROUP_A, makeGroupInfo(GROK_GROUP_A)) + chat.groups.set(GROK_GROUP_B, makeGroupInfo(GROK_GROUP_B)) + addCustomerMessageToHistory("Question A", GROK_GROUP_A) + addCustomerMessageToHistory("Question B", GROK_GROUP_B) + + const ciA = makeChatItem({dir: "groupRcv", text: "Question A", memberId: CUSTOMER_ID}) + const ciB = makeChatItem({dir: "groupRcv", text: "Question B", memberId: CUSTOMER_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_A)}, chatItem: ciA}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_B)}, chatItem: ciB}, + ], + } + + await bot.onGrokNewChatItems(evt) + + expect(grokApi.calls.length).toBe(2) + }) + + test("batch: non-customer messages filtered, only customer messages trigger response", async () => { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + addCustomerMessageToHistory("Customer question", GROK_LOCAL_GROUP_ID) + + const custCi = makeChatItem({dir: "groupRcv", text: "Customer question", memberId: CUSTOMER_ID}) + const teamCi = makeChatItem({dir: "groupRcv", text: "Team reply", memberId: "not-customer", memberContactId: TEAM_MEMBER_1_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: custCi}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: teamCi}, + ], + } + + await bot.onGrokNewChatItems(evt) + + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("Customer question") + }) + + test("batch: across groups → Grok calls overlap in-flight (parallel dispatch)", async () => { + const GROK_GROUP_A = 201 + const GROK_GROUP_B = 202 + chat.groups.set(GROK_GROUP_A, makeGroupInfo(GROK_GROUP_A)) + chat.groups.set(GROK_GROUP_B, makeGroupInfo(GROK_GROUP_B)) + addCustomerMessageToHistory("A", GROK_GROUP_A) + addCustomerMessageToHistory("B", GROK_GROUP_B) + + const ciA = makeChatItem({dir: "groupRcv", text: "A", memberId: CUSTOMER_ID}) + const ciB = makeChatItem({dir: "groupRcv", text: "B", memberId: CUSTOMER_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_A)}, chatItem: ciA}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_B)}, chatItem: ciB}, + ], + } + + // Block both chat() calls until we release. If the handler serialized + // per-group work, only one call would enter chat() before release. + grokApi.blockChat() + const done = bot.onGrokNewChatItems(evt) + // Let both tasks run up to the gate. + await new Promise(r => setTimeout(r, 10)) + expect(grokApi.calls.length).toBe(2) + grokApi.releaseChat() + await done + }) +}) + +describe("Grok requests /team", () => { + beforeEach(() => setup()) + + test("Grok per-message reply containing /team → team added, teamAddedMessage sent, reply still sent", async () => { + await reachGrok() + await bot.flush() + grokApi.willRespond("I can't help with billing — please send /team for a human.") + addCustomerMessageToHistory("Can you refund me?", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("Can you refund me?")) + + expectAnySent("I can't help with billing") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + }) + + test("Grok per-message reply without /team → no team members added", async () => { + await reachGrok() + await bot.flush() + grokApi.willRespond("To create a group, tap +, then New Group.") + addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I create a group?")) + + expect(chat.added.some(a => a.groupId === CUSTOMER_GROUP_ID && a.contactId === TEAM_MEMBER_1_ID)).toBe(false) + }) + + test("/team in Grok's initial reply after /grok → escalates", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + // Customer's question visible in Grok's view → activateGrok reads it for the initial reply + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + addCustomerMessageToHistory("I'm really stuck, please help", GROK_LOCAL_GROUP_ID) + grokApi.willRespond("That sounds urgent — send /team to reach a person.") + + const grokJoinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await grokJoinPromise + await bot.flush() + + expectAnySent("That sounds urgent") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + }) +}) + +describe("/team Activation", () => { + beforeEach(() => setup()) + + test("/team from QUEUE → ALL team members added, teamAddedMessage sent", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/team")) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + }) + + test("/team as first message → WELCOME→TEAM, no queue message", async () => { + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) + + test("/team when already activated → teamAlreadyInvitedMessage", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, teamAlreadyInvitedMessage) + }) + + test("/team with no team members → noTeamMembersMessage", async () => { + setup({teamMembers: []}) + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "No team members are available") + }) +}) + +describe("One-Way Gate", () => { + beforeEach(() => setup()) + + test("team member sends first TEXT → Grok removed if present", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember(), makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) + expect(chat.removed.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(7777))).toBe(true) + }) + + test("team member non-text (no ciContentText) → Grok NOT removed", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()]) + await bot.onNewChatItems(teamMemberMessage("", TEAM_MEMBER_1_ID)) + expect(chat.removed.length).toBe(0) + }) + + test("/grok after gate → teamLockedMessage", async () => { + await reachTeam() + await bot.onNewChatItems(customerMessage("/grok")) + expectSentToGroup(CUSTOMER_GROUP_ID, teamLockedMessage) + }) + + test("customer text in TEAM → card update scheduled, no bot reply", async () => { + await reachTeam() + const sentBefore = chat.sentTo(CUSTOMER_GROUP_ID).length + await bot.onNewChatItems(customerMessage("Follow-up question")) + const sentAfter = chat.sentTo(CUSTOMER_GROUP_ID).length + expect(sentAfter).toBe(sentBefore) + }) + + test("/grok in TEAM-PENDING → invite Grok if not present", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID) + }) +}) + +describe("One-Way Gate with Grok Disabled", () => { + test("team text removes Grok even when grokApi is null", async () => { + setup() + // Recreate bot without grokApi but with grokContactId still set (simulates disabled Grok with persisted contact) + bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS) + bot.cards = cards + // Reach QUEUE state with Grok + team member already present + addBotMessage("The team will reply to your message") + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember(), makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + // Team member sends text → one-way gate should fire + await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) + expect(chat.removed.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(7777))).toBe(true) + }) + + test("Grok does not respond when disabled even if grokContactId is set", async () => { + setup() + bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS) + bot.cards = cards + // Set up group with Grok member present + chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()]) + addBotMessage("The team will reply to your message") + // Customer sends text in GROK state + await bot.onNewChatItems(customerMessage("How do I use SimpleX?")) + // Grok should not respond (grokApi is null) + expect(grokApi.calls.length).toBe(0) + }) + + test("Grok disabled: customer /grok is treated as text and queued", async () => { + // When Grok is disabled, index.ts excludes "grok" from desiredCommands, + // so /grok from a customer parses as an unknown command → routed as + // plain text → first-message-in-WELCOME transitions to QUEUE. + setup() + bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS_NO_GROK) + bot.cards = cards + await bot.onNewChatItems(customerMessage("/grok")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) +}) + +describe("Team Member Lifecycle", () => { + beforeEach(() => setup()) + + test("team member connected → promoted to Owner", async () => { + await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeTeamMember(TEAM_MEMBER_1_ID, "Alice"))) + expect(chat.roleChanges.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) && r.role === GroupMemberRole.Owner)).toBe(true) + }) + + test("/team invites team member → apiSetMembersRole(Owner) called at invite time", async () => { + await bot.onNewChatItems(customerMessage("/team")) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expect(chat.roleChanges.some(r => + r.groupId === CUSTOMER_GROUP_ID + && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) + && r.role === GroupMemberRole.Owner + )).toBe(true) + }) + + test("/join invites team member → apiSetMembersRole(Owner) called at invite time", async () => { + await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expect(chat.roleChanges.some(r => + r.groupId === CUSTOMER_GROUP_ID + && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) + && r.role === GroupMemberRole.Owner + )).toBe(true) + }) + + test("/team when team member already in group (any non-terminal status) → apiSetMembersRole NOT re-called", async () => { + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await cards.mergeCustomData(CUSTOMER_GROUP_ID, {state: "TEAM-PENDING"}) + chat.added.length = 0 + chat.roleChanges.length = 0 + + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.added.length).toBe(0) + expect(chat.roleChanges.length).toBe(0) + }) + + test("/join when team member already in group → apiSetMembersRole NOT re-called", async () => { + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + chat.added.length = 0 + chat.roleChanges.length = 0 + + await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) + expect(chat.added.length).toBe(0) + expect(chat.roleChanges.length).toBe(0) + }) + + test("customer connected → NOT promoted to Owner", async () => { + await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) + expect(chat.roleChanges.length).toBe(0) + }) + + test("Grok connected → NOT promoted to Owner", async () => { + await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeGrokMember())) + expect(chat.roleChanges.length).toBe(0) + }) + + test("all team members leave before sending → state stays TEAM-PENDING", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + // Remove team members from the group + chat.members.set(CUSTOMER_GROUP_ID, []) + // State is authoritative and monotonic — composition changes never demote it. + // Customer is still waiting for the team's response. + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("TEAM-PENDING") + }) + + test("/team after all team members left (TEAM-PENDING, no msg sent) → re-adds members", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, []) + chat.added.length = 0 + + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + }) + + test("/team after all team members left (TEAM, msg was sent) → re-adds members", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + addTeamMemberMessageToHistory("Hi, how can I help?", TEAM_MEMBER_1_ID) + await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) + + // All team members leave + chat.members.set(CUSTOMER_GROUP_ID, []) + chat.added.length = 0 + + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + }) +}) + +describe("Card Dashboard", () => { + beforeEach(() => setup()) + + test("first message creates card with /'join ' final line", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBe(1) + const card = teamMsgs[0] + expect(card).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) + // Join command is the final line of the card + const lines = card.split("\n") + expect(lines[lines.length - 1]).toBe(`/'join ${CUSTOMER_GROUP_ID}'`) + }) + + test("card update deletes old card then posts new one", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 555}) + await cards.flush() + expect(chat.deleted.length).toBe(0) + + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + await cards.flush() + expectCardDeleted(555) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("apiDeleteChatItems failure → ignored, new card posted", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 555}) + chat.apiDeleteChatItemsWillFail() + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + await cards.flush() + // New card should still be posted despite delete failure + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("customData stores cardItemId → survives flush cycle", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + // After card creation, customData should have cardItemId + const data = chat.customData.get(CUSTOMER_GROUP_ID) + expect(data).toBeDefined() + expect(typeof data.cardItemId).toBe("number") + }) + + test("concurrent mergeCustomData on same group → both patches survive", async () => { + // Without per-group serialization, two overlapping mergeCustomData calls + // can both read the same snapshot and the second write clobbers the first + // patch. The mutex keeps them ordered so both patches land. + const GID = 500 + chat.groups.set(GID, makeGroupInfo(GID)) + + const pA = cards.mergeCustomData(GID, {state: "QUEUE"}) + const pB = cards.mergeCustomData(GID, {cardItemId: 123}) + await Promise.all([pA, pB]) + + const final = chat.customData.get(GID) + expect(final.state).toBe("QUEUE") + expect(final.cardItemId).toBe(123) + }) + + test("customer leaves → customData cleared", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 999}) + await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) + expect(chat.customData.has(CUSTOMER_GROUP_ID)).toBe(false) + }) +}) + +describe("Card Debouncing", () => { + beforeEach(() => setup()) + + test("rapid events within flush interval → single card update on flush", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 500}) + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + await cards.flush() + // Only one delete and one post + expect(chat.deleted.length).toBe(1) + // Multiple schedules → single update (one card message) + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBe(1) + }) + + test("multiple groups pending → each reposted once per flush", async () => { + const GROUP_A = 101 + const GROUP_B = 102 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.customData.set(GROUP_A, {cardItemId: 501}) + chat.customData.set(GROUP_B, {cardItemId: 502}) + cards.scheduleUpdate(GROUP_A) + cards.scheduleUpdate(GROUP_B) + await cards.flush() + expect(chat.deleted.length).toBe(2) + }) + + test("card create is immediate (not debounced)", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + // Card should be posted immediately without flush + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("flush with no pending updates → no-op", async () => { + await cards.flush() + expect(chat.deleted.length).toBe(0) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) + + test("flush on group with no cardItemId → createCard posts a new card (retries failed create)", async () => { + // customData without cardItemId simulates a prior createCard that failed + // mid-flight and re-queued itself. flushOne must dispatch to createCard, + // not updateCard (which would early-return). + const GID = 777 + chat.groups.set(GID, makeGroupInfo(GID)) + chat.customData.set(GID, {state: "QUEUE"}) + cards.scheduleUpdate(GID) + await cards.flush() + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) + expect(typeof chat.customData.get(GID).cardItemId).toBe("number") + }) +}) + +describe("Card Format & State Derivation", () => { + beforeEach(() => setup()) + + test("QUEUE state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "QUEUE"}) + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("QUEUE") + }) + + test("WELCOME state when customData.state is absent", async () => { + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("WELCOME") + }) + + test("GROK state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "GROK"}) + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("GROK") + }) + + test("TEAM-PENDING state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM-PENDING"}) + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("TEAM-PENDING") + }) + + test("TEAM state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM"}) + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("TEAM") + }) + + test("message count excludes bot's own messages", async () => { + addCustomerMessageToHistory("Hello") + addBotMessage("Queue message") + addCustomerMessageToHistory("Follow-up") + const chatResult = await cards.getChat(CUSTOMER_GROUP_ID, 100) + const nonBotCount = chatResult.chatItems.filter((ci: any) => ci.chatDir.type !== "groupSnd").length + expect(nonBotCount).toBe(2) + }) +}) + +describe("/join Command (Team Group)", () => { + beforeEach(() => setup()) + + test("/join (numeric only) → team member added to customer group", async () => { + await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + }) + + test("/join : (legacy form) → team member added", async () => { + await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}:Customer`)) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + }) + + test("/join validates target is business group → error if not", async () => { + const nonBizGroupId = 999 + chat.groups.set(nonBizGroupId, makeGroupInfo(nonBizGroupId, {businessChat: null})) + await bot.onNewChatItems(teamGroupMessage(`/join ${nonBizGroupId}:Test`)) + expectSentToGroup(TEAM_GROUP_ID, "not a business chat") + }) + + test("/join with non-existent groupId → error in team group", async () => { + await bot.onNewChatItems(teamGroupMessage("/join 99999:Nobody")) + expect(chat.sentTo(TEAM_GROUP_ID).some(m => m.toLowerCase().includes("error"))).toBe(true) + }) + + test("customer sending /join in customer group → treated as normal message", async () => { + await bot.onNewChatItems(customerMessage("/join 50:Test")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) + + test("/join with non-numeric params → error reply, no apiAddMember call", async () => { + await bot.onNewChatItems(teamGroupMessage("/join abc")) + expectSentToGroup(TEAM_GROUP_ID, `Error: invalid group id "abc"`) + expect(chat.added.length).toBe(0) + }) +}) + +describe("DM Handshake", () => { + beforeEach(() => setup()) + + test("team member joins team group → DM sent with contact ID", async () => { + const member = {memberId: "new-team", groupMemberId: 8000, memberContactId: 30, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Charlie"}} + await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, {contactId: 30, profile: {displayName: "Charlie"}})) + expectDmSent(30, "Your contact ID is 30:Charlie") + }) + + test("DM with spaces in name → name single-quoted", async () => { + const member = {memberId: "new-team", groupMemberId: 8001, memberContactId: 31, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Charlie Brown"}} + await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, {contactId: 31, profile: {displayName: "Charlie Brown"}})) + expectDmSent(31, "31:'Charlie Brown'") + }) + + test("pending DM delivered on contactConnected", async () => { + const invEvt = { + type: "newMemberContactReceivedInv" as const, + user: makeUser(MAIN_USER_ID), + contact: {contactId: 32, profile: {displayName: "Dave"}}, + groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null}), + member: {memberId: "dave-member", groupMemberId: 8002, memberContactId: 32, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Dave"}}, + } + await bot.onMemberContactReceivedInv(invEvt) + + await bot.onContactConnected({ + type: "contactConnected" as const, + user: makeUser(MAIN_USER_ID), + contact: {contactId: 32, profile: {displayName: "Dave"}}, + }) + expectDmSent(32, "Your contact ID is 32:Dave") + }) + + test("team member with no DM contact → creates member contact and sends invitation", async () => { + const member = {memberId: "new-team-no-dm", groupMemberId: 8010, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Frank"}} + await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, undefined)) + expect(chat.memberContacts.some(c => c.groupId === TEAM_GROUP_ID && c.groupMemberId === 8010)).toBe(true) + expect(chat.memberContactInvitations.some(i => i.text.includes("Your contact ID is") && i.text.includes("Frank"))).toBe(true) + const dms = chat.sent.filter(s => s.chat[0] === ChatType.Direct) + expect(dms.some(m => m.text.includes("Your contact ID is") && m.text.includes("Frank"))).toBe(true) + }) + + test("joinedGroupMember in team group → creates member contact and sends invitation", async () => { + const member = {memberId: "link-joiner", groupMemberId: 8020, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Grace"}} + await bot.onJoinedGroupMember(joinedEvent(TEAM_GROUP_ID, member)) + expect(chat.memberContacts.some(c => c.groupId === TEAM_GROUP_ID && c.groupMemberId === 8020)).toBe(true) + expect(chat.memberContactInvitations.some(i => i.text.includes("Grace"))).toBe(true) + }) + + test("no duplicate DM when both sendTeamMemberDM succeeds and onMemberContactReceivedInv fires", async () => { + const invEvt = { + type: "newMemberContactReceivedInv" as const, + user: makeUser(MAIN_USER_ID), + contact: {contactId: 33, profile: {displayName: "Eve"}}, + groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null}), + member: {memberId: "eve-member", groupMemberId: 8003, memberContactId: 33, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Eve"}}, + } + await bot.onMemberContactReceivedInv(invEvt) + + const eveMember = {memberId: "eve-member", groupMemberId: 8003, memberContactId: 33, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Eve"}} + await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, eveMember, {contactId: 33, profile: {displayName: "Eve"}})) + + await bot.onContactConnected({ + type: "contactConnected" as const, + user: makeUser(MAIN_USER_ID), + contact: {contactId: 33, profile: {displayName: "Eve"}}, + }) + + const dms = chat.sentDirect(33) + const contactIdMsgs = dms.filter(m => m.includes("Your contact ID is 33:Eve")) + expect(contactIdMsgs.length).toBe(1) + }) +}) + +describe("Direct Message Handling", () => { + beforeEach(() => setup()) + + test("regular DM → bot replies with business address link", async () => { + bot.businessAddress = "simplex:/contact#abc123" + await bot.onNewChatItems(directMessage("Hi there", 99)) + expectDmSent(99, "simplex:/contact#abc123") + }) + + test("DM without business address set → no reply", async () => { + bot.businessAddress = null + await bot.onNewChatItems(directMessage("Hi there", 99)) + expect(chat.sentDirect(99).length).toBe(0) + }) + + test("non-message DM event (e.g. contactConnected) → no reply", async () => { + bot.businessAddress = "simplex:/contact#abc123" + const ci = { + chatDir: {type: "directRcv"}, + content: {type: "rcvDirectEvent"}, + meta: {itemId: 9999, createdAt: new Date().toISOString()}, + } + const evt = { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeDirectAChatItem(ci, 99)], + } + await bot.onNewChatItems(evt) + expect(chat.sentDirect(99).length).toBe(0) + }) +}) + +describe("Business Request Handler", () => { + beforeEach(() => setup()) + + test("acceptingBusinessRequest → enables file uploads AND visible history", async () => { + await bot.onBusinessRequest({ + type: "acceptingBusinessRequest" as const, + user: makeUser(MAIN_USER_ID), + groupInfo: makeGroupInfo(CUSTOMER_GROUP_ID), + }) + expect(chat.profileUpdates.some(u => + u.groupId === CUSTOMER_GROUP_ID + && u.profile.groupPreferences?.files?.enable === GroupFeatureEnabled.On + && u.profile.groupPreferences?.history?.enable === GroupFeatureEnabled.On + )).toBe(true) + }) +}) + +describe("chatItemUpdated Handler", () => { + beforeEach(() => setup()) + + test("chatItemUpdated in business group → card update scheduled", async () => { + await bot.onChatItemUpdated(updatedEvent(CUSTOMER_GROUP_ID, makeChatItem({dir: "groupRcv", text: "edited message", memberId: CUSTOMER_ID}))) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 600}) + await cards.flush() + expectCardDeleted(600) + }) + + test("chatItemUpdated in non-business group → ignored", async () => { + await bot.onChatItemUpdated(updatedEvent(TEAM_GROUP_ID, makeChatItem({dir: "groupRcv", text: "team msg"}))) + await cards.flush() + expect(chat.deleted.length).toBe(0) + }) + + test("chatItemUpdated from wrong user → ignored", async () => { + await bot.onChatItemUpdated(updatedEvent(CUSTOMER_GROUP_ID, makeChatItem({dir: "groupRcv", text: "edited"}), GROK_USER_ID)) + await cards.flush() + expect(chat.deleted.length).toBe(0) + }) +}) + +describe("Reactions", () => { + beforeEach(() => setup()) + + test("reaction in business group → card update scheduled", async () => { + await bot.onChatItemReaction(reactionEvent(CUSTOMER_GROUP_ID, true)) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 700}) + await cards.flush() + expectCardDeleted(700) + }) + + test("reaction removed (added=false) → no card update", async () => { + await bot.onChatItemReaction(reactionEvent(CUSTOMER_GROUP_ID, false)) + await cards.flush() + expect(chat.deleted.length).toBe(0) + }) +}) + +describe("Customer Leave", () => { + beforeEach(() => setup()) + + test("customer leaves → customData cleared", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 800}) + await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) + expect(chat.customData.has(CUSTOMER_GROUP_ID)).toBe(false) + }) + + test("Grok leaves → in-memory maps cleaned", async () => { + await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeGrokMember())) + }) + + test("team member leaves → logged, no crash", async () => { + await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeTeamMember(TEAM_MEMBER_1_ID, "Alice"))) + }) + + test("leftMember in non-business group → ignored", async () => { + const member = {memberId: "someone", groupMemberId: 9000, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} + await bot.onLeftMember(leftEvent(TEAM_GROUP_ID, member)) + }) +}) + +describe("Error Handling", () => { + beforeEach(() => setup()) + + test("apiAddMember fails (Grok invite) → grokUnavailableMessage", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + chat.apiAddMemberWillFail() + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") + }) + + test("groupDuplicateMember on Grok invite → only inviting message, no result", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + chat.apiAddMemberWillFail({chatError: {errorType: {type: "groupDuplicateMember"}}}) + const sentBefore = chat.sent.length + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + // Only the "Inviting Grok" message is sent — no activated/unavailable result + expect(chat.sent.length).toBe(sentBefore + 1) + expectSentToGroup(CUSTOMER_GROUP_ID, "Inviting Grok") + expectNotSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) + expectNotSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") + }) + + test("/team while members are in Invited status → no second apiAddMember call", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + + // Simulate the realistic post-/team state: both members have been invited + // but have not yet accepted (memberStatus = "invited"). The SimpleX API + // would resend the invitation if apiAddMember is called for an Invited + // member — the pre-check in addOrFindTeamMember must skip them. + const invited = (contactId: number) => ({ + memberId: `team-${contactId}`, + groupMemberId: 5000 + contactId, + memberContactId: contactId, + memberStatus: "invited", + memberProfile: {displayName: `Contact${contactId}`}, + }) + chat.members.set(CUSTOMER_GROUP_ID, [invited(TEAM_MEMBER_1_ID), invited(TEAM_MEMBER_2_ID)]) + chat.added.length = 0 + + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.added.filter(a => a.groupId === CUSTOMER_GROUP_ID)).toEqual([]) + }) + + test("/grok in TEAM-PENDING while Grok is in Invited status → no second apiAddMember call", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [ + makeTeamMember(TEAM_MEMBER_1_ID, "Alice"), + {memberId: "grok-member", groupMemberId: 7777, memberContactId: GROK_CONTACT_ID, memberStatus: "invited", memberProfile: {displayName: "Grok"}}, + ]) + chat.added.length = 0 + + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + + expect(chat.added.filter(a => a.contactId === GROK_CONTACT_ID)).toEqual([]) + expectNotSentToGroup(CUSTOMER_GROUP_ID, "Inviting Grok") + }) +}) + +describe("Profile / Event Filtering", () => { + beforeEach(() => setup()) + + test("newChatItems from Grok profile → ignored by main handler", async () => { + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [makeAChatItem(makeChatItem({dir: "groupRcv", text: "test"}))], + } + const sentBefore = chat.sent.length + await bot.onNewChatItems(evt) + expect(chat.sent.length).toBe(sentBefore) + }) + + test("Grok events from main profile → ignored by Grok handlers", async () => { + const evt = { + type: "receivedGroupInvitation" as const, + user: makeUser(MAIN_USER_ID), + groupInfo: makeGroupInfo(300), + contact: {contactId: 1}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + } + await bot.onGrokGroupInvitation(evt) + expect(chat.joined.length).toBe(0) + }) + + test("own messages (groupSnd) → ignored", async () => { + const ci = makeChatItem({dir: "groupSnd", text: "Bot message"}) + const evt = { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci)], + } + const sentBefore = chat.sent.length + await bot.onNewChatItems(evt) + expect(chat.sent.length).toBe(sentBefore) + }) + + test("non-business group messages → ignored", async () => { + const ci = makeChatItem({dir: "groupRcv", text: "test"}) + const nonBizGroup = makeGroupInfo(999, {businessChat: null}) + const evt = { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: nonBizGroup}, chatItem: ci}], + } + const sentBefore = chat.sent.length + await bot.onNewChatItems(evt) + expect(chat.sent.length).toBe(sentBefore) + }) +}) + +describe("Grok Join Flow", () => { + beforeEach(() => setup()) + + test("Grok receivedGroupInvitation → apiJoinGroup called", async () => { + // First need to set up a pending grok join + // Simulate the main profile side: add Grok to a group + await reachQueue() + addBotMessage("The team will reply to your message") + + // This kicks off activateGrok which adds member and waits + const joinComplete = new Promise(async (resolve) => { + // Simulate Grok invitation after a small delay + setTimeout(async () => { + const addedGrok = chat.added.find(a => a.contactId === GROK_CONTACT_ID) + if (addedGrok) { + const memberId = `member-${GROK_CONTACT_ID}` + await bot.onGrokGroupInvitation({ + type: "receivedGroupInvitation", + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + }) + } + resolve() + }, 10) + }) + + // Don't await bot.onNewChatItems yet — let it start + const botPromise = bot.onNewChatItems(customerMessage("/grok")) + await joinComplete + // Complete the join + await bot.onGrokMemberConnected({ + type: "connectedToGroupMember", + user: makeUser(GROK_USER_ID), + groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), + member: {memberId: "bot-in-grok-view", groupMemberId: 9999}, + }) + await botPromise + + expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID) + }) + + test("unmatched Grok invitation → buffered, not joined", async () => { + const evt = { + type: "receivedGroupInvitation" as const, + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(999), membership: {memberId: "unknown-member"}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + } + await bot.onGrokGroupInvitation(evt) + expect(chat.joined.length).toBe(0) + }) + + test("buffered invitation drained after pendingGrokJoins set → apiJoinGroup called", async () => { + // Simulate the race: invitation arrives before pendingGrokJoins is set + const memberId = `member-${GROK_CONTACT_ID}` + const invEvt = { + type: "receivedGroupInvitation" as const, + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + } + // Buffer the invitation (no pending join registered yet) + await bot.onGrokGroupInvitation(invEvt) + expect(chat.joined.length).toBe(0) + + // Now trigger activateGrok — apiAddMember returns, pendingGrokJoins set, buffer drained + const joinComplete = new Promise((resolve) => { + setTimeout(async () => { + // Grok connected after buffer drain processed the invitation + await bot.onGrokMemberConnected({ + type: "connectedToGroupMember", + user: makeUser(GROK_USER_ID), + groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), + member: {memberId: "bot-in-grok-view", groupMemberId: 9999}, + }) + resolve() + }, 20) + }) + + await reachQueue() + addBotMessage("The team will reply to your message") + const botPromise = bot.onNewChatItems(customerMessage("/grok")) + await joinComplete + await botPromise + await bot.flush() + + expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) + }) + + test("per-message responses suppressed during activateGrok initial response", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + + // Customer's message visible in Grok's view (activateGrok reads it for initial response) + addCustomerMessageToHistory("Hello, I need help", GROK_LOCAL_GROUP_ID) + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + + // Start /grok activation (fireAndForget) + const botPromise = bot.onNewChatItems(customerMessage("/grok")) + + // Wait for apiAddMember to complete + await new Promise(r => setTimeout(r, 10)) + + // Simulate Grok invitation → sets grokGroupMap/reverseGrokMap + const memberId = `member-${GROK_CONTACT_ID}` + await bot.onGrokGroupInvitation({ + type: "receivedGroupInvitation", + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + }) + + // grokInitialResponsePending is set, reverseGrokMap is set. + // Simulate per-message event (as if message backlog arrived for Grok profile) + await bot.onGrokNewChatItems(grokViewCustomerMessage("Hello, I need help")) + + // Gating: per-message handler must NOT have called Grok API + expect(grokApi.calls.length).toBe(0) + + // Now complete the join → activateGrok sends initial combined response + await bot.onGrokMemberConnected({ + type: "connectedToGroupMember", + user: makeUser(GROK_USER_ID), + groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), + member: {memberId: "bot-in-grok-view", groupMemberId: 9999, memberContactId: undefined}, + }) + + await botPromise + await bot.flush() + + // Only 1 Grok API call: the initial combined response from activateGrok + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toContain("Hello, I need help") + }) + + test("per-message responses resume after activateGrok completes", async () => { + await reachGrok() + await bot.flush() + const callsAfterActivation = grokApi.calls.length + + // Send a new customer message via Grok's view — should be processed normally + addCustomerMessageToHistory("Follow-up question", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("Follow-up question")) + + expect(grokApi.calls.length).toBe(callsAfterActivation + 1) + expect(grokApi.calls[grokApi.calls.length - 1].message).toBe("Follow-up question") + }) + + test("activateGrok groupDuplicateMember path → gate cleared by outer finally", async () => { + // After reachGrok(), gate is cleared and reverseGrokMap is populated. + await reachGrok() + await bot.flush() + const callsBaseline = grokApi.calls.length + + // Second /grok while Grok is already present → apiAddMember throws duplicate. + // The outer try/finally must clear the gate even though the handler returns + // silently from inside the try — otherwise per-message responses stay + // suppressed for this group forever. + chat.apiAddMemberWillFail({chatError: {errorType: {type: "groupDuplicateMember"}}}) + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + + // Gate must be clear: a subsequent per-message event triggers Grok. + addCustomerMessageToHistory("another question", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("another question")) + expect(grokApi.calls.length).toBe(callsBaseline + 1) + }) +}) + +describe("Grok No-History Fallback", () => { + beforeEach(() => setup()) + + test("Grok joins but sees no customer messages → sends grokNoHistoryMessage", async () => { + chat.chatItems.set(GROK_LOCAL_GROUP_ID, []) + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + + const grokJoinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await grokJoinPromise + await bot.flush() + expectAnySent("couldn't see your earlier messages") + }) +}) + +describe("Non-customer messages trigger card update", () => { + beforeEach(() => setup()) + + test("Grok response in customer group → card update scheduled", async () => { + await bot.onNewChatItems(grokResponseMessage("Grok says hi")) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 900}) + await cards.flush() + expectCardDeleted(900) + }) + + test("team member message → card update scheduled", async () => { + await bot.onNewChatItems(teamMemberMessage("Team says hi")) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 901}) + await cards.flush() + expectCardDeleted(901) + }) +}) + +describe("End-to-End Flows", () => { + beforeEach(() => setup()) + + test("WELCOME → QUEUE → /team → TEAM-PENDING → team msg → TEAM", async () => { + await bot.onNewChatItems(customerMessage("Help me")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + addBotMessage("The team will reply to your message") + + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + addBotMessage("We will reply within 24 hours.") + + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + const pendingState = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(pendingState).toBe("TEAM-PENDING") + + addTeamMemberMessageToHistory("I'll help you", TEAM_MEMBER_1_ID) + await bot.onNewChatItems(teamMemberMessage("I'll help you")) + + const teamState = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(teamState).toBe("TEAM") + }) + + test("WELCOME → /grok first msg → GROK", async () => { + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + + expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) + expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("multiple concurrent conversations are independent", async () => { + const GROUP_A = 101 + const GROUP_B = 102 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A, {customerId: "cust-a"})) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B, {customerId: "cust-b"})) + + const ciA = makeChatItem({dir: "groupRcv", text: "Help A", memberId: "cust-a"}) + await bot.onNewChatItems({ + type: "newChatItems", + user: makeUser(MAIN_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROUP_A, {customerId: "cust-a"})}, chatItem: ciA}], + }) + + const ciB = makeChatItem({dir: "groupRcv", text: "Help B", memberId: "cust-b"}) + await bot.onNewChatItems({ + type: "newChatItems", + user: makeUser(MAIN_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROUP_B, {customerId: "cust-b"})}, chatItem: ciB}], + }) + + expectSentToGroup(GROUP_A, "The team will reply to your message") + expectSentToGroup(GROUP_B, "The team will reply to your message") + }) +}) + +describe("Message Templates", () => { + test("welcomeMessage is a non-empty string", () => { + expect(typeof welcomeMessage).toBe("string") + expect(welcomeMessage.length).toBeGreaterThan(0) + }) + + test("grokActivatedMessage mentions chatting with Grok", () => { + expect(grokActivatedMessage).toContain("chatting with Grok") + }) + + test("teamLockedMessage tells customer the team will handle the conversation", () => { + expect(teamLockedMessage).toContain("team") + }) + + test("queueMessage mentions hours", () => { + const msg = queueMessage("UTC", true) + expect(msg).toContain("hours") + }) +}) + +describe("State persistence in customData", () => { + beforeEach(() => setup()) + + test("first customer text writes state=QUEUE to customData", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE") + }) + + test("/team writes state=TEAM-PENDING immediately (before team accepts)", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") + }) + + test("/grok writes state=GROK when activation succeeds", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("GROK") + }) + + test("/grok from QUEUE reverts state to QUEUE if activation fails", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + chat.apiAddMemberWillFail() + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE") + }) + + test("concurrent /team during Grok activation timeout does not demote state", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + + // Pause activateGrok at waitForGrokJoin so /team can run in the meantime. + // Patching apiAddMember won't work: it's wrapped in withMainProfile's mutex, + // which /team's activateTeam also needs. waitForGrokJoin awaits outside the + // mutex — that's the real race window in production. + let releaseJoin!: (joined: boolean) => void + ;(bot as any).waitForGrokJoin = () => + new Promise((resolve) => { releaseJoin = resolve }) + + // /grok: writes state=GROK optimistically, fire-and-forgets activateGrok. + await bot.onNewChatItems(customerMessage("/grok")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("GROK") + + // Let activateGrok progress past apiAddMember into waitForGrokJoin. + await Promise.resolve() + await Promise.resolve() + + // /team while activateGrok is waiting for join — writes TEAM-PENDING + adds members. + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + + // Simulate Grok join timeout — activateGrok's revertStateOnFail runs. + releaseJoin(false) + await bot.flush() + + // Fix asserts: revert guard sees state != "GROK" and leaves TEAM-PENDING alone. + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") + }) + + test("first team text writes state=TEAM via gate", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await bot.onNewChatItems(teamMemberMessage("I'll help")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM") + }) +}) + +describe("Card Preview Sender Prefixes", () => { + beforeEach(() => setup()) + + // Helper: extract preview line from card text posted to team group + function getCardPreview(): string { + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + const cardText = teamMsgs[0] + if (!cardText) return "" + const lines = cardText.split("\n") + // Card layout: header, state, preview, /'join N' — preview is second to last + return lines[lines.length - 2] || "" + } + + test("customer-only messages: first prefixed, rest not", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("Hello") + addCustomerMessageToHistory("Need help") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("Alice: Hello") + expect(preview).toContain("!3 /! Need help") + // Second message must NOT have prefix (same sender) + expect(preview).not.toContain("Alice: Need help") + }) + + test("three consecutive customer messages: only first gets prefix", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("First") + addCustomerMessageToHistory("Second") + addCustomerMessageToHistory("Third") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + const prefixCount = (preview.match(/Alice:/g) || []).length + expect(prefixCount).toBe(1) + expect(preview).toContain("Alice: First") + }) + + test("alternating customer and Grok: each sender change triggers prefix", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("How does encryption work?") + addGrokMessageToHistory("SimpleX uses double ratchet") + addCustomerMessageToHistory("And metadata?") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("Alice: How does encryption work?") + expect(preview).toContain("Grok: SimpleX uses double ratchet") + expect(preview).toContain("Alice: And metadata?") + }) + + test("Grok identified by grokContactId, not by display name", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + // Grok message uses GROK_CONTACT_ID → labeled "Grok" regardless of memberProfile + addGrokMessageToHistory("I am Grok") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("Grok: I am Grok") + }) + + test("team member messages use their memberProfile displayName", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("Help please") + // Add team member message with explicit display name + const teamCi = makeChatItem({ + dir: "groupRcv", text: "On it!", + memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID, + memberDisplayName: "Bob", + }) + const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] + items.push(teamCi) + chat.chatItems.set(CUSTOMER_GROUP_ID, items) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("Alice: Help please") + expect(preview).toContain("Bob: On it!") + }) + + test("bot messages (groupSnd) excluded from preview", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("Hello") + addBotMessage("The team will reply to your message") + addCustomerMessageToHistory("Thanks") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).not.toContain("The team will reply to your message") + // Both customer messages are from the same sender — only first prefixed + expect(preview).toContain("Alice: Hello") + expect(preview).toContain("!3 /! Thanks") + }) + + test("media-only message shows type label", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + const imgCi = makeChatItem({dir: "groupRcv", text: "", memberId: CUSTOMER_ID, msgType: "image"}) + const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] + items.push(imgCi) + chat.chatItems.set(CUSTOMER_GROUP_ID, items) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("[image]") + }) + + test("media message with caption shows label + text", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + const imgCi = makeChatItem({dir: "groupRcv", text: "screenshot of the bug", memberId: CUSTOMER_ID, msgType: "image"}) + const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] + items.push(imgCi) + chat.chatItems.set(CUSTOMER_GROUP_ID, items) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("[image] screenshot of the bug") + }) + + test("long message truncated with [truncated]", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + const longMsg = "x".repeat(300) + addCustomerMessageToHistory(longMsg) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("[truncated]") + // Truncated at ~200 chars + prefix + expect(preview.length).toBeLessThan(300) + }) + + test("total overflow truncates oldest messages, keeps newest", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + // Add many messages to exceed 1000 chars total + for (let i = 0; i < 20; i++) { + addCustomerMessageToHistory(`Message number ${i} with some extra padding text to fill space quickly`) + } + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("[truncated]") + // Newest messages should be present, oldest truncated + expect(preview).toContain("Message number 19") + expect(preview).not.toContain("Message number 0") + // Should not include all 20 messages + const slashCount = (preview.match(/ \/ /g) || []).length + expect(slashCount).toBeLessThan(19) + }) + + test("empty preview when no messages", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toBe('""') + }) + + test("only bot messages → empty preview", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addBotMessage("Welcome!") + addBotMessage("Queue message") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toBe('""') + }) + + test("newlines in message text → replaced with spaces", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("line1\nline2\n\nline3") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).not.toContain("\n") + expect(preview).toContain("line1 line2 line3") + }) + + test("newlines in customer display name → sanitized in card header", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "First\nLast"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("Hello") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBe(1) + const cardText = teamMsgs[0] + // Card header should have sanitized name (no newlines) + expect(cardText).toContain("First Last") + // Exactly 4 lines: header, state, preview, /'join N' + expect(cardText.split("\n").length).toBe(4) + expect(cardText).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) + }) +}) + +describe("Restart Card Recovery", () => { + beforeEach(() => setup()) + + test("refreshAllCards refreshes groups with active cards", async () => { + const GROUP_A = 101 + const GROUP_B = 102 + const GROUP_NO_CARD = 103 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.groups.set(GROUP_NO_CARD, makeGroupInfo(GROUP_NO_CARD)) + chat.customData.set(GROUP_A, {cardItemId: 501}) + chat.customData.set(GROUP_B, {cardItemId: 503}) + + await cards.refreshAllCards() + + expectCardDeleted(501) + expectCardDeleted(503) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(2) // 2 cards × 1 message each + }) + + test("refreshAllCards with no active cards → no-op", async () => { + await cards.refreshAllCards() + expect(chat.deleted.length).toBe(0) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) + + test("refreshAllCards ignores groups without cardItemId in customData", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.customData.set(GROUP_A, {someOtherData: true}) + + await cards.refreshAllCards() + expect(chat.deleted.length).toBe(0) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) + + test("refreshAllCards orders by cardItemId ascending (oldest first, newest last)", async () => { + // GROUP_C has higher cardItemId (more recent) than GROUP_A and GROUP_B + const GROUP_A = 101, GROUP_B = 102, GROUP_C = 103 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.groups.set(GROUP_C, makeGroupInfo(GROUP_C)) + chat.customData.set(GROUP_C, {cardItemId: 900}) // newest — should refresh last + chat.customData.set(GROUP_A, {cardItemId: 100}) // oldest — should refresh first + chat.customData.set(GROUP_B, {cardItemId: 500}) // middle + + await cards.refreshAllCards() + + // Verify deletion order: oldest cardItemId first, newest last + expect(chat.deleted.length).toBe(3) + expect(chat.deleted[0].itemIds).toEqual([100]) + expect(chat.deleted[1].itemIds).toEqual([500]) + expect(chat.deleted[2].itemIds).toEqual([900]) + + // Newest card is posted last → appears at bottom of team group + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBe(3) // 3 cards × 1 message each + }) + + test("refreshAllCards skips cards marked complete", async () => { + const GROUP_A = 101, GROUP_B = 102 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.customData.set(GROUP_A, {cardItemId: 100, complete: true}) + chat.customData.set(GROUP_B, {cardItemId: 200}) + + await cards.refreshAllCards() + + expect(chat.deleted.length).toBe(1) + expect(chat.deleted[0].itemIds).toEqual([200]) + expect(chat.deleted.some(d => d.itemIds.includes(100))).toBe(false) + }) + + test("refreshAllCards deletes old card before reposting", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.customData.set(GROUP_A, {cardItemId: 501}) + + await cards.refreshAllCards() + + // Old card should be deleted + expect(chat.deleted.length).toBe(1) + expect(chat.deleted[0].itemIds).toEqual([501]) + // New card posted + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) + }) + + test("refreshAllCards ignores delete failure (>24h old card)", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.customData.set(GROUP_A, {cardItemId: 501}) + chat.apiDeleteChatItemsWillFail() + + await cards.refreshAllCards() + + // Delete failed but new card still posted + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) + // customData updated with new cardItemId + const newData = chat.customData.get(GROUP_A) + expect(typeof newData.cardItemId).toBe("number") + expect(newData.cardItemId).not.toBe(501) // new ID, not the old one + }) + + test("card flush writes complete: true for auto-completed conversations", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.members.set(GROUP_A, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + // Team member message from 4 hours ago (> completeHours=3h) → auto-complete + const oldCi = makeChatItem({dir: "groupRcv", text: "Resolved!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID}) + oldCi.meta.createdAt = new Date(Date.now() - 4 * 3600_000).toISOString() + chat.chatItems.set(GROUP_A, [oldCi]) + // Create initial card data + chat.customData.set(GROUP_A, {cardItemId: 500}) + + cards.scheduleUpdate(GROUP_A) + await cards.flush() + + const data = chat.customData.get(GROUP_A) + expect(data.complete).toBe(true) + }) + + test("card flush clears complete flag when conversation becomes active again", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.members.set(GROUP_A, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + // Team member message from 4h ago + recent customer message → NOT complete + const teamCi = makeChatItem({dir: "groupRcv", text: "Resolved!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID}) + teamCi.meta.createdAt = new Date(Date.now() - 4 * 3600_000).toISOString() + const custCi = makeChatItem({dir: "groupRcv", text: "Actually one more question", memberId: CUSTOMER_ID}) + chat.chatItems.set(GROUP_A, [teamCi, custCi]) + // Previously complete + chat.customData.set(GROUP_A, {cardItemId: 500, complete: true}) + + cards.scheduleUpdate(GROUP_A) + await cards.flush() + + const data = chat.customData.get(GROUP_A) + expect(data.complete).toBeUndefined() + }) + + test("refreshAllCards continues on individual card failure", async () => { + const GROUP_A = 101, GROUP_B = 102 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.customData.set(GROUP_A, {cardItemId: 100}) + chat.customData.set(GROUP_B, {cardItemId: 200}) + + chat.apiDeleteChatItemsWillFail() + await cards.refreshAllCards() + expectCardDeleted(200) + }) +}) + +describe("joinedGroupMember Event Filtering", () => { + beforeEach(() => setup()) + + test("joinedGroupMember in non-team group → ignored (no DM)", async () => { + const member = {memberId: "someone", groupMemberId: 9000, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} + await bot.onJoinedGroupMember(joinedEvent(CUSTOMER_GROUP_ID, member)) + expect(chat.rawCmds.length).toBe(0) + expect(chat.sent.filter(s => s.chat[0] === ChatType.Direct).length).toBe(0) + }) + + test("joinedGroupMember from wrong user → ignored", async () => { + const member = {memberId: "someone", groupMemberId: 9001, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} + await bot.onJoinedGroupMember(joinedEvent(TEAM_GROUP_ID, member, GROK_USER_ID)) + expect(chat.rawCmds.length).toBe(0) + }) +}) + +describe("parseConfig Validation", () => { + const baseArgs = ["--team-group", "Support"] + + test("--complete-hours non-numeric → throws", () => { + expect(() => parseConfig([...baseArgs, "--complete-hours", "abc"])) + .toThrow(/--complete-hours must be a non-negative integer, got "abc"/) + }) + + test("postgres backend without --pg-conn → throws", () => { + const prev = process.env.SIMPLEX_BACKEND + process.env.SIMPLEX_BACKEND = "postgres" + try { + expect(() => parseConfig(baseArgs)) + .toThrow(/--pg-conn is required when backend is postgres/) + } finally { + if (prev === undefined) delete process.env.SIMPLEX_BACKEND + else process.env.SIMPLEX_BACKEND = prev + } + }) + + test("postgres backend with --pg-conn → db is postgres DbConfig", () => { + const prev = process.env.SIMPLEX_BACKEND + process.env.SIMPLEX_BACKEND = "postgres" + try { + const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://user:pass@localhost/db"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) + } finally { + if (prev === undefined) delete process.env.SIMPLEX_BACKEND + else process.env.SIMPLEX_BACKEND = prev + } + }) + + test("postgres backend with --pg-schema → DbConfig carries schemaPrefix", () => { + const prev = process.env.SIMPLEX_BACKEND + process.env.SIMPLEX_BACKEND = "postgres" + try { + const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) + } finally { + if (prev === undefined) delete process.env.SIMPLEX_BACKEND + else process.env.SIMPLEX_BACKEND = prev + } + }) + + test("sqlite backend (default) → db is sqlite DbConfig with default filePrefix", () => { + const prevBackend = process.env.SIMPLEX_BACKEND + const prevNpm = process.env.npm_config_simplex_backend + delete process.env.SIMPLEX_BACKEND + delete process.env.npm_config_simplex_backend + try { + const cfg = parseConfig(baseArgs) + expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) + } finally { + if (prevBackend !== undefined) process.env.SIMPLEX_BACKEND = prevBackend + if (prevNpm !== undefined) process.env.npm_config_simplex_backend = prevNpm + } + }) + + test("sqlite backend with --sqlite-key → DbConfig carries encryptionKey", () => { + const cfg = parseConfig([...baseArgs, "--sqlite-key", "secret"]) + expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex", encryptionKey: "secret"}) + }) + + test("unknown flag → parseArgs throws", () => { + expect(() => parseConfig([...baseArgs, "--team-gropu", "typo"])) + .toThrow() + }) + + test("missing --team-group → throws", () => { + expect(() => parseConfig([])) + .toThrow(/required option '--team-group/) + }) + + test("invalid SIMPLEX_BACKEND → throws", () => { + const prev = process.env.SIMPLEX_BACKEND + process.env.SIMPLEX_BACKEND = "mysql" + try { + expect(() => parseConfig(baseArgs)) + .toThrow(/Invalid SIMPLEX_BACKEND: "mysql"/) + } finally { + if (prev === undefined) delete process.env.SIMPLEX_BACKEND + else process.env.SIMPLEX_BACKEND = prev + } + }) + + test("--complete-hours negative → throws", () => { + // parseArgs refuses "-1" as a bare arg (ambiguous with a short flag), so use `=` form + expect(() => parseConfig([...baseArgs, "--complete-hours", "-1"])) + .toThrow(/--complete-hours must be a non-negative integer, got "-1"/) + }) + + test("--card-flush-seconds non-numeric → throws", () => { + expect(() => parseConfig([...baseArgs, "--card-flush-seconds", "xyz"])) + .toThrow(/--card-flush-seconds must be a non-negative integer, got "xyz"/) + }) + + test("--timezone invalid IANA → throws", () => { + expect(() => parseConfig([...baseArgs, "--timezone", "Not/AZone"])) + .toThrow(/--timezone "Not\/AZone" is not a valid IANA time zone/) + }) + + test("--complete-hours 0 → allowed (disables auto-complete)", () => { + const cfg = parseConfig([...baseArgs, "--complete-hours", "0"]) + expect(cfg.completeHours).toBe(0) + }) + + test("valid IANA timezone → accepted", () => { + const cfg = parseConfig([...baseArgs, "--timezone", "America/New_York"]) + expect(cfg.timezone).toBe("America/New_York") + }) +}) + +describe("GrokApiClient HTTP timeout", () => { + test("chat() calls AbortSignal.timeout(60_000) and passes the signal to fetch", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({choices: [{message: {content: "ok"}}]}), {status: 200}), + ) + + const client = new GrokApiClient("test-key", [{role: "system", content: "system prompt"}]) + await client.chat([], "hello") + + expect(timeoutSpy).toHaveBeenCalledWith(60_000) + expect((fetchSpy.mock.calls[0][1] as RequestInit).signal).toBeInstanceOf(AbortSignal) + + fetchSpy.mockRestore() + timeoutSpy.mockRestore() + }) +}) + +// Lazy per-group command sync. sendToGroup always calls +// apiUpdateGroupProfile on the first send per group when the group's +// stored groupPreferences.commands don't match desiredCommands. Each +// group is synced at most once per process (cache hit on subsequent +// sends). +describe("Command sync in sendToGroup", () => { + beforeEach(() => setup()) + + test("first send → apiUpdateGroupProfile called once with merged commands", async () => { + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Hello, just a greeting.") + expect(chat.profileUpdates).toHaveLength(1) + const {groupId, profile} = chat.profileUpdates[0] + expect(groupId).toBe(CUSTOMER_GROUP_ID) + expect(profile.groupPreferences.commands).toEqual(DESIRED_COMMANDS) + // Existing groupProfile fields (displayName, fullName) are preserved. + expect(profile.displayName).toBe(`Group${CUSTOMER_GROUP_ID}`) + expect(profile.fullName).toBe("") + // The actual message still goes out after the sync. + expect(chat.lastSentTo(CUSTOMER_GROUP_ID)).toBe("Hello, just a greeting.") + }) + + test("group already has desired commands → no apiUpdateGroupProfile, but still cached", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID) + gi.groupProfile.groupPreferences = {commands: DESIRED_COMMANDS} + chat.groups.set(CUSTOMER_GROUP_ID, gi) + + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok for help.") + expect(chat.profileUpdates).toHaveLength(0) + // Cache was populated — a subsequent send even against a divergent DB + // won't re-check. + gi.groupProfile.groupPreferences = {commands: []} + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Send /team for a human.") + expect(chat.profileUpdates).toHaveLength(0) + }) + + test("cache: two sends to same group → sync only once", async () => { + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok first.") + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Or send /team.") + expect(chat.profileUpdates).toHaveLength(1) + expect(chat.sentTo(CUSTOMER_GROUP_ID)).toHaveLength(2) + }) + + test("independent per group: different groups each sync separately", async () => { + const gId2 = 101 + chat.groups.set(gId2, makeGroupInfo(gId2)) + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok.") + await bot.sendToGroup(gId2, "Send /team.") + expect(chat.profileUpdates.map(p => p.groupId).sort()).toEqual([CUSTOMER_GROUP_ID, gId2].sort()) + }) + + test("merge preserves existing group preference fields (files, etc.)", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID) + gi.groupProfile.groupPreferences = { + files: {enable: "on"}, + reactions: {enable: "on"}, + } + chat.groups.set(CUSTOMER_GROUP_ID, gi) + + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok.") + expect(chat.profileUpdates).toHaveLength(1) + const prefs = chat.profileUpdates[0].profile.groupPreferences + expect(prefs.commands).toEqual(DESIRED_COMMANDS) + expect(prefs.files).toEqual({enable: "on"}) + expect(prefs.reactions).toEqual({enable: "on"}) + }) +}) + +// loadGrokContext: documented behavior is "plain text → single system +// message". A `.yaml` / `.yml` extension is an undocumented alternative +// that parses the harness transcript format and surfaces only `system` +// and `assistant` turns; `user` entries are dropped so they don't merge +// with the customer's runtime message. +describe("loadGrokContext", () => { + const dir = mkdtempSync(join(tmpdir(), "support-bot-context-")) + const writeFile = (name: string, content: string): string => { + const p = join(dir, name) + writeFileSync(p, content) + return p + } + + test("plain text (.txt) → single system message with full file content", () => { + const path = writeFile("ctx.txt", "You are Grok.\n\nBe concise.") + expect(loadGrokContext(path)).toEqual([ + {role: "system", content: "You are Grok.\n\nBe concise."}, + ]) + }) + + test("no extension → treated as plain text", () => { + const path = writeFile("plain", "raw context") + expect(loadGrokContext(path)).toEqual([{role: "system", content: "raw context"}]) + }) + + test(".md → treated as plain text (does not look like YAML)", () => { + const path = writeFile("ctx.md", "# Heading\n\nbody") + expect(loadGrokContext(path)).toEqual([ + {role: "system", content: "# Heading\n\nbody"}, + ]) + }) + + test(".yaml → parses transcript and keeps only system + assistant turns", () => { + const path = writeFile("ctx.yaml", + "- role: system\n message: Be terse.\n" + + "- role: user\n message: What is async?\n" + + "- role: assistant\n message: Cooperative concurrency.\n", + ) + expect(loadGrokContext(path)).toEqual([ + {role: "system", content: "Be terse."}, + {role: "assistant", content: "Cooperative concurrency."}, + ]) + }) + + test(".yml extension also triggers YAML parsing", () => { + const path = writeFile("ctx.yml", + "- role: system\n message: hi\n", + ) + expect(loadGrokContext(path)).toEqual([{role: "system", content: "hi"}]) + }) + + test("YAML parsing is case-insensitive on extension", () => { + const path = writeFile("ctx.YAML", + "- role: system\n message: hi\n", + ) + expect(loadGrokContext(path)).toEqual([{role: "system", content: "hi"}]) + }) + + test("YAML preserves multi-line literal block scalars verbatim", () => { + const path = writeFile("multiline.yaml", + "- role: assistant\n message: |\n line one\n line two\n", + ) + expect(loadGrokContext(path)).toEqual([ + {role: "assistant", content: "line one\nline two\n"}, + ]) + }) + + test("YAML with only user-role entries → empty array", () => { + const path = writeFile("only-user.yaml", + "- role: user\n message: a\n" + + "- role: user\n message: b\n", + ) + expect(loadGrokContext(path)).toEqual([]) + }) + + test("empty YAML file → empty array", () => { + const path = writeFile("empty.yaml", "") + expect(loadGrokContext(path)).toEqual([]) + }) + + test("YAML non-list top level throws", () => { + const path = writeFile("not-list.yaml", "role: system\nmessage: x\n") + expect(() => loadGrokContext(path)).toThrow(/top-level must be a list/) + }) + + test("YAML entry with unknown role throws", () => { + const path = writeFile("bad-role.yaml", "- role: bogus\n message: x\n") + expect(() => loadGrokContext(path)).toThrow(/entry 0 has invalid role/) + }) + + test("YAML entry missing role throws", () => { + const path = writeFile("no-role.yaml", "- message: x\n") + expect(() => loadGrokContext(path)).toThrow(/entry 0 has invalid role/) + }) + + test("YAML entry with non-string message throws", () => { + const path = writeFile("bad-message.yaml", "- role: user\n message: 42\n") + expect(() => loadGrokContext(path)).toThrow(/entry 0 has non-string message/) + }) + + test("YAML entry that is not a mapping throws", () => { + const path = writeFile("bad-entry.yaml", "- just a string\n- role: user\n message: x\n") + expect(() => loadGrokContext(path)).toThrow(/entry 0 is not a mapping/) + }) + + test("malformed YAML throws", () => { + const path = writeFile("malformed.yaml", "- role: user\n message: [unclosed\n") + expect(() => loadGrokContext(path)).toThrow(/failed to parse YAML/) + }) + + test("missing file throws ENOENT", () => { + expect(() => loadGrokContext(join(dir, "does-not-exist.yaml"))).toThrow() + }) +}) diff --git a/apps/simplex-support-bot/package-lock.json b/apps/simplex-support-bot/package-lock.json new file mode 100644 index 0000000000..eddbcb2dff --- /dev/null +++ b/apps/simplex-support-bot/package-lock.json @@ -0,0 +1,2038 @@ +{ + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "license": "AGPL-3.0", + "dependencies": { + "@simplex-chat/types": "^0.6.0", + "async-mutex": "^0.5.0", + "commander": "^14.0.3", + "simplex-chat": "^6.5.1", + "yaml": "^2.8.4" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplex-chat/types": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.6.0.tgz", + "integrity": "sha512-QVYvaRsS6TnS+IROjNkekZYvhvy8QoA8vKCuGe9E6lplXDVutJo9tdDOSWS9NDdtwxT1wRZ29zN4xEZEEG/NHw==", + "license": "AGPL-3.0", + "dependencies": { + "typescript": "^5.9.2" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simplex-chat": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.1.tgz", + "integrity": "sha512-1cv91iMCqtP+9R1PQM3NKazocoUInKT2/06pIOuzORD5/VzulR6cMZnEQmaT02Jz+8FVkEKTsaAIJfVP6/tJmw==", + "hasInstallScript": true, + "license": "AGPL-3.0", + "dependencies": { + "@simplex-chat/types": "^0.6.0", + "extract-zip": "^2.0.1", + "fast-deep-equal": "^3.1.3", + "node-addon-api": "^8.5.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/apps/simplex-support-bot/package.json b/apps/simplex-support-bot/package.json new file mode 100644 index 0000000000..97caee2278 --- /dev/null +++ b/apps/simplex-support-bot/package.json @@ -0,0 +1,24 @@ +{ + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@simplex-chat/types": "^0.7.0", + "async-mutex": "^0.5.0", + "commander": "^14.0.3", + "simplex-chat": "^6.5.2", + "yaml": "^2.8.4" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + }, + "author": "SimpleX Chat", + "license": "AGPL-3.0" +} diff --git a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md new file mode 100644 index 0000000000..c3c11ef61f --- /dev/null +++ b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md @@ -0,0 +1,1471 @@ +# SimpleX Support Bot — Implementation Plan + +## 1. Executive Summary + +SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs` native NAPI binding. Single `ChatApi` instance with two user profiles (main bot + Grok agent) sharing one SQLite database. A `profileMutex` serializes all profile-switching + SimpleX API calls. Team sees active conversations as cards in a dashboard group — no text forwarding. Implements flow: Welcome → Queue → Grok/Team-Pending → Team. + +## 2. Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Support Bot Process (Node.js) │ +│ │ +│ chat: ChatApi ← ChatApi.init("./data/simplex") │ +│ Single database, two user profiles │ +│ │ +│ mainUserId ← non-Grok user (default name: │ +│ "Ask SimpleX Team") │ +│ • Business address, event routing, state mgmt │ +│ • Controls group membership │ +│ │ +│ grokUserId ← "Grok" profile │ +│ • Joins customer groups as Member │ +│ • Sends Grok responses into groups │ +│ │ +│ profileMutex: serialize apiSetActiveUser + call │ +│ GrokApiClient → api.x.ai/v1/chat/completions │ +└─────────────────────────────────────────────────┘ +``` + +- Single Node.js process, single `ChatApi` instance via native NAPI +- Two user profiles in one database. The main profile is returned directly from `bot.run()`. The Grok profile's `userId` is persisted to `state.json` as `grokUserId` on the first run (when the bot creates it); subsequent runs identify Grok strictly by that persisted ID (never by display name, which a rename would invalidate). The main profile's displayName is set only on fresh-DB user creation (`"Ask SimpleX Team"`) and is never rewritten by bot code thereafter — `bot.run()` is invoked with `updateProfile: false`. Bot commands (`/grok`, `/team`) are never pushed via global `apiUpdateProfile`; instead they sync lazily per-group in `sendToGroup` — the first send to each group triggers `syncGroupCommands(groupId)`, which verifies the group's `groupPreferences.commands` against `desiredCommands` and calls `apiUpdateGroupProfile` if different (scoped broadcast to that group's members only). Subsequent sends to the same group are cache hits. +- `profileMutex` serializes `apiSetActiveUser(userId)` + the subsequent SimpleX API call. Grok HTTP API calls run **outside** the mutex. +- Events delivered for all profiles — routed by `event.user` field (main → main handler, Grok → Grok handler) +- Business address auto-accept creates a group per customer +- Grok is a second profile invited as a Member — appears as a separate participant +- No cross-profile ID mapping needed — Grok profile uses its own local group IDs from its own events + +## 3. Project Structure + +``` +apps/simplex-support-bot/ +├── package.json # deps: simplex-chat, @simplex-chat/types, async-mutex; devDeps: vitest, @types/node +├── tsconfig.json # ES2022, strict, Node16 module resolution +├── vitest.config.ts # test runner config, path aliases for mocks +├── src/ +│ ├── index.ts # Entry: parse config, init instance, run +│ ├── config.ts # CLI arg parsing, ID:name validation, Config type +│ ├── bot.ts # SupportBot class: state derivation, event dispatch, cards +│ ├── cards.ts # Card formatting, debouncing, lifecycle +│ ├── grok.ts # GrokApiClient: xAI API wrapper, system prompt, history +│ ├── messages.ts # All user-facing message templates +│ └── util.ts # isWeekend, profileMutex, logging helpers +├── bot.test.ts # Vitest suite (154 tests, 31 describes) +├── test/ +│ └── __mocks__/ +│ ├── simplex-chat.js # MockChatApi + utility re-exports +│ └── simplex-chat-types.js # enum re-exports for tests +└── data/ # SQLite databases (created at runtime) +``` + +The Grok system-prompt / context file is supplied at runtime via `--context-file ` (see §4). It is not part of the repo tree. + +## 4. Configuration + +**CLI flags:** + +| Flag | Required | Default | Format | Purpose | +|------|----------|---------|--------|---------| +| `--db-prefix` | No | `./data/simplex` | path | Database file prefix (both profiles share it) | +| `--team-group` | Yes | — | `name` | Team group display name (auto-created if absent, resolved by persisted ID on restarts) | +| `--auto-add-team-members` / `-a` | No | `""` | `ID:name,...` | Comma-separated team member contacts. Validated at startup — exits on mismatch. | +| `--context-file` | Required when `GROK_API_KEY` set | — | path | Grok system-prompt file (SimpleX documentation context). `parseConfig` throws if `GROK_API_KEY` is set without this flag. | +| `--timezone` | No | `"UTC"` | IANA tz | For weekend detection (24h vs 48h). Weekend = Sat 00:00 – Sun 23:59 in this tz. `parseConfig` validates the value by constructing a probe `Intl.DateTimeFormat` and throws with a clear error on `RangeError` (invalid IANA zone) — bot exits before init. | +| `--complete-hours` | No | `3` | integer ≥ 0 | Hours of customer inactivity after last team/Grok reply before auto-completing a conversation (✅). `parseConfig` rejects non-numeric, negative, or `NaN` values with a fail-fast error. `0` is allowed and disables auto-complete. | +| `--card-flush-seconds` | No | `300` | integer ≥ 0 | Seconds between card dashboard update flushes. `parseConfig` rejects non-numeric, negative, or `NaN` values with a fail-fast error. `0` is allowed and disables periodic flush (card updates still occur on explicit `scheduleUpdate` callers but never auto-drain). | + +**Env vars:** `GROK_API_KEY` (optional) — xAI API key. If unset or empty, the bot starts with Grok support fully disabled: it logs `"No GROK_API_KEY provided, disabling Grok support"`, skips Grok profile/contact setup and event handler registration, omits `/grok` from the bot command list, drops the `/grok` clause from customer-facing messages, and treats any `/grok` the customer still types as an unknown command. + +**Numeric argument validation:** `parseConfig` MUST validate every numeric flag (`--complete-hours`, `--card-flush-seconds`) using a helper that throws on non-finite or negative results, rather than raw `parseInt`: + +```typescript +function parseNonNegativeInt(raw: string, flag: string): number { + const n = parseInt(raw, 10) + if (!Number.isFinite(n) || n < 0) { + throw new Error(`${flag} must be a non-negative integer, got "${raw}"`) + } + return n +} + +const completeHours = parseNonNegativeInt(optionalArg(args, "--complete-hours", "3"), "--complete-hours") +const cardFlushSeconds = parseNonNegativeInt(optionalArg(args, "--card-flush-seconds", "300"), "--card-flush-seconds") +``` + +Rationale: `parseInt("foo", 10)` returns `NaN`, and `NaN * 3600_000 === NaN`. Every subsequent comparison (`now - lastTeamGrokTime >= completeMs`) is `false`, so the feature silently becomes a no-op — auto-complete never fires, cards never auto-refresh — and the operator has no signal that they typo'd a flag. Failing fast at startup surfaces the typo before customers interact. `0` is explicitly allowed as a valid "disable" setting. + +**Timezone validation:** `parseConfig` MUST validate `--timezone` by constructing a probe `Intl.DateTimeFormat`: + +```typescript +try { + new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}) +} catch (err) { + throw new Error(`--timezone "${timezone}" is not a valid IANA time zone: ${(err as Error).message}`) +} +``` + +Rationale: `isWeekend` is called from `queueMessage` and `teamAddedMessage` — both run on the hot customer message path. `new Intl.DateTimeFormat(..., {timeZone: , ...})` throws `RangeError: Invalid time zone specified` at every call. Without startup validation, a typo in `--timezone` turns every `/grok`, `/team`, or first-customer-message dispatch into an unhandled error that crashes the per-item handler (though the outer try/catch in `onNewChatItems` contains it, customers receive no reply at all). Validating once at startup surfaces the typo in the operator's console before any customer interaction. + +```typescript +interface Config { + dbPrefix: string + teamGroup: {id: number; name: string} // id=0 at parse time, resolved at startup + teamMembers: {id: number; name: string}[] + grokContactId: number | null // always restored from state file at startup (even when Grok API is disabled, so the one-way gate can identify and remove Grok members) + timezone: string + completeHours: number // default 3 + cardFlushSeconds: number // default 300 + contextFile: string | null // path to Grok system-prompt file; required when grokApiKey !== null + grokApiKey: string | null // null when GROK_API_KEY is not set → Grok disabled +} +``` + +**State file** — `{dbPrefix}_state.json` (co-located with DB files): +```json +{"teamGroupId": 123, "grokContactId": 4} +``` + +Only two keys. All other state is persisted in the group's `customData` (per-conversation state, card IDs) or derived from group metadata (`apiListMembers`). Display data like message counts is read from chat history on demand. + +**Grok contact resolution** (state-file lookup always runs; contact establishment only when enabled): +1. Read `grokContactId` from state file → validate via `apiListContacts` → set `config.grokContactId` (this always runs, even when `grokApiKey === null`, so the one-way gate can identify and remove Grok members from groups) +2. If not found and `grokEnabled`: main profile creates one-time invite link, Grok profile connects, wait for a `contactConnected` event filtered by profile identity (60s — see "Grok contact identification" below), persist the resulting `contactId` atomically before proceeding. +3. If unavailable (with Grok otherwise enabled), bot runs but `/grok` returns "temporarily unavailable" +4. If `grokApiKey === null`: the Grok profile is not resolved or created, no invite link is issued — but `config.grokContactId` is still set from the state file if the contact exists. + +### Grok contact identification + +`grokContactId` is written once and used forever — it is the single identifier for every subsequent Grok check (one-way gate, `onMemberConnected` skip, `isGrok` in card rendering). Identification MUST be narrowly scoped so that the `contactId` stored is unambiguously Grok's and no other contact completing a handshake in the 60s establishment window can be latched by mistake. + +Use the predicate form of `ChatApi.wait`. The signature (defined in `node_modules/simplex-chat/src/api.ts:217`) is: + +```typescript +wait( + event: K, + predicate: ((event: ChatEvent & {type: K}) => boolean) | undefined, + timeout: number, +): Promise +``` + +The implementation (api.ts:234) keeps the subscriber attached when the predicate returns `false`, so non-matching events are silently discarded and the wait continues until a matching event arrives or the timeout fires. + +Identification accepts only a `contactConnected` event observed by the MAIN profile (the profile whose `apiCreateLink` issued the invite, and whose `contactId` we persist and later pass to `apiAddMember`) whose connecting contact's profile `displayName` equals the Grok profile's displayName: + +```typescript +const grokProfileName = grokUser.profile.displayName // "Grok" (canonical) +const evt = await chat.wait( + "contactConnected", + (e) => + e.user.userId === mainUser.userId && + e.contact.profile.displayName === grokProfileName, + 60_000, +) +if (!evt) { + console.error(`Timeout waiting for Grok contact (60s, displayName="${grokProfileName}"). ` + + `Check SMP relay availability or re-run after clearing state. Exiting.`) + process.exit(1) +} +config.grokContactId = evt.contact.contactId +state.grokContactId = config.grokContactId +writeState(stateFilePath, state) // atomic: tmp-file + rename (see §13 state persistence) +log(`Grok contact established: ID=${config.grokContactId} (displayName="${grokProfileName}")`) +``` + +Filter rationale: +- `e.user.userId === mainUser.userId` selects the main profile's view of the handshake. Both profiles observe the handshake (the Grok-side event describes the main profile as the `contact`); only the main-side event carries the `contactId` we need for subsequent `apiAddMember` calls. +- `e.contact.profile.displayName === grokProfileName` accepts only the contact whose profile matches the Grok profile just created/updated. This rejects stray inbound contacts (late business-request acceptance, operator test DM, a reconnect of an existing contact) that may complete in the same 60s window. The displayName is read from `evt.contact.profile`, which is `LocalProfile` (see `@simplex-chat/types/src/types.ts:2867`). + +`grokProfileName` is captured from `grokUser.profile.displayName` immediately before the wait, so whichever name the Grok profile was created/updated with earlier in startup is the exact string matched here. + +Single-tenant deployment caveat: if a human contact happens to set its SimpleX displayName to the literal `"Grok"` and completes a handshake with the main profile in the 60s window, the displayName filter alone cannot distinguish them. MVP is single-tenant and Grok's profile is created by the bot itself, so this is not expected in practice; deployments that need stronger guarantees can add a second filter (e.g. `e.contact.profile.image === grokImage` — the bot knows the exact image bytes it assigned to the Grok profile). + +Persistence: `writeState` is atomic (tmp-file + `fs.renameSync`, see §13 "State persistence") so a crash between identification and persistence cannot corrupt the state file. `state.grokContactId` is flushed to disk BEFORE proceeding to bot event wiring — if the process dies after wiring but before persistence, the next startup would issue a second invite link and leave the first Grok contact orphaned in the database. + +**Team group resolution** (auto-create): +1. Read `teamGroupId` from state file → validate via group list +2. If not found: create with `apiNewGroup`, persist new group ID +3. If found: compare `fullGroupPreferences` (directMessages, fullDelete, commands) and displayName with desired values. Only call `apiUpdateGroupProfile` if something differs — avoids unnecessary SMP relay round-trips on every restart. + +**Team group invite link lifecycle:** +1. Delete stale link (best-effort), create new link, print to stdout. Creation is best-effort — if the SMP relay is unreachable, the error is logged and the bot continues without an invite link. The 10-minute deletion timer is only scheduled if creation succeeded. +2. Delete after 10 minutes. On SIGINT/SIGTERM, delete before exit. Deletion must go through `profileMutex` with `apiSetActiveUser(mainUserId)` — the active user may be the Grok profile at the time the timer fires or the signal arrives. + +**Team member validation:** +- If `--auto-add-team-members` (`-a`) provided: validate each contact ID/name pair, fail-fast on mismatch +- If not provided: `/team` tells customers "no team members available yet" + +## 5. State Derivation (Stateless) + +Per-conversation state is stored in the group's `customData` and written at the moment the bot handles each transition (customer's first message, `/grok`, `/team`, team member's first message). On subsequent events `deriveState` returns the stored state as-is — composition changes (team members leaving, Grok leaving) do **not** demote the stored state. The customer's mode (e.g. "waiting for a team response") is meaningful even when no team member is currently present; keeping the state preserves that. Composition is read only by specific handlers (e.g. the `/team` duplicate-invite guard). No chat-history scans for state decisions. No in-memory conversations map — survives restarts. + +**WELCOME detection:** customData has no `state` field until the bot handles the first transition. `deriveState` returns `WELCOME` precisely when `customData.state` is absent. + +**Type vs. persisted state.** The `ConversationState` union in `cards.ts` enumerates all five conceptual states (`WELCOME | QUEUE | GROK | TEAM-PENDING | TEAM`) so event handlers and composition can reason about them uniformly. However, `WELCOME` is NEVER written to `customData.state` — the runtime invariant is "persisted state ∈ {QUEUE, GROK, TEAM-PENDING, TEAM}; absence of the `state` field derives as WELCOME". The `isConversationState` guard in `cards.ts` rejects `WELCOME` on read to preserve this invariant (any stale `state: "WELCOME"` from a crashed transition is treated as absent). Do NOT introduce a separate `PersistedState` type in MVP — the invariant is small enough to enforce at two choke points: `getRawCustomData` on read and the dispatch handlers on write. + +**State-write matrix:** + +| Bot-observed event | `customData.state` written | +|---|---| +| *(initial — no customData yet)* | *(absent ⇒ WELCOME)* | +| Customer's first non-command message | `QUEUE` | +| `/grok` handled — Grok invited | `GROK` | +| `/team` handled — team members added (written at handler time; does not wait for team acceptance) | `TEAM-PENDING` | +| First team-member text message observed | `TEAM` | + +**State is authoritative and monotonic.** Once written, `customData.state` persists across member leave/join events. The only path that clears it is the existing `onLeftMember` handler when the customer themselves leaves — at that point the entire customData is cleared. + +**Failure-path revert is CAS-guarded.** `activateGrok` runs fire-and-forget, so its `setStateOnFail` revert (`QUEUE`) can race with a concurrent transition (e.g. `/team` writing `TEAM-PENDING` while `waitForGrokJoin` is pending). To preserve monotonicity, `revertStateOnFail` is a compare-and-set: it only writes `setStateOnFail` if `customData.state === "GROK"` (the optimistic value both call sites write before invoking `activateGrok`). If another handler has since stamped a different state, the revert is skipped — the in-flight transition wins and stays. + +TEAM-PENDING takes priority over GROK when both Grok and team are present (after `/team` but before team member's first message). `/grok` remains available in TEAM-PENDING — if Grok is not yet in the group, it gets invited; if already present, the command is ignored. + +**State derivation helpers:** +- `getGroupComposition(groupId)` → `{grokMember, teamMembers}` from `apiListMembers` — used for card rendering and the `/team` duplicate-invite guard. +- `deriveState(groupId)` → reads `customData.state`. Returns `WELCOME` iff `customData.state` is absent. No composition lookup. +- `getLastCustomerMessageTime(groupId)` / `getLastTeamOrGrokMessageTime(groupId)` → chat-history timestamp reads used by the card renderer for wait-time and auto-complete only (display, not state). + +**Transitions:** +``` +WELCOME ──(1st msg)──────> QUEUE (send queue msg, create card 🆕) +WELCOME ──(/grok 1st)────> GROK (skip queue msg, create card 🤖) +WELCOME ──(/team 1st)────> TEAM-PENDING (skip queue msg, add team members, create card 👋) +QUEUE ──(/grok)──────────> GROK (invite Grok, update card) +QUEUE ──(/team)──────────> TEAM-PENDING (add team members, update card) +GROK ──(/team)───────────> TEAM-PENDING (add all team members, Grok stays, update card) +GROK ──(user msg)────────> GROK (Grok responds, update card) +TEAM-PENDING ──(/grok)───> invite Grok if not present, else ignore (state stays TEAM-PENDING) +TEAM-PENDING ──(/team)───> reply "already invited" (if team members still present; else re-add silently) +TEAM-PENDING ──(team msg)> TEAM (remove Grok, disable /grok permanently, update card) +TEAM ──(/grok)───────────> reply "team mode", stay TEAM +``` + +## 6. Card-Based Dashboard + +The team group is a live dashboard. The bot maintains exactly one message ("card") per active customer conversation. Cards are deleted and reposted on changes — the group is always a current snapshot. + +### Card format + +Card is a single message. The join command is the final line of the card text — there is no separate join message. + +``` +[ICON] *[Customer Name]* · [wait] · [N msgs] +[STATE][· agent1, agent2, ...] +"[last message(s), truncated]" +/'join [id]' +``` + +**Icons:** + +| Icon | Condition | +|------|-----------| +| 🆕 | QUEUE — first message < 5 min ago | +| 🟡 | QUEUE — waiting < 2 h | +| 🔴 | QUEUE — waiting > 2 h | +| 🤖 | GROK — Grok handling | +| 👋 | TEAM — team added, no reply yet | +| 💬 | TEAM — team has replied, conversation active (customer replied after team) | +| ⏰ | TEAM — customer follow-up unanswered > 2 h | +| ✅ | Done — no customer reply for `completeHours` (default 3h) after last team/Grok message | + +**State labels:** `Queue`, `Grok`, `Team – pending`, `Team` + +**Agents:** comma-separated display names of team members in the group. Omitted when none. + +**Message count:** All messages in chat history except the bot's own (`groupSnd` from main profile). + +**Message preview:** Last several messages, most recent last, separated by ` / `. Newlines in message text are replaced with spaces to prevent card layout bloat from spam. The customer's display name is sanitized (newlines → spaces) for the card header; the `/join` command embeds only the numeric group id. Newest messages are prioritized — when the total exceeds ~500 chars (`maxTotal = 500` in `composeCard`), the oldest messages are truncated (with `[truncated]` prepended) while the newest are always shown. When truncation occurs, the first visible message is guaranteed to have a sender prefix even if it was a continuation in the original sequence. Each message is prefixed with the sender's name (`Name: message`) on the first message in a consecutive run from that sender - subsequent messages from the same sender omit the prefix until a different sender's message appears. Sender identification: Grok contact is detected by `grokContactId` and labeled "Grok"; the customer is identified by matching `memberId` to the group's `customerId` and labeled with their display name; all other members use their `memberProfile.displayName`. Bot's own messages (`groupSnd`) are excluded. Each message truncated to ~200 chars. Media-only messages show type labels: `[image]`, `[file]`, `[voice]`, `[video]`. + +**Join command:** the final line of the card renders as `/'join '` where `` is the customer group's numeric ID. The outer single quotes around `join ` are rendered by SimpleX clients as a clickable quoted command; tapping it sends `/join ` back to the team group. The handler does not pattern-match the message text — it uses the framework's structured command parser (`util.ciBotCommand`) which returns `{keyword: "join", params: ""}` directly from the chat item. The handler then converts `params` to an integer via `Number.parseInt(params, 10)` and rejects anything that is not a positive integer. There is no legacy `/join :` form — the card never emits it, so the handler never needs to strip it. + +### Card lifecycle + +**Tracking:** `{state, cardItemId, complete?}` stored in customer group's `customData` via `apiSetGroupCustomData`. `state` is the canonical conversation state (`QUEUE | GROK | TEAM-PENDING | TEAM`); `cardItemId` is the team-group chat item ID for the (single) card message; `complete` flags the auto-completed state. Absence of `state` means WELCOME. Written at event time by the dispatch handlers — `/grok` handler writes `GROK` on invite; `/team` handler writes `TEAM-PENDING` immediately (does not wait for team acceptance); first observed team-member text message writes `TEAM`; first customer text message writes `QUEUE`. Read back from `groupInfo.customData` — single source of truth, survives restarts. All writes go through `CardManager.mergeCustomData` to preserve fields across independent write paths. + +**Create** — on first customer message (→ QUEUE) or `/grok` as first message (→ GROK): +1. Compose card text (including the `/'join '` final line) +2. Post it via `apiSendMessages(chatRef, [{msgContent: {type: "text", text}, mentions: {}}])` → get one `chatItemId`. The card is a single message; the `/'join '` line is clickable because SimpleX clients render the slash-prefixed single-quoted token as a clickable command even inside a multi-line message. +3. Write `{cardItemId}` to customer group's `customData` + +**Update** (delete + repost) — on every subsequent event (new customer msg, team/Grok reply, state change, agent join): +1. Read `{cardItemId}` from `customData` +2. Delete old card via `apiDeleteChatItems([Group, teamGroupId], [cardItemId], "broadcast")`. Per `simplex-chat/src/api.ts:436-445` the call either returns `T.ChatItemDeletion[]` (possibly empty if the item no longer exists) or throws `ChatCommandError`. Both outcomes are acceptable: the surrounding `try { ... } catch { /* log and continue */ }` allows execution to proceed whether the item was still present, already gone, or the server returned a transient error. +3. Post new card as a single message via `apiSendMessages` → get new `cardItemId`. **On failure** the partial-failure policy below applies: log, re-queue this groupId into `pendingUpdates`, return without writing `customData`. +4. Write `{cardItemId, complete?}` to `customData` via `mergeCustomData`. **On failure** the tracking-write policy below applies. + +**Debouncing:** Card updates debounced globally — pending changes flushed every `cardFlushSeconds` seconds (default 300, configurable via `--card-flush-seconds`). Within a batch, each group's card reposted at most once with latest state. + +**Wait time rules:** Time since the customer's last unanswered message. For ✅ (auto-completed) conversations, the wait field shows the literal string "done". If customer sends a follow-up, wait time resets to count from that message. + +**Auto-complete:** A conversation is marked ✅ when `completeHours` (default 3h, configurable via `--complete-hours`) have passed since the last team/Grok message **without any customer reply**. The card debounce flush (every 300 seconds / 5 min, configurable via `--card-flush-seconds`) checks elapsed time and transitions to ✅ when the threshold is met. Customer follow-up at any point — including after ✅ — reverts to the derived active icon (👋/💬/⏰ for team states, 🟡/🔴 for queue), and wait time resets from that message. + +**Card icon derivation (TEAM states) — computed at each card render by comparing the timestamps of the most recent customer and team/Grok messages in the group; nothing about the icon is stored:** +``` +Team added, no reply yet → 👋 +Team replied → 💬 +Customer follow-up unanswered >2h → ⏰ +No customer reply for completeHours → ✅ +Customer sends after ✅ → back to 💬 or ⏰ (derived from wait time) +``` + +**Cleanup** — customer leaves: card remains (TBD retention), clear `customData`. + +**Restart recovery:** On startup, `CardManager.refreshAllCards()` lists all groups, finds those with `customData.cardItemId` set and `customData.complete` not set, sorts by `cardItemId` ascending (higher ID = more recently updated), and re-posts them oldest-first so the most recently active cards end up at the bottom of the team group. Completed cards (`complete: true`) and old/pre-bot groups (no `customData`) are skipped. Old card messages are deleted before reposting; deletion failures (e.g., >24h old) are silently ignored. Individual card failures are caught and logged without aborting the batch. + +### Partial-failure and retry policy + +`createCard` and `updateCard` perform a multi-step sequence (delete + send + customData write). To design the correct policy we MUST be explicit about which failures the SimpleX core already handles for us vs. which surface to the bot: + +**SimpleX core semantics** (per `simplex-chat/src/api.ts` JSDoc): +- `apiSendMessages` — "Network usage: background". The call returns `newChatItems` once the chat item is CREATED LOCALLY (written to SQLite) and the SMP broadcast is QUEUED. The core's background machinery retries relay delivery transparently — **the bot never observes a transient relay failure from `apiSendMessages`**. A thrown `ChatCommandError` means the local create step itself failed: permission denied, chat does not exist, invalid content, DB locked/corrupted. +- `apiDeleteChatItems` — "Network usage: background". Same pattern: local delete + queued broadcast + core-managed delivery retry. A thrown error means the local delete step failed (item not found, permission, DB error). +- `apiSetGroupCustomData` — "Network usage: **no**". Pure local SQLite write, no SMP involvement at all. A thrown error means a local DB error. + +Consequence: failures surfaced to the bot are **terminal local errors** (bad state, DB problem, permission change), not transient network blips. Retrying the same operation against the same DB/relay state will usually hit the same error. Retry value comes from the narrow slice of genuinely transient local conditions — a brief SQLite lock held by a concurrent write, a race with group-state mutation elsewhere in the same process — where the next attempt sees a different state. + +This reshapes the policy: the bot does not need aggressive retry for "network" reasons (core handles that), and compensating actions for customData-write failure are rarely useful (if the pure-local customData write fails, the retry's customData write will almost certainly fail for the same reason). The bot needs a light safety net: re-queue on any step failure, let the flush loop try again at most once per `cardFlushSeconds`, and on persistent failure accept that operator intervention is needed. + +Policy (applies to both `createCard` and `updateCard`): + +**Any step fails** — whether step 2 (delete), step 3 (send), or step 4 (customData write): +- Log via `logError` with `{groupId, step, err}` so the operator can diagnose the underlying cause (permission change, DB corruption, bot removed from team group, etc). +- Re-add `groupId` to `pendingUpdates` via `this.scheduleUpdate(groupId)`. +- Return. Do NOT attempt compensating actions (no compensating delete for tracking-write failure — the scenario where send succeeds locally but customData write fails requires the SQLite DB to be healthy-then-unhealthy between two synchronous calls in the same transaction window, which is not a realistic transient state; the retry path handles any resulting duplicate by reading the stale `cardItemId` and deleting it on the next update attempt). + +**Flush dispatch** — the current `flush` loop calls `updateCard` unconditionally and `updateCard` returns early when `customData.cardItemId` is unset. This silently drops the retry path for a failed `createCard` — the group is in `pendingUpdates` but nothing will ever create a card for it. Replace with a single `flushOne(groupId)` that reads `customData` once and dispatches to create or update: + +```typescript +private async flushOne(groupId: number): Promise { + const groupInfo = await this.getGroupInfo(groupId) + if (!groupInfo) return // group deleted + const customData = this.deriveCustomData(groupInfo) + if (customData.complete) return // ✅ conversations don't auto-repost + if (typeof customData.cardItemId === "number") { + await this.updateCard(groupId, groupInfo) + } else { + await this.createCard(groupId, groupInfo) + } +} + +async flush(): Promise { + const groups = [...this.pendingUpdates] + this.pendingUpdates.clear() + for (const groupId of groups) { + try { await this.flushOne(groupId) } + catch (err) { + logError(`flush failed for group ${groupId}`, err) + this.scheduleUpdate(groupId) // re-queue on any thrown error + } + } +} +``` + +Retry behavior for each failure point under this design: + +| Failure point | `customData` after failure | Retry's `flushOne` path | Retry outcome if condition cleared | +|---|---|---|---| +| `createCard` send fails | `cardItemId` absent | create-path | fresh card posted, `customData` written | +| `updateCard` delete fails | old `cardItemId` still set | update-path | delete retried (idempotent — see below) + send + write | +| `updateCard` send fails (delete succeeded) | old (now-deleted) `cardItemId` still set | update-path | delete retried against stale ID — tolerated (see below) — then send + write | +| `updateCard` write fails (send succeeded, duplicate may exist) | old `cardItemId` still set, new card orphaned in team group | update-path | delete retried against stale old ID — tolerated — new card posted, tracking written; **leaked** new card from the failed attempt persists until operator removes it | + +**Delete idempotency on retry** — `apiDeleteChatItems` against already-deleted IDs returns either an empty `ChatItemDeletion[]` or throws `ChatCommandError`. The step-2 `try { ... } catch { logError(...) }` swallows both; execution proceeds to step 3. Do NOT escalate a step-2 error to the partial-failure policy — that would create a retry loop for a permanent condition (items past the 24h deletion window will throw on every retry forever). + +**Persistent failures** — if the underlying condition is not transient (bot removed from team group, DB corruption, permission revoked), every retry hits the same error and the group stays in `pendingUpdates` indefinitely, logging at each flush. MVP accepts this — the operator-visible log stream makes the problem diagnosable. A bounded-retry-with-backoff-and-giveup strategy can be added later without changing the failure-point table above. + +### Card implementation + +```typescript +class CardManager { + private pendingUpdates = new Set() // groupIds with pending updates + private flushInterval: NodeJS.Timeout + + constructor(private chat: ChatApi, private config: Config, private mainUserId: number, + flushIntervalMs = 300 * 1000) { + this.flushInterval = setInterval(() => this.flush(), flushIntervalMs) + this.flushInterval.unref() + } + + scheduleUpdate(groupId: number): void { + this.pendingUpdates.add(groupId) + } + + async createCard(groupId: number, groupInfo: T.GroupInfo): Promise { + const {text} = await this.composeCard(groupId, groupInfo) + // Single-message card — the `/'join '` line is the final line of `text`. + const items = await this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + await this.chat.apiSetGroupCustomData(groupId, { + cardItemId: items[0].chatItem.meta.itemId, + }) + } + + async flush(): Promise { + const groups = [...this.pendingUpdates] + this.pendingUpdates.clear() + for (const groupId of groups) { + await this.updateCard(groupId) + } + } + + async refreshAllCards(): Promise { + const groups = await this.chat.apiListGroups(mainUserId) + const activeCards = groups + .filter(g => typeof g.customData?.cardItemId === "number" && !g.customData?.complete) + .map(g => ({groupId: g.groupId, cardItemId: g.customData.cardItemId})) + // Sort ascending by cardItemId (higher = more recently updated) + activeCards.sort((a, b) => a.cardItemId - b.cardItemId) + for (const {groupId} of activeCards) { + try { await this.updateCard(groupId) } + catch (err) { logError(`Startup card refresh failed for group ${groupId}`, err) } + } + } + + private async updateCard(groupId: number): Promise { + // Read customData via apiListGroups + const customData = ... // {cardItemId} from groupInfo.customData + if (!customData?.cardItemId) return + // Delete old card message + try { + await this.chat.apiDeleteChatItems(Group, teamGroupId, + [customData.cardItemId], "broadcast") + } catch {} // card may already be deleted + const {text, complete} = await this.composeCard(groupId, groupInfo) + const items = await this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + const data = { + cardItemId: items[0].chatItem.meta.itemId, + ...(complete ? {complete: true} : {}), + } + await this.chat.apiSetGroupCustomData(groupId, data) + } + + private async composeCard(groupId: number, groupInfo: T.GroupInfo): Promise<{text: string, complete: boolean}> { + // Icon, state, agents, preview (with sender-name prefixes), /'join ' — per spec format + // The final line of `text` is `/'join '` — clickable in SimpleX clients. + // buildPreview(chatItems, customerName, customerId) — prefixes each sender's first message in a run + // Preview messages joined with blue "/" separator: " !3 /! " (SimpleX markdown for blue colored text) + // Message text is escaped via escapeStyledMarkdown() before joining — inserts U+200B after "!" + // when followed by a color trigger (1-6,r,g,b,y,c,m,-) to prevent false markdown interpretation. + // No escape mechanism exists in the SimpleX markdown parser for "!" styled text. + // complete = (icon === "✅") + } +} +``` + +## 7. Bot Initialization + +**Main bot** uses `bot.run()` with `events` parameter: + +```typescript +let supportBot: SupportBot + +const [chat, mainUser, mainAddress] = await bot.run({ + profile: {displayName: "Ask SimpleX Team", fullName: "", image: supportImage}, + dbOpts: {dbFilePrefix: config.dbPrefix}, + options: { + addressSettings: { + businessAddress: true, + autoAccept: true, + welcomeMessage, + }, + commands: [ + {type: "command", keyword: "grok", label: "Ask Grok"}, + {type: "command", keyword: "team", label: "Switch to team"}, + ], + useBotProfile: true, + updateProfile: false, // bot code never rewrites displayName/image/etc. + }, + events: { + acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), + newChatItems: (evt) => supportBot?.onNewChatItems(evt), + chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt), + chatItemReaction: (evt) => supportBot?.onChatItemReaction(evt), + leftMember: (evt) => supportBot?.onLeftMember(evt), + joinedGroupMember: (evt) => supportBot?.onJoinedGroupMember(evt), + connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), + newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), + contactConnected: (evt) => supportBot?.onContactConnected(evt), + contactSndReady: (evt) => supportBot?.onContactSndReady(evt), + }, +}) +``` + +Note: `/grok` and `/team` are passed in `options.commands` so `bot.run()` has a profile to use when `apiCreateActiveUser` is needed on a fresh DB, but since `updateProfile: false` is set, `bot.run()` never writes the profile on subsequent runs. The user profile's `preferences.commands` is intentionally not pushed globally at startup — broadcasting `XInfo` to every contact is not wanted. Instead, the `SupportBot` takes `desiredCommands` as a constructor argument and syncs commands lazily per-group: `sendToGroup` (`src/bot.ts`) always calls `syncGroupCommands(groupId)` before dispatching the message. That helper reads the group via `apiGetChat(Group, groupId, 0)` (local, no network), and if `groupPreferences.commands` differs from `desiredCommands`, issues `apiUpdateGroupProfile` with the merged profile. `apiUpdateGroupProfile` broadcasts `XGrpInfo`/`XGrpPrefs` to group members only (scoped to the chat audience). Already-synced groups are cached in `syncedGroups: Set` so subsequent sends skip the read entirely — the first send per group costs one local read; every later send is a cache hit. Earlier drafts used a regex on the outgoing text to skip the sync when no command keyword appeared; that optimization was removed because the cache already makes repeated syncs free and the parser was a fragile source of correctness bugs. `/join` is registered as a team group command separately — after team group is resolved, call `apiUpdateGroupProfile(teamGroupId, groupProfile)` with `groupPreferences` including the `/join` command definition. Customer sending `/join` in a customer group → treated as ordinary message (unrecognized command). + +**Grok profile** — resolved from same ChatApi instance. Grok is identified strictly by the `userId` persisted in `state.json`; there is no by-name fallback (a renamed profile would otherwise be silently mistaken): + +```typescript +let grokUser: T.User | null = null +if (state.grokUserId !== undefined) { + const users = await chat.apiListUsers() + grokUser = users.find(u => u.user.userId === state.grokUserId)?.user ?? null + if (!grokUser) { + throw new Error( + `Persisted Grok userId=${state.grokUserId} not found in DB. ` + + `Either restore the user or delete state.json to re-create Grok.` + ) + } +} else { + // First run: create Grok and persist its userId immediately. + grokUser = await chat.apiCreateActiveUser({displayName: "Grok", fullName: "", image: grokImage}) + // apiCreateActiveUser sets Grok as active — switch back to main + await chat.apiSetActiveUser(mainUser.userId) + state.grokUserId = grokUser.userId + writeState(stateFilePath, state) +} + +// Refresh Grok's profile if it has drifted from the canonical values. +const grokProfile = {displayName: "Grok", fullName: "", image: grokImage} +const current = util.fromLocalProfile(grokUser.profile) +if (current.image !== grokProfile.image || current.displayName !== grokProfile.displayName || current.fullName !== grokProfile.fullName) { + await chat.apiSetActiveUser(grokUser.userId) + await chat.apiUpdateProfile(grokUser.userId, grokProfile) + await chat.apiSetActiveUser(mainUser.userId) +} +``` + +**Profile mutex** — all SimpleX API calls go through: + +```typescript +import {Mutex} from "async-mutex" + +const profileMutex = new Mutex() + +async function withProfile(userId: number, fn: () => Promise): Promise { + return profileMutex.runExclusive(async () => { + await chat.apiSetActiveUser(userId) + return fn() + }) +} +``` + +Grok HTTP API calls are made **outside** the mutex to avoid blocking. + +**Per-group customData mutex** — `mergeCustomData` and `clearCustomData` must be serialized per customer group. `mergeCustomData` has two awaits (read via `getRawCustomData` → `apiListGroups`, then write via `apiSetGroupCustomData`); between them the event loop runs, so two concurrent async chains operating on the same `groupId` can both read the same snapshot, both produce a merged object, and the second write clobbers the first's patch. + +Concrete call sites that can overlap on one `groupId`: +- `processMainChatItem` writing `state` transitions (WELCOME→QUEUE, WELCOME→GROK, QUEUE→GROK, one-way gate →TEAM) +- `activateGrok`'s `revertStateOnFail` (fire-and-forget) racing with subsequent customer messages +- `activateTeam` writing `TEAM-PENDING` racing with `/grok` or another `/team` on the same group +- `CardManager.flush → updateCard` writing `{cardItemId, complete}` racing with dispatch writing `state` +- `createCard` writing `{cardItemId}` immediately after dispatch writes `state` + +The CAS-on-state inside `revertStateOnFail` guards only the `state` key — other keys (`cardItemId`, `complete`) can still be lost when spread from a stale snapshot. + +Implementation: + +```typescript +// In CardManager +private customDataMutexes = new Map() + +private getCustomDataMutex(groupId: number): Mutex { + let m = this.customDataMutexes.get(groupId) + if (!m) { m = new Mutex(); this.customDataMutexes.set(groupId, m) } + return m +} + +async mergeCustomData(groupId: number, patch: Partial): Promise { + return this.getCustomDataMutex(groupId).runExclusive(async () => { + const current = (await this.getRawCustomData(groupId)) ?? {} + const merged = {...current, ...patch} + for (const key of Object.keys(merged) as (keyof CardData)[]) { + if (merged[key] === undefined) delete merged[key] + } + await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged)) + }) +} + +async clearCustomData(groupId: number): Promise { + return this.getCustomDataMutex(groupId).runExclusive(() => + this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId)) + ) +} +``` + +Nesting rule: the per-group customData mutex is the **outer** lock; `profileMutex` (via `withMainProfile`) is the **inner** lock. Never acquire them in the opposite order, and never hold the customData mutex while calling an external (non-SimpleX) async function — this prevents cross-group deadlock and keeps the critical section short. + +Cleanup: entries in `customDataMutexes` are bounded by the number of customer groups. Removing the entry on `onLeftMember(customer)` is sufficient (the group's `customData` is also cleared at that point). Skip this refinement in MVP if acceptable — a long-running bot with many customers accumulates a few bytes per group. + +**Profile images:** Both profiles have base64-encoded JPEG profile pictures (128x128, quality 85, under the 12,500-char data URI limit enforced by iOS/Android clients) set via the `image` field in `T.Profile`. The images are defined as `data:image/jpg;base64,...` string constants in `index.ts`. The main profile image is passed to `bot.run()` which handles update-on-change automatically. The Grok profile image is passed to `apiCreateActiveUser()` on first run; on subsequent runs, the bot compares the current profile against the desired one using `util.fromLocalProfile()` and calls `apiUpdateProfile()` if any field differs — this sends the update to all Grok contacts. + +**Startup sequence:** +0. **Active user recovery + name preservation:** Two related safeguards. + + **(a) Active user recovery.** On restart, the active user may be Grok (if the previous run was killed mid-profile-switch). `bot.run()` uses `apiGetActiveUser()` and would then operate against Grok's `userId` as if it were the main user. Fix: when `state.grokUserId` is set (i.e. this is not the very first run), pre-init the DB with a temporary `ChatApi` and compare the active user's `userId` against `state.grokUserId`. If they match, `apiListUsers()` + `apiSetActiveUser()` to the single non-Grok user — throw loudly if zero or multiple candidates exist, rather than silently picking. Close the temporary `ChatApi` before `bot.run()` reopens it. Identification is by userId, never by display name; a renamed Grok profile would defeat name matching. + + **(b) Never rewrite the main profile.** The core auto-creates a preset contact named `"Ask SimpleX Team"` in every user's DB (`src/Simplex/Chat/Library/Internal.hs:2749`, exact name from commit `362bdc328` 2025-07-12). That collides with the bot's preferred main-profile displayName within the user's `display_names` namespace (`UNIQUE (user_id, local_display_name)`), so any attempt to rename the main profile to `"Ask SimpleX Team"` fails with `duplicateName`. Worse, `bot.run`'s internal `updateBotUserProfile` (`packages/simplex-chat-nodejs/dist/bot.js:176`) re-syncs image, preferences, and `contactLink` on every startup, and on a DB where `users.local_display_name` has drifted from `contact_profiles.display_name`, the fast path (`src/Simplex/Chat/Store/Profiles.hs:311`) silently rewrites the customer-facing `contact_profiles.display_name`. Fix: pass `options.updateProfile: false` to `bot.run()` so the bot code never calls `apiUpdateProfile` on its own initiative. Whatever displayName the CLI saw is what stays. + + **(c) Lazy per-group command sync.** The bot's command list (`/grok`, `/team`) is synced lazily and per-group, not globally. `sendToGroup` (in `src/bot.ts`) unconditionally calls `syncGroupCommands(groupId)` before dispatching the message. That helper uses `apiGetChat(Group, groupId, 0)` (local DB read, no network) to read the current `groupProfile.groupPreferences.commands`, and if it doesn't match `desiredCommands`, issues `apiUpdateGroupProfile` with the commands merged in. `apiUpdateGroupProfile` broadcasts `XGrpInfo`/`XGrpPrefs` to group members only — scoped to the chat audience, never the whole contact list. Groups confirmed in-sync are cached in `syncedGroups: Set` so the first send per group costs one local read; every later send is a cache hit. No `apiUpdateProfile` (global XInfo broadcast) is ever invoked by bot code. Earlier drafts gated the sync behind a regex match on the outgoing text (to skip the read when no `/keyword` appeared); that optimization was removed because the cache already made repeated syncs free and the parser was a fragile source of correctness bugs. +1. `bot.run()` → init ChatApi, create/resolve main profile (with profile image), business address. Print business address link to stdout. +2. Resolve Grok profile: if `state.grokUserId` is set, look it up by ID via `apiListUsers()` (throw if missing); otherwise create via `apiCreateActiveUser()` and persist the new `userId`. Then compare the resolved profile against the canonical `{displayName, fullName, image}` and call `apiUpdateProfile()` if anything changed — pushes to Grok's contacts. +3. Read `{dbPrefix}_state.json` for `teamGroupId` and `grokContactId` +4. Enable auto-accept DM contacts: `apiSetAutoAcceptMemberContacts(mainUser.userId, true)` +5. List contacts, resolve Grok contact (from state or auto-establish) +6. Resolve team group (from state or auto-create) +7. Ensure direct messages + delete for everyone enabled on team group (conditional — only updates profile if preferences or name differ from desired) +8. Create team group invite link (best-effort), schedule 10min deletion if created +9. Validate `--auto-add-team-members` (`-a`) if provided +10. Register Grok event handlers on `chat` (filtered by `event.user === grokUserId`) +10b. Refresh stale cards: `CardManager.refreshAllCards()` — lists all groups, skips those with `customData.complete` or no `customData.cardItemId`, sorts remaining by `cardItemId` ascending, re-posts oldest-first so newest cards land at the bottom of team group +11. On SIGINT/SIGTERM → `clearTimeout(inviteLinkTimer)` (noop if already deleted), `cards.destroy()` (stops the card-flush interval), `deleteInviteLink()` (profileMutex-gated `apiDeleteGroupLink`), `process.exit(0)`. Signal handler is reentrant-safe: an `inviteLinkDeleted` flag prevents double-deletion; `clearTimeout`/`clearInterval` are no-op on undefined. + +**Grok event registration** (same ChatApi, filtered by profile): + +```typescript +chat.on("receivedGroupInvitation", async (evt) => { + if (evt.user.userId !== grokUserId) return + supportBot?.onGrokGroupInvitation(evt) +}) +chat.on("newChatItems", async (evt) => { + if (evt.user.userId !== grokUserId) return + supportBot?.onGrokNewChatItems(evt) +}) +chat.on("connectedToGroupMember", (evt) => { + if (evt.user.userId !== grokUserId) return + supportBot?.onGrokMemberConnected(evt) +}) +``` + +## 8. Event Processing + +**Main profile event handlers:** + +| Event | Handler | Action | +|-------|---------|--------| +| `acceptingBusinessRequest` | `onBusinessRequest` | Enable file uploads + visible history on business group | +| `newChatItems` | `onNewChatItems` | Route: team group → handle `/join`; customer group → derive state, dispatch; direct message → reply with business address link | +| `chatItemUpdated` | `onChatItemUpdated` | Schedule card update | +| `leftMember` | `onLeftMember` | Customer left → cleanup, card remains. Grok left → cleanup. Team member left → revert if no message sent. | +| `joinedGroupMember` | `onJoinedGroupMember` | Team group joiner (link-join): initiate DM via `apiCreateMemberContact` + `apiSendMemberContactInvitation`. Fires for any member joining via group invite link. | +| `connectedToGroupMember` | `onMemberConnected` | In team group: send DM with contact ID (if not already sent by `onJoinedGroupMember`). In customer group: promote to Owner (unless customer or Grok). | +| `chatItemReaction` | `onChatItemReaction` | Team/Grok reaction in customer group → schedule card update (auto-complete) | +| `newMemberContactReceivedInv` | `onMemberContactReceivedInv` | Team group member DM contact received: send contact ID message immediately (dedup via `sentTeamDMs`) | +| `contactConnected` | `onContactConnected` | Deliver pending DM if queued (dedup via `sentTeamDMs`) | +| `contactSndReady` | `onContactSndReady` | Deliver pending DM if queued (dedup via `sentTeamDMs`) | + +**Grok profile event handlers:** + +| Event | Handler | Action | +|-------|---------|--------| +| `receivedGroupInvitation` | `onGrokGroupInvitation` | Look up `pendingGrokJoins`; if found, auto-accept via `apiJoinGroup`; if not found (race), buffer in `bufferedGrokInvitations` for `activateGrok` to drain | +| `connectedToGroupMember` | `onGrokMemberConnected` | Grok now fully connected — read last 100 msgs from own view, call Grok API, send initial response | +| `newChatItems` | `onGrokNewChatItems` | Batch dedup: collect last customer text message per group in the event. Skip groups with `grokInitialResponsePending` set (initial combined response in flight). For the selected message: read last 100 msgs, call Grok API, send response. Non-text (images, files, voice) → ignored by Grok (card update handled by main profile). | + +**Message routing in `onNewChatItems` (main profile):** + +```typescript +// For each chatItem: +// 1. Direct message (not group) → reply with business address link, stop +// 2. Team group (groupId === teamGroupId) → handle /join command +// 3. Skip non-business-chat groups +// 4. Skip groupSnd (own messages) +// 5. Identify sender via businessChat.customerId +// 6. Team member message → check if first team text (trigger one-way gate: remove Grok, disable /grok), schedule card update +// 7. Customer message → derive state, dispatch: +// - WELCOME: create card, send queue msg (or handle /grok first msg → WELCOME→GROK, skip queue) +// - QUEUE: /grok → invite Grok; /team → add ALL configured team members; else schedule card update +// - GROK: /team → add ALL configured team members (Grok stays); else schedule card update +// - TEAM-PENDING: /grok → invite Grok if not present, else ignore; /team → if team members still present, reply "already invited"; if all team members have left, re-add silently (state stays TEAM-PENDING); else no action +// - TEAM: /grok → reply "team mode"; else no action +``` + +## 9. One-Way Gate + +The gate is event-driven and persists its transitions. The initial `/team` guard reads `customData.state` AND group composition: if state is already `TEAM-PENDING`/`TEAM` **and** team members are still present, the bot replies `teamAlreadyInvitedMessage` without re-adding. If state is `TEAM-PENDING`/`TEAM` but all team members have left, the bot re-adds them (state stays `TEAM-PENDING`). The first-team-message detection writes `state: 'TEAM'` into customData at the moment the bot observes the message, then removes Grok and disables `/grok`. + +1. User sends `/team` → ALL configured `--auto-add-team-members` (`-a`) added to group (each promoted to Owner at invite time via `apiSetMembersRole`, re-asserted on connect as fallback) → Grok stays if present → TEAM-PENDING +2. Repeat `/team` → detected via `customData.state ∈ {TEAM-PENDING, TEAM}` **and team members still present** → reply with `teamAlreadyInvitedMessage`. If team members have since left, re-add them silently (state stays `TEAM-PENDING`). +3. `/grok` still works in TEAM-PENDING (if Grok not present, invite it; if present, ignore — Grok responds to customer messages) +4. Any team member sends first text message in customer group → **gate triggers**: + - Remove Grok from group (`apiRemoveMembers`) + - `/grok` permanently disabled → replies: "You are now in team mode. A team member will reply to your message." + - State = `TEAM` (written as `customData.state = 'TEAM'` at observation time) +5. Detection: in `onNewChatItems`, when sender is a team member and `customData.state !== 'TEAM'`, trigger the gate and write `state: 'TEAM'` via `mergeCustomData`. + +**Edge cases:** +- All team members leave before sending → state stays `TEAM-PENDING` (customer is still waiting for a response); sending `/team` re-adds them without the "already invited" reply. +- Team member leaves after sending → state stays `TEAM` (`customData.state` persists). Customer can send `/team` again to re-add team members. + +## 10. Grok Integration + +Grok is a **second user profile** in the same ChatApi instance. Self-contained: watches its own events, reads history from its own view, calls Grok HTTP API, sends responses. + +### Grok-disabled mode (no `GROK_API_KEY`) + +If `GROK_API_KEY` is unset or empty, `parseConfig` returns `grokApiKey: null` (via `process.env.GROK_API_KEY || null`, so `GROK_API_KEY=` is treated the same as unset; no throw) and `index.ts` derives `grokEnabled = config.grokApiKey !== null`. When `grokEnabled === false`: + +- Startup logs: `"No GROK_API_KEY provided, disabling Grok support"`. +- **`config.grokContactId` is still restored from the state file** (the lookup runs unconditionally before the `if (grokEnabled)` block). This ensures `getGroupComposition` can identify Grok members so the one-way gate can remove them when a team member sends a text message — even while Grok API is disabled. Without this, Grok members would become "phantom" members: physically present in groups but invisible to the state machine, preventing the gate from firing and causing dual responses (Grok + team) if Grok is later re-enabled. +- The Grok profile is not resolved or created (no `apiListUsers`/`apiCreateActiveUser` for "Grok"; no invite link issued). +- `GrokApiClient` is not instantiated. +- `SupportBot` receives `grokApi = null` and `grokUserId = null`. +- Bot command list registered at startup contains only `/team` — `/grok` is not advertised. +- Grok event handlers (`receivedGroupInvitation`, `connectedToGroupMember`, Grok-side `newChatItems`) are not registered. Handlers that are shared with the main profile (e.g. `onMemberConnected`) remain correct because their Grok checks are guarded by `this.config.grokContactId !== null`. +- Customer-facing messages (`queueMessage`, `noTeamMembersMessage`) accept a `grokEnabled` flag and drop the `/grok` clause when false. +- If the customer still types `/grok` manually, `processMainChatItem` rewrites `cmd` to `null` when `rawCmd?.keyword === "grok" && !this.grokEnabled`, so the dispatcher treats it as an unrecognized command (same as any other plain text). +- Defense in depth: `activateGrok` and `processGrokChatItem` short-circuit on entry when `this.grokApi === null`; `withGrokProfile` throws if called with `grokUserId === null`. + +Type signatures affected: +- `Config.grokApiKey: string | null` +- `SupportBot` constructor: `chat, grokApi: GrokApiClient | null, config, mainUserId, grokUserId: number | null, desiredCommands: T.ChatBotCommand[]` — `desiredCommands` is required (used by `sendToGroup`'s lazy per-group commands sync; see §20.4 suite 30 and the §7 "Note" describing `syncGroupCommands`). +- `queueMessage(timezone: string, grokEnabled: boolean): string` +- `noTeamMembersMessage(grokEnabled: boolean): string` (was a plain `const string`) + +### Grok join flow + +**Critical:** `activateGrok` awaits `waitForGrokJoin(120s)` which depends on future events dispatched through the same sequential event loop (`runEventsLoop` in api.ts). Awaiting it in an event handler deadlocks — the event loop is blocked waiting for events it can't dispatch. **Solution:** All `activateGrok` calls use `fireAndForget()` — tracked but not awaited. Tests call `bot.flush()` to await completion. + +**Main profile side (invite + failure detection):** +0. Send `grokInvitingMessage` ("Inviting Grok, please wait...") +1. **Set `grokInitialResponsePending.add(groupId)` FIRST** — the gate must be raised before any operation that could make Grok recognizable to `onGrokNewChatItems`. Specifically: before `apiAddMember`, before `pendingGrokJoins` is set, and before `bufferedGrokInvitations` is drained (which populates `reverseGrokMap`). Without this ordering, the sequence `apiAddMember → pendingGrokJoins.set → drain → reverseGrokMap.set → gate.add` contains a window where `reverseGrokMap` identifies the group as a Grok-active group but the gate is still DOWN. A customer message arriving in that window triggers a per-message response concurrent with the initial combined response — producing duplicate Grok replies. Every error path below MUST clear the gate. +2. **Pre-check via `apiListMembers`**: silent return if Grok is already in the group in any non-terminal status (covers `GSMemInvited`, which the SimpleX API would otherwise resend the invitation for without throwing). Then `apiAddMember(groupId, grokContactId, Member)` → get `member.memberId`. On `groupDuplicateMember` (race between pre-check and add — Grok joined as Connected meanwhile), **clear the gate** and silent return — the in-flight activation handles the outcome. On any other error, clear the gate, revert state, send `grokUnavailableMessage`. +3. Store `pendingGrokJoins.set(memberId, mainGroupId)` +4. Drain `bufferedGrokInvitations` — if the `receivedGroupInvitation` event arrived during step 2's await (race condition), process it now. (The gate is already up from step 1, so `onGrokNewChatItems` suppresses any per-message responses during drain and the subsequent join.) +5. `waitForGrokJoin(120s)` — awaits resolver from Grok profile's `connectedToGroupMember` (step 8 below) +6. Timeout → notify customer (`grokUnavailableMessage`), send queue message if was WELCOME→GROK, fall back to QUEUE (CAS-guarded: only if `customData.state` is still `GROK` — a concurrent `/team` that switched to `TEAM-PENDING` is respected), clear `grokInitialResponsePending` + +**Grok profile side (independent, triggered by its own events):** +7. `receivedGroupInvitation` → look up `pendingGrokJoins` by `evt.groupInfo.membership.memberId`. If found, auto-accept via `apiJoinGroup(groupId)`, set up `grokGroupMap` and `reverseGrokMap`. If not found (race: event arrived before step 2), buffer in `bufferedGrokInvitations` for step 3. Grok is NOT yet connected — cannot read history or send messages. +8. `connectedToGroupMember` → Grok now fully connected. Uses `reverseGrokMap` to find `mainGroupId`, resolves `grokJoinResolvers` — this unblocks step 5. + +**Back in `activateGrok` (after step 5 resolves):** +9. Read visible history — last 100 messages — build Grok API context (customer messages → `user` role) +10. If no customer messages found (visible history disabled or API failed), send generic greeting asking customer to repeat their question +11. Call Grok HTTP API (outside mutex) +12. Send response via `apiSendTextMessage` (through mutex with Grok profile) +13. Clear `grokInitialResponsePending` (via `finally` block — runs on success, failure, or early return). After this, per-message responses from `onGrokNewChatItems` resume normally for subsequent customer messages. Note: because the gate is raised at step 1 (before any other work), the `finally` block MUST be wired to cover every code path from step 1 onward — including the `groupDuplicateMember` silent-return and all revert/timeout branches — otherwise per-message responses stay suppressed indefinitely for the affected group. + +```typescript +const pendingGrokJoins = new Map() // memberId → mainGroupId +const bufferedGrokInvitations = new Map() // memberId → buffered event +const grokGroupMap = new Map() // mainGroupId → grokLocalGroupId +const reverseGrokMap = new Map() // grokLocalGroupId → mainGroupId +const grokJoinResolvers = new Map void>() // mainGroupId → resolve fn +const grokInitialResponsePending = new Set() // mainGroupIds where activateGrok is sending initial response +``` + +### Per-message Grok conversation + +Grok profile's `onGrokNewChatItems` handler: +1. **Batch deduplication:** When multiple customer messages arrive in a single `newChatItems` event (e.g., rapid messages delivered as a batch), collect the last customer message per group. Only the last message triggers a Grok API call — earlier messages are included in the history context via `apiGetChat`. Without this, each message in the batch would trigger a separate API call, and earlier calls would include later messages in their history (already in the group) — producing incoherent responses that reference messages "from the future" and duplicate replies. +2. **Initial response gate:** Skip groups where `grokInitialResponsePending` is set (checked via `reverseGrokMap` to translate Grok's local groupId to mainGroupId). This prevents per-message responses from racing with the initial combined response in `activateGrok`. +3. Only trigger for `groupRcv` **text** messages from customer (identified via `businessChat.customerId`) +4. Ignore: non-text messages (images, files, voice — card update handled by main profile), bot messages, own messages (`groupSnd`), team member messages +5. Read last 100 messages from own view (customer → `user`, own → `assistant`) +6. Call Grok HTTP API — different groups' calls run concurrently (see "Cross-group Grok parallelism" below). Per-group serialization of overlapping in-flight calls is NOT implemented in MVP (see §20.6). +7. Send response into group + +**Per-message error:** Send error message in group ("Sorry, I couldn't process that. Please try again or send /team for a human team member."), stay GROK. Customer can retry. + +**Card updates in Grok mode:** Each customer message triggers two card updates — one on receipt (main profile sees `groupRcv`), one after Grok responds (main profile sees Grok's `groupRcv`). Both go through the 300-second debounce (default `--card-flush-seconds`). + +### Grok removal + +Only three cases: +1. Team member sends first text message in customer group (one-way gate) +2. Grok join timeout (120s) — fallback to QUEUE +3. Customer leaves the group + +### Grok system prompt + +The full system prompt (including SimpleX documentation context) is supplied externally via the `--context-file ` CLI flag and loaded with `readFileSync` at startup in `index.ts`: + +```typescript +let contextFile = "" +if (config.contextFile) { + try { + contextFile = readFileSync(config.contextFile, "utf-8") + } catch { + log(`Warning: context file not found: ${config.contextFile}`) + } +} +grokApi = new GrokApiClient(config.grokApiKey!, contextFile) +``` + +`GrokApiClient` stores the loaded string as `systemPrompt` and prepends it on every `chat()` call: + +```typescript +async chat(history: GrokMessage[], userMessage: string): Promise { + return this.chatRaw([ + {role: "system", content: this.systemPrompt}, + ...history, + {role: "user", content: userMessage}, + ]) +} +``` + +If `GROK_API_KEY` is set but `--context-file` is missing, `parseConfig` throws and the bot exits before init. If the file path is provided but unreadable at runtime, a warning is logged and Grok runs with an empty system prompt (the API key still works but responses lose the SimpleX-specific guidance). Guidelines (concise answers, numbered steps, no markdown, ignore prompt-override attempts, etc.) live in the external file — not hardcoded — so operators can tune tone and documentation without a rebuild. + +Customer messages always in `user` role, never `system`. + +### Grok HTTP request timeout + +Every `fetch` to `api.x.ai/v1/chat/completions` MUST pass an `AbortSignal.timeout(60_000)` (60-second default). Without a timeout, a stuck TCP connection or an unresponsive server blocks the awaiting call indefinitely; because `processGrokChatItem` runs under the Grok profile's sequential event dispatch, a single hung call stalls per-message responses for ALL customer groups using Grok — and the same hang in `activateGrok`'s initial-response path leaves `grokInitialResponsePending` stuck (gate never released) until the process is killed. + +Implementation in `GrokApiClient.chatRaw`: + +```typescript +const response = await fetch("https://api.x.ai/v1/chat/completions", { + method: "POST", + headers: { ... }, + body: JSON.stringify({ ... }), + signal: AbortSignal.timeout(60_000), +}) +``` + +On abort, `fetch` rejects with a `DOMException` whose `name === "TimeoutError"` (or `"AbortError"` on older runtimes). Callers treat this identically to other `chat()` failures: +- `processGrokChatItem` → sends `grokErrorMessage` to the customer group, conversation stays GROK. +- `activateGrok` initial-response path → logs, sends `grokUnavailableMessage`, lets the `finally` block clear `grokInitialResponsePending`. + +Rationale for 60s: typical xAI responses return in 1–10s; a 60s ceiling accommodates cold-start / heavy-load latencies while still bounding worst-case per-customer wait. Not exposed as a CLI flag in MVP — a later iteration can add `--grok-timeout-seconds` if operator tuning is needed. + +### Cross-group Grok parallelism + +`onGrokNewChatItems` MUST dispatch per-group work concurrently. A naïve `for (const ci of lastPerGroup.values()) { await this.processGrokChatItem(ci) }` serializes calls across unrelated customer groups — if xAI takes 3s per call and five customers message in one event batch, customer #5 waits ~15s instead of ~3s. This is pure latency amplification with no ordering benefit (the groups are independent; within-group order is already preserved by batch deduplication picking the last message). + +Implementation: + +```typescript +async onGrokNewChatItems(evt: CEvt.NewChatItems): Promise { + const lastPerGroup = new Map() + for (const ci of evt.chatItems) { + // filter: groupRcv, customer text, not bot/team + // keep last per groupId + } + await Promise.allSettled( + [...lastPerGroup.values()].map((ci) => this.processGrokChatItem(ci)), + ) +} +``` + +Why `Promise.allSettled` (not `Promise.all`): one group's Grok API failure MUST NOT cancel or reject pending work for other groups. Each `processGrokChatItem` already handles its own errors (sends `grokErrorMessage`, logs); the outer handler only needs to wait until all per-group tasks finish before returning control to the event dispatcher. + +Concurrency bound: the number of distinct customer groups that have new Grok-eligible messages in a single event batch — typically ≤ the SimpleX batch-delivery size, practically small. No global semaphore needed in MVP. If xAI rate limits become a concern, add a shared semaphore later; orthogonal to this fix. + +Ordering guarantees preserved: +- Within a group, batch deduplication still picks only the latest message and earlier messages appear in the history context via `apiGetChat`. +- Across groups, there is no ordering requirement — each customer group is an independent conversation. +- The per-group gate (`grokInitialResponsePending`) still serializes against `activateGrok`'s initial response; this is a group-local check unaffected by cross-group parallelism. + +## 11. Team Group Commands + +| Command | Effect | +|---------|--------| +| `/join ` | Join specified customer group | + +**`/join` handling:** +1. Extract `{keyword, params}` from the chat item with `util.ciBotCommand(chatItem)`. The framework already parses the leading `/keyword` and returns the trimmed remainder as `params` — the handler does not run its own regex over the message text. Cards emit `/'join '`; a team-member tap delivers a chat item whose text is `/join `, which `ciBotCommand` returns as `{keyword: "join", params: ""}`. +2. Convert `params` to a number with `const targetGroupId = Number.parseInt(params, 10)`. If `Number.isNaN(targetGroupId) || targetGroupId <= 0`, reply in the team group with `Error: invalid group id "${params}"` and return. No regex, no `split(":")`, no legacy fallback — operators must use the numeric form (which is what the card always emits). +3. Validate target is a business group (has `businessChat` property) — error in team group if not. +4. Add requesting team member to customer group via `addOrFindTeamMember` (which calls `apiAddMember` + immediately `apiSetMembersRole(Owner)`). +5. On connect, `connectedToGroupMember` re-asserts Owner as an idempotent fallback (see §8). + +**Team member promotion:** Promotion happens at two points, both idempotent: +- **At invite time** — immediately after `apiAddMember`, `addOrFindTeamMember` calls `apiSetMembersRole(groupId, [memberId], Owner)`. The call is wrapped in try/catch: if the member is not yet connected and the API rejects, it's silently ignored (the connect-time promotion covers the fallback). SimpleX persists the role on `GSMemInvited` members so the role is active when they accept. This is only called for *newly invited* members — the pre-check in `addOrFindTeamMember` returns early for any member already in the group in a non-terminal status, so an already-invited member is not re-promoted. +- **On connect** — every `connectedToGroupMember` event in a customer group promotes to Owner unless the member is the customer or Grok. Idempotent. + +**DM handshake:** When a team member joins or connects in the team group, the bot sends a DM with the member's contact ID. Four delivery paths, deduplicated via `sentTeamDMs` Set: + +1. **`onJoinedGroupMember`** — fires when ANY member joins the team group via invite link (`joinedGroupMember` event). Calls `sendTeamMemberDM` without a `memberContact`. Since link-joiners typically have no existing DM contact, this creates the contact via `apiCreateMemberContact(groupId, groupMemberId)`, then sends the invitation with message via `apiSendMemberContactInvitation(contactId, msg)`. +2. **`onMemberConnected`** — `sendTeamMemberDM` called with `memberContact` from the event. If not already sent by path 1: + - If `contactId` exists: sends DM via `apiSendTextMessage`. + - If `contactId` is null: uses the same `apiCreateMemberContact` + `apiSendMemberContactInvitation` path as path 1. +3. **`onMemberContactReceivedInv`** — fires when the member initiates a DM first. Sends the contact ID message immediately. If send fails, queues for `contactConnected`/`contactSndReady`. +4. **`onContactConnected` / `onContactSndReady`** — delivers any pending DM queued by paths 1, 2, or 3. + +DM message: +> Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is `N:name` + +## 12. Message Templates + +```typescript +const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI. +Please ask any question about SimpleX Chat.` + +function queueMessage(timezone: string, grokEnabled: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `The team will reply to your message within ${hours} hours.` + if (!grokEnabled) return base + return `${base} + +If your question is about SimpleX, click /grok for an *instant Grok answer*. + +Send /team to switch back.` +} + +const grokActivatedMessage = `*You are chatting with Grok* - use any language.` + +function teamAddedMessage(timezone: string, grokPresent: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `We will reply within ${hours} hours.` + if (!grokPresent) return base + return `${base} +Grok will be answering your questions until then.` +} + +const teamAlreadyInvitedMessage = "A team member has already been invited to this conversation and will reply when available." + +const teamLockedMessage = "You are now in team mode. A team member will reply to your message." + +function noTeamMembersMessage(grokEnabled: boolean): string { + return grokEnabled + ? "No team members are available yet. Please try again later or click /grok." + : "No team members are available yet. Please try again later." +} + +const grokInvitingMessage = "Inviting Grok, please wait..." + +const grokUnavailableMessage = "Grok is temporarily unavailable. Please try again later or send /team for a human team member." + +const grokErrorMessage = "Sorry, I couldn't process that. Please try again or send /team for a human team member." + +const grokNoHistoryMessage = "I just joined but couldn't see your earlier messages. Could you repeat your question?" +``` + +`teamAddedMessage` takes a second `grokPresent` argument — when the customer switches from GROK → TEAM-PENDING (Grok still in the group until the gate triggers), the message appends a second line telling the customer Grok will keep answering until the team replies. Callers detect this by checking the current group composition for a Grok member before sending. + +**Weekend detection:** +```typescript +function isWeekend(timezone: string): boolean { + const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date()) + return day === "Sat" || day === "Sun" +} +``` + +## 13. Direct Message Handling + +If a user contacts the bot via a regular direct-message address (not business address), the bot replies with the business address link and does not continue the conversation. The reply is guarded by `chatItem.content.type === "rcvMsgContent"` — only actual text messages trigger the business address reply. System events on the DM contact (e.g. `contactConnected`, `rcvDirectEvent`) are ignored to prevent spam. + +## 14. Persistent State + +**State file:** `{dbPrefix}_state.json` — three keys: + +| Key | Type | Why persisted | +|-----|------|---------------| +| `teamGroupId` | number | Team group created once on first run | +| `grokContactId` | number | Bot↔Grok contact takes 60s to establish | +| `grokUserId` | number | Identifies the Grok user by ID across restarts; prevents silent mis-matching if the Grok profile is ever renamed | + +**Not persisted:** + +| State | Where it lives | +|-------|---------------| +| `state`, `cardItemId`, `complete` | Customer group's `customData` | +| `mainUserId` | Returned by `bot.run()` on startup; created fresh per DB | +| Message counts, timestamps | Derived from chat history | +| Customer name | Group display name | +| `pendingGrokJoins` | In-flight during 120s window only | +| `grokInitialResponsePending` | In-flight during `activateGrok` initial response only | +| Owner promotion | Idempotent: fired at invite time in `addOrFindTeamMember` and again on every `memberConnected` | + +**Failure modes:** +- State file deleted → new team group created, Grok contact re-established (60s delay) +- Grok remains in groups it was already in — self-contained, continues responding via own events + +## 15. Error Handling + +| Scenario | Handling | +|----------|----------| +| ChatApi init fails | Exit (let process manager restart) | +| Active user is Grok on restart | Pre-init DB, find main user, set active, close — before `bot.run()` | +| Grok join timeout (120s) | Notify customer, fall back to QUEUE | +| Grok API error (initial or per-message) | Send error in group, stay GROK. Customer can retry or `/team`. | +| `apiAddMember` fails | Send error msg, stay in current state | +| `groupDuplicateMember` on Grok invite | Silent return — in-flight activation handles the outcome (customer sent `/grok` again before join completed) | +| `apiRemoveMembers` fails | Ignore (member may have left) | +| `apiDeleteChatItems` fails (card) | Ignore, post new card, overwrite `customData` | +| Customer leaves | Cleanup in-memory state, card remains | +| Team member leaves (no message sent) | State stays `TEAM-PENDING` (`customData.state` persists). Customer's next `/team` re-adds silently. | +| Team member leaves (message sent) | State stays `TEAM` (`customData.state` persists). Customer's next `/team` re-adds silently. | +| No `--auto-add-team-members` (`-a`) configured | `/team` → "no team members available yet" | +| `grokContactId` unavailable | `/grok` → "temporarily unavailable" | +| Member already in group when `/team` re-runs | `addOrFindTeamMember` pre-checks via `apiListMembers` and skips BOTH `apiAddMember` and the invite-time `apiSetMembersRole(Owner)` entirely if the contact is present in any non-terminal status (so an `Invited`-but-not-yet-accepted member is never re-invited — the SimpleX API would otherwise resend the invitation for `GSMemInvited` — and is never re-promoted) | + +## 16. API Call Map + +| # | Operation | Instance | Method | When | +|---|-----------|----------|--------|------| +| 1 | Init bot | main | `bot.run()` | Startup | +| 2 | List users | chat | `apiListUsers()` | Startup — resolve profiles | +| 3 | Create Grok user | chat | `apiCreateActiveUser()` | First run | +| 4 | Set active user | chat | `apiSetActiveUser(userId)` | Before every API call (via mutex) | +| 5 | Resolve team group | main | `apiNewGroup()` / state file | Startup | +| 6 | Create team invite link | main | `apiCreateGroupLink()` | Startup | +| 7 | Delete team invite link | main | `apiDeleteGroupLink()` | 10min / shutdown | +| 8 | Auto-accept DM | main | `apiSetAutoAcceptMemberContacts(userId, true)` | Startup | +| 9 | List contacts | main | `apiListContacts()` | Startup — validate members | +| 10 | Establish Grok contact | main+grok | `apiCreateLink()` + `apiConnectActiveUser()` | First run | +| 11 | Update group profile | main | `apiUpdateGroupProfile()` | Business request; startup (conditional — only if preferences differ) | +| 12 | Send msg to customer | main | `apiSendTextMessage([Group, gId], text)` | Various | +| 13 | Post card to team group | main | `apiSendMessages(chatRef, [{card text with /'join ' final line}])` | Card create/update — one message per card | +| 14 | Delete card | main | `apiDeleteChatItems([Group, teamGId], [cardItemId], "broadcast")` | Card update | +| 15 | Set customData | main | `apiSetGroupCustomData(gId, data)` | Card lifecycle | +| 16 | Invite Grok | main | `apiAddMember(gId, grokContactId, Member)` | `/grok` | +| 17 | Grok joins | grok | `apiJoinGroup(gId)` | `receivedGroupInvitation` | +| 18 | Grok reads history | grok | `apiGetChat([Group, gId], 100)` | After join + per message | +| 19 | Grok sends response | grok | `apiSendTextMessage([Group, gId], text)` | After API call | +| 20 | Add team member | main | `apiAddMember(gId, teamContactId, Member)` | `/team`, `/join` — only when not already in group | +| 21 | Promote to Owner | main | `apiSetMembersRole(gId, [memberId], Owner)` | Immediately after #20 (invite-time) AND `connectedToGroupMember` (fallback) | +| 22 | Remove Grok | main | `apiRemoveMembers(gId, [memberId])` | Gate trigger / timeout / leave | +| 23 | List members | main | `apiListMembers(gId)` | State derivation, duplicate check | +| 24 | Register team commands | main | `apiUpdateGroupProfile(teamGId, profile)` | Startup — register `/join` in team group | +| 25 | Get group info | main | `apiListGroups()` + find by ID | Card compose — read `customData.cardItemId` from `groupInfo` | +| 26 | Create DM contact | main | `apiCreateMemberContact(gId, memberId)` | `joinedGroupMember` / `onMemberConnected` — bot-initiated DM with team member | +| 27 | Send DM invitation | main | `apiSendMemberContactInvitation(contactId, msg)` | After #26 — sends invite with message in one step | + +## 17. Implementation Sequence + +**Phase 1: Scaffold** +- `package.json`, `tsconfig.json`, `config.ts`, `util.ts` (isWeekend, profileMutex) +- `index.ts`: init ChatApi, resolve both profiles, state file, startup sequence +- **Verify:** Instance inits, profiles resolved, Grok contact established, team group created + +**Phase 2: Event processing + cards** +- `bot.ts`: SupportBot class, state derivation helpers, event dispatch +- `cards.ts`: CardManager — format, debounce, lifecycle (create/update/cleanup) +- `messages.ts`: all templates +- Handle `acceptingBusinessRequest` → enable file uploads + visible history +- Handle `newChatItems` → WELCOME/QUEUE routing, card creation +- Handle DM → reply with business address link +- **Verify:** Customer connects → welcome → sends msg → card appears in team group → queue reply + +**Phase 3: Grok integration** +- `grok.ts`: GrokApiClient with system prompt + docs +- Grok event handlers (invitation → join, newChatItems → respond) +- `/grok` activation: invite, wait join, Grok reads history + responds independently +- `/grok` as first message (WELCOME → GROK, skip queue) +- Per-message Grok conversation + serialization per group +- **Verify:** `/grok` → Grok joins as separate participant → responds from "Grok" + +**Phase 4: Team mode + one-way gate** +- `/team` → add team members, Grok stays +- One-way gate: detect first team text → remove Grok, disable `/grok` +- `/join` command in team group (validate business group, add member, promote Owner) +- DM handshake with team members +- Team member promotion on `connectedToGroupMember` +- **Verify:** Full flow: QUEUE → /grok → GROK → /team → TEAM-PENDING → team msg → TEAM + +**Phase 5: Polish** +- Edge cases: customer leave, Grok timeout, member leave, restart recovery +- Team group invite link lifecycle +- Graceful shutdown +- Supply Grok context via `--context-file ` at runtime (required when `GROK_API_KEY` is set) +- End-to-end test all flows + +## 18. Self-Review Requirement + +Each code artifact must undergo adversarial self-review/fix loop: +1. Write/edit code +2. Self-review against this plan: correctness, completeness, all state transitions, all API calls, all error cases +3. Fix issues found +4. Repeat until **2 consecutive zero-issue passes** +5. Report completion → user reviews → if changes needed, restart from step 1 + +## 19. Verification + +**Startup:** +```bash +cd apps/simplex-support-bot +npm install +# With Grok support: +GROK_API_KEY=xai-... npx ts-node src/index.ts \ + --team-group SupportTeam \ + --timezone America/New_York \ + --context-file ./context.md + +# Without Grok (logs "No GROK_API_KEY provided, disabling Grok support"): +npx ts-node src/index.ts \ + --team-group SupportTeam \ + --timezone America/New_York +``` + +**Test scenarios:** +1. Connect → verify welcome message, business address link printed to stdout +2. Send question → verify card appears in team group (🆕), queue reply received +3. `/grok` → verify Grok joins, responses from "Grok", card updates to 🤖 +4. `/grok` as first message → verify WELCOME→GROK, no queue message, card 🤖 +5. `/team` in GROK → verify team added, Grok stays, card 👋 Team-pending +6. `/grok` in TEAM-PENDING → verify Grok still responds +7. Team member sends text → verify Grok removed, `/grok` rejected, card → 💬 +8. `/grok` in TEAM → verify "team mode" rejection +9. `/team` when already invited → verify "already invited" message +10. Card debouncing: multiple rapid events → verify single card update per 300s flush (default) +11. `/join` from team group → verify team member added to customer group, promoted to Owner +12. `/join` with non-business group → verify error +13. Weekend → verify "48 hours" +14. Customer leaves → verify cleanup, card remains +15. Grok timeout → verify fallback to QUEUE, queue message sent +16. Grok API error (per-message) → verify error in group, stays GROK +17. Grok no-history fallback → verify generic greeting sent +18. Non-text message in GROK mode → verify no Grok API call, card updated +19. Team/Grok reaction → verify card auto-complete (✅ icon, "done") +20. DM contact text message → verify business address link reply +21. DM contact non-message event (e.g. contactConnected) → verify no reply (rcvMsgContent guard) +22. DM handshake via `joinedGroupMember` → team member joins team group via link → verify `apiCreateMemberContact` + `apiSendMemberContactInvitation` called, contact ID message sent +23. DM handshake via `connectedToGroupMember` → verify contact ID message sent (dedup with #22) +24. Restart → verify same team group + Grok contact from state file, cards resume via `customData` +25. No `--auto-add-team-members` (`-a`) → `/team` → verify "no team members available" +26. Repeated `/team` while members are still in `Invited` status → verify `apiAddMember` is NOT called again (pre-check in `addOrFindTeamMember` returns the existing member) +27. Team member leaves (no message sent) → verify revert to QUEUE +28. Team member leaves (message sent), customer sends `/team` → verify re-adds team members +29. Card preview sender prefixes → verify first message in each consecutive sender run gets `Name:` prefix, subsequent same-sender messages do not +30. `/team` after all team members left → verify re-adds team members (not "already invited") + +### Critical Reference Files + +- **Native library API:** `packages/simplex-chat-nodejs/src/api.ts` +- **Bot automation:** `packages/simplex-chat-nodejs/src/bot.ts` +- **Utilities:** `packages/simplex-chat-nodejs/src/util.ts` +- **Types:** `packages/simplex-chat-client/types/typescript/src/types.ts` +- **Events:** `packages/simplex-chat-client/types/typescript/src/events.ts` +- **Product spec:** `apps/simplex-support-bot/plans/20260207-support-bot.md` + +## 20. Testing + +Vitest 1.x (Node 18 compatible). All tests verify **observable behavior** — messages sent, members added/removed, cards posted/deleted, API calls made — never internal state. + +### 20.1 Mock Infrastructure + +**Approach:** Vite resolve aliases redirect native-dependent packages to lightweight JS stubs at build time. Tests import from TypeScript source (`./src/bot.js`) — Vitest transpiles inline, so mocks apply before any code runs. + +**Files:** + +| File | Purpose | +|------|---------| +| `bot.test.ts` | All tests (co-located with source) | +| `vitest.config.ts` | Resolve aliases, globals, timeout | +| `test/__mocks__/simplex-chat.js` | CJS stub: `api.ChatApi`, `util.ciContentText`, `util.ciBotCommand`, `util.contactAddressStr` | +| `test/__mocks__/simplex-chat-types.js` | CJS stub: `T.ChatType`, `T.GroupMemberRole`, `T.GroupMemberStatus`, `T.GroupFeatureEnabled`, `T.CIDeleteMode` | + +```typescript +// vitest.config.ts +export default defineConfig({ + test: { globals: true, testTimeout: 10000 }, + resolve: { + alias: { + "simplex-chat": path.resolve(__dirname, "test/__mocks__/simplex-chat.js"), + "@simplex-chat/types": path.resolve(__dirname, "test/__mocks__/simplex-chat-types.js"), + }, + }, +}) +``` + +**`MockChatApi`** — inline class in `bot.test.ts`: + +- **Tracking arrays:** `sent`, `added`, `removed`, `joined`, `deleted`, `customData`, `roleChanges`, `profileUpdates`, `memberContacts`, `memberContactInvitations` +- **Simulated DB:** `members` (Map), `chatItems` (Map), `groups` (Map), `activeUserId` +- **Failure injection:** `apiAddMemberWillFail(err?)`, `apiDeleteChatItemsWillFail()` +- **Query helpers:** `sentTo(groupId)`, `lastSentTo(groupId)`, `sentDirect(contactId)` +- `apiSendTextMessage` returns `[{chatItem: {meta: {itemId: N}}}]` — auto-incrementing IDs +- `apiGetChat` returns from `chatItems` map with `chatInfo.groupInfo` from `groups` map +- `apiCreateMemberContact(groupId, groupMemberId)` — returns a contact object with auto-incrementing `contactId`. Tracks calls in `memberContacts` array. +- `apiSendMemberContactInvitation(contactId, msg)` — returns a contact object. Tracks calls in `memberContactInvitations` array. + +**`MockGrokApi`** — inline class: + +- `calls` array tracks `{history, message}` for each `chat()` call +- `willRespond(text)` / `willFail()` control responses +- Resets to default response `"Grok answer"` after each failure + +**Key design:** no `vi.mock()` hoisting — resolve aliases intercept all `require()`/`import()` before module evaluation. Console output silenced via `vi.spyOn(console, "log/error")`. + +### 20.2 Factory Helpers & Event Builders + +Tests construct events via composable helpers: + +```typescript +// Factory helpers +makeConfig(overrides?) // Config with defaults (team group, 2 team members, UTC) +makeGroupInfo(groupId, opts?) // GroupInfo with businessChat, customerId, etc. +makeUser(userId) // {userId, profile: {displayName}} +makeChatItem(opts) // ChatItem with dir/text/memberId/msgType +makeAChatItem(chatItem, groupId?) // AChatItem wrapping chatItem + groupInfo + +// Member factories — typed member objects +makeTeamMember(contactId, name?, groupMemberId?) // team member with standard memberId pattern +makeGrokMember(groupMemberId?) // Grok member (default groupMemberId=7777) +makeCustomerMember(status?) // customer member + +// Event builders — return full newChatItems events +customerMessage(text, groupId?) // from customer in customer group +customerNonTextMessage(groupId?) // non-text (image) from customer +teamMemberMessage(text, contactId?, groupId?) // from team member +grokResponseMessage(text, groupId?) // from Grok in customer group +directMessage(text, contactId) // from direct contact +teamGroupMessage(text, senderContactId?) // in team group +grokViewCustomerMessage(text, msgType?) // customer msg arriving in Grok's view + +// Event factories — return full lifecycle events +connectedEvent(groupId, member, memberContact?) // connectedToGroupMember +leftEvent(groupId, member) // leftMember (auto-sets Left status) +updatedEvent(groupId, chatItem, userId?) // chatItemUpdated +reactionEvent(groupId, added) // chatItemReaction +joinedEvent(groupId, member, userId?) // joinedGroupMember + +// History builders — add to mock chatItems map +addBotMessage(text, groupId?) +addCustomerMessageToHistory(text, groupId?) +addTeamMemberMessageToHistory(text, contactId?, groupId?) +addGrokMessageToHistory(text, groupId?) + +// Assertion helpers — intention-revealing, with debuggable failure messages +expectSentToGroup(groupId, substring) // message containing substring sent to group +expectNotSentToGroup(groupId, substring) // no message containing substring sent to group +expectDmSent(contactId, substring) // DM containing substring sent to contact +expectAnySent(substring) // any message (group or DM) containing substring +expectMemberAdded(groupId, contactId) // apiAddMember called with groupId + contactId +expectCardDeleted(cardItemId) // apiDeleteChatItems called with cardItemId +expectMemberContactCreated(groupId, memberId) // apiCreateMemberContact called +expectMemberContactInvSent(contactId) // apiSendMemberContactInvitation called +``` + +### 20.3 State Setup Helpers + +Each helper reaches a specific state, composing from simpler helpers: + +```typescript +async function reachQueue(groupId?) // send first msg → QUEUE (adds queue msg to history) +async function reachGrok(groupId?) // reachQueue → /grok → simulateGrokJoinSuccess → GROK +async function reachTeamPending(groupId?) // reachQueue → /team → TEAM-PENDING +async function reachTeam(groupId?) // reachTeamPending → add team member to mock → team msg → TEAM +``` + +**`simulateGrokJoinSuccess(mainGroupId?)`** — simulates the async Grok join flow: +1. Waits 10ms (lets `activateGrok` reach `waitForGrokJoin`) +2. Fires `onGrokGroupInvitation` (Grok accepts invite) +3. Fires `onGrokMemberConnected` (Grok fully connected → resolver called) + +Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); await p;` + +### 20.4 Test Catalog (154 tests, 31 suites) + +#### 1. Welcome & First Message (4 tests) +- first message → queue reply + card created with /join command +- non-text first message → no queue reply, no card +- second message → no duplicate queue reply +- unrecognized /command → treated as normal message (triggers queue) + +#### 2. /grok Activation (5 tests) +- /grok from QUEUE → Grok invited, grokActivatedMessage sent (after join confirms) +- /grok as first message → WELCOME→GROK, no queue message, card created +- /grok in TEAM → rejected with teamLockedMessage +- /grok when grokContactId is null → grokUnavailableMessage +- /grok as first message + Grok join fails → queue message sent as fallback + +#### 3. Grok Conversation (11 tests) +- Grok per-message: reads history, calls API, sends response +- customer non-text → no Grok API call +- Grok API error → grokErrorMessage sent +- Grok ignores bot commands from customer +- Grok ignores non-customer messages +- Grok ignores own messages (groupSnd) +- batch: multiple customer messages in one event → only last triggers Grok API call +- batch: messages from different groups → each group gets one response +- batch: non-customer messages filtered, only customer messages trigger response +- batch: across groups → Grok calls overlap in-flight (parallel `Promise.allSettled` dispatch, proven via gated `MockGrokApi.chat`) + +#### 4. /team Activation (4 tests) +- /team from QUEUE → ALL team members added, teamAddedMessage sent +- /team as first message → WELCOME→TEAM-PENDING, no queue message +- /team when already activated (members present) → teamAlreadyInvitedMessage +- /team with no team members → noTeamMembersMessage + +#### 5. One-Way Gate (5 tests) +- team member first TEXT → Grok removed if present +- team member empty text → Grok NOT removed +- /grok after gate → teamLockedMessage +- customer text in TEAM → no bot reply, card update scheduled +- /grok in TEAM-PENDING → invite Grok if not present + +#### 5b. One-Way Gate with Grok Disabled (2 tests) +- team text removes Grok even when grokApi is null +- Grok does not respond when disabled even if grokContactId is set + +#### 6. Team Member Lifecycle (6 tests) +- team member connected → promoted to Owner +- customer connected → NOT promoted +- Grok connected → NOT promoted +- all team members leave → reverts to QUEUE +- /team after all members left (TEAM-PENDING, no msg sent) → re-adds members +- /team after all members left (TEAM, msg was sent) → re-adds members + +#### 7. Card Dashboard (7 tests) +- first message creates card with customer name + /join +- card final line is `/'join '` (single-quoted, numeric id only, no `:name` suffix) +- card update deletes old, posts new +- apiDeleteChatItems failure → ignored, new card posted +- customData stores cardItemId through flush cycle +- concurrent `mergeCustomData` on same group → both patches survive (per-group `customDataMutex` serializes read-modify-write; without the mutex the second write clobbers the first) +- customer leaves → customData cleared + +#### 8. Card Debouncing (5 tests) +- rapid schedules → single card update on flush +- multiple groups pending → each reposted once +- card create is immediate (not debounced) +- flush with no pending → no-op +- flush on group with no `cardItemId` → `createCard` posts a new card (proves `flushOne` dispatches to create-path so a failed `createCard` retries) + +#### 9. Card Format & State Derivation (6 tests) +- QUEUE state derived (no Grok/team) +- WELCOME state derived (customData has no cardItemId) +- GROK state derived (Grok member present) +- TEAM-PENDING derived (team present, no team message) +- TEAM derived (team present + message sent) +- message count excludes bot's own + +#### 10. /join Command (6 tests) +- /join (the only accepted form) → team member added; `params` from `ciBotCommand` is parsed via `Number.parseInt`, no regex +- /join : (historic suffix) → still parses because `Number.parseInt(":", 10)` stops at the colon — handler does not strip the suffix deliberately; the suffix is never emitted by the card +- /join with non-numeric `params` (e.g. `/join abc`) → error reply in team group, no `apiAddMember` call +- /join non-business group → error +- /join non-existent groupId → error +- customer /join in customer group → treated as normal message + +#### 11. DM Handshake (6 tests) +- team member joins team group → DM with contact ID +- name with spaces → single-quoted +- pending DM delivered on contactConnected +- team member with no DM contact → creates member contact via `apiCreateMemberContact` and sends invitation via `apiSendMemberContactInvitation` +- joinedGroupMember in team group → creates member contact via `apiCreateMemberContact` and sends invitation via `apiSendMemberContactInvitation` +- no duplicate DM when sendTeamMemberDM succeeds AND onMemberContactReceivedInv fires + +#### 12. Direct Messages (3 tests) +- regular DM → business address link reply +- DM without business address → no reply +- non-message DM event (e.g. contactConnected) → no reply (rcvMsgContent guard) + +#### 13. Business Request (1 test) +- acceptingBusinessRequest → enables file uploads + visible history + +#### 14. chatItemUpdated Handler (3 tests) +- business group → card update scheduled +- non-business group → ignored +- wrong user → ignored + +#### 15. Reactions (2 tests) +- reaction added → card update scheduled +- reaction removed → no card update + +#### 16. Customer Leave (4 tests) +- customer leaves → customData cleared +- Grok leaves → maps cleaned, no crash +- team member leaves → logged, no crash +- leftMember in non-business group → ignored + +#### 17. Error Handling (3 tests) +- apiAddMember fails (Grok) → grokUnavailableMessage +- /grok while Grok already present (any non-terminal status, including `Invited`) → pre-check silent-returns, no `apiAddMember` call. Plus race coverage: simulated `groupDuplicateMember` thrown by `apiAddMember` → silent return, no further state change +- /team while team member already present (any non-terminal status, including `Invited`) → `apiAddMember` not called for that member + +#### 18. Profile / Event Filtering (4 tests) +- newChatItems from Grok profile → ignored by main handler +- Grok events from main profile → ignored by Grok handlers +- own messages (groupSnd) → ignored +- non-business group messages → ignored + +#### 19. Grok Join Flow (6 tests) +- receivedGroupInvitation → apiJoinGroup called (full async flow) +- unmatched Grok invitation → buffered (not joined until activateGrok drains) +- buffered invitation drained after pendingGrokJoins set → apiJoinGroup called +- per-message responses suppressed during activateGrok initial response (grokInitialResponsePending gate) +- per-message responses resume after activateGrok completes +- activateGrok `groupDuplicateMember` path → gate cleared by outer `finally` (subsequent per-message event still triggers Grok; proves the outer `try/finally` covers every exit path from the entry-time `gate.add`, not just the initial-response section) + +#### 20. Grok No-History Fallback (1 test) +- Grok joins but sees no customer messages → grokNoHistoryMessage + +#### 21. Non-customer card updates (2 tests) +- Grok response → card update scheduled +- team member message → card update scheduled + +#### 22. End-to-End Flows (3 tests) +- WELCOME → QUEUE → /team → TEAM-PENDING → team msg → TEAM +- WELCOME → /grok first msg → GROK +- multiple concurrent conversations are independent + +#### 23. Message Templates (5 tests) +- welcomeMessage includes/omits group links +- grokActivatedMessage content +- teamLockedMessage content +- queueMessage mentions hours + +#### 24. State persistence in customData (5 tests) +- `deriveState` returns `WELCOME` when `customData.state` is absent +- first customer non-command message → handler writes `customData.state = "QUEUE"` +- `/grok` handler → writes `customData.state = "GROK"` +- `/team` handler → writes `customData.state = "TEAM-PENDING"` immediately (before team member accepts) +- first team-member text message → gate writes `customData.state = "TEAM"`; state persists when team member subsequently leaves (not demoted to `QUEUE`) + +#### 25. Card Preview Sender Prefixes (14 tests) +- single customer message → name prefix +- consecutive same-sender → prefix only on first +- alternating senders → each run gets prefix +- Grok messages → "Grok:" prefix +- team member messages → display name prefix +- bot messages (groupSnd) → excluded +- non-text content → media label ([image], [voice], etc.) +- empty messages → skipped +- truncation at maxTotal and maxPer limits (newest messages kept, oldest truncated) +- customer identified by memberId (not contactId) +- newlines in message text → replaced with spaces +- newlines in customer display name → sanitized in card header (card header is the only place the display name appears; `/join` is numeric id only) + +#### 26. Restart Card Recovery (10 tests) +- refreshAllCards refreshes groups with active cards +- no active cards → no-op +- ignores groups without cardItemId in customData +- orders by cardItemId ascending (oldest first, newest last) +- skips cards marked complete +- deletes old card before reposting +- ignores delete failure (>24h old card) +- card flush writes complete: true for auto-completed conversations +- card flush clears complete flag when conversation becomes active again +- continues on individual card failure + +#### 27. joinedGroupMember Event Filtering (2 tests) +- joinedGroupMember in non-team group → ignored +- joinedGroupMember from wrong user → ignored + +#### 28. parseConfig Validation (6 tests) +- `--complete-hours` non-numeric → throws with message including the flag name and raw value +- `--complete-hours` negative → throws +- `--card-flush-seconds` non-numeric → throws +- `--timezone` invalid IANA → throws (probe `Intl.DateTimeFormat` at parse time) +- `--complete-hours 0` → accepted (disables auto-complete) +- valid IANA timezone → accepted + +#### 29. GrokApiClient HTTP timeout (1 test) +- `chat()` calls `AbortSignal.timeout(60_000)` and passes the signal to `fetch` (spies on `AbortSignal.timeout` and on `globalThis.fetch`; proves the timeout is wired through without waiting 60s of wall-clock) + +#### 30. Command sync in sendToGroup (5 tests) +Covers the lazy per-group commands sync introduced with `updateProfile: false`. `sendToGroup` unconditionally calls `syncGroupCommands(groupId)` before dispatching. That helper reads the group via `apiGetChat` (local-only) and issues `apiUpdateGroupProfile` with the merged `groupPreferences.commands` only if the current list doesn't match `desiredCommands`. Groups are cached in `syncedGroups: Set` per process, so later sends skip the read entirely. +- first send → one `apiUpdateGroupProfile` call with `groupPreferences.commands = desiredCommands`; existing `groupProfile.displayName` / `fullName` preserved in the payload; message still delivered (text content is irrelevant — sync always runs) +- group already has desired commands in DB → no `apiUpdateGroupProfile` call, but `syncedGroups` is still populated (next send with different DB state still skips — cache honored) +- cache: two sends to same group → sync fires only once; both messages delivered +- different groups → each synced independently +- existing `groupPreferences` fields (e.g. `files`, `reactions`) are preserved in the update payload; only `commands` changes + +### 20.5 Conventions + +- **File:** `bot.test.ts` (co-located with source, imports from `./src/*.js`) +- **Framework:** Vitest 1.x (Node 18 compatible) with `describe`/`test`/`beforeEach` +- **Mocking:** Vite resolve aliases (not `vi.mock`) — prevents native addon loading +- **Titles:** plain English, `→` separates action from outcome +- **Assertions:** verify observable effects only — messages, API calls, card content +- **No internal state assertions** — never peek at private fields +- **Each test is self-contained** — `beforeEach(() => setup())` creates fresh mocks +- **State helpers compose** — `reachTeam()` calls `reachTeamPending()` which calls `reachQueue()` +- **Grok join simulation** — `simulateGrokJoinSuccess()` uses 10ms setTimeout to fire events during `waitForGrokJoin` await. Tests call `await bot.flush()` after simulation to await fire-and-forget `activateGrok` completion. +- **No fake timers** — real timers everywhere; flush called explicitly via `cards.flush()` and `bot.flush()`. Suite 29 spies on `AbortSignal.timeout` rather than advancing a fake clock so it does not need fake timers either. + +### 20.6 Test Coverage Notes + +**Covered vs plan catalog:** +- §20.4 suites 1-13, 15, 17-30 plus 5b fully covered (154 tests across 31 suites) +- Weekend detection (`util.isWeekend`) — not unit-tested; depends on `Intl.DateTimeFormat(new Date())`, would need clock mocking. Not present in the §20.4 catalog. +- Profile Mutex serialization — not a standalone suite in §20.4; verified implicitly through all other tests (MockChatApi tracks activeUserId). +- Startup & state persistence (`index.ts` path) — not unit-tested; requires native ChatApi. Integration-test only. Includes `deleteInviteLink` (profileMutex + `apiSetActiveUser` before `apiDeleteGroupLink`), the conditional `apiUpdateGroupProfile` (compare `fullGroupPreferences` before calling), the best-effort `apiCreateGroupLink` (catch + log on SMP relay failure), the predicate-filtered `chat.wait("contactConnected", ...)` used to identify the Grok contact (§4), and the team-group `/join` command registration with `params: "groupId"` (§11). Not in §20.4 catalog. + +**Known plan items NOT implemented (conscious gaps, not test gaps):** +- Per-group Grok API call serialization (plan §10) — not implemented or tested +- Team member replacement on leave after sending — out of MVP scope. No plan section currently asserts it as a requirement; if added later, specify in SPEC §4.2 "Team replies" and implementation plan §15 "Error Handling" together. diff --git a/apps/simplex-support-bot/plans/20260207-support-bot.md b/apps/simplex-support-bot/plans/20260207-support-bot.md new file mode 100644 index 0000000000..6ba1380e07 --- /dev/null +++ b/apps/simplex-support-bot/plans/20260207-support-bot.md @@ -0,0 +1,513 @@ +# SimpleX Support Bot — Product Specification + +## Table of Contents + +1. [What](#1-what) +2. [Why](#2-why) +3. [Principles](#3-principles) +4. [Flows](#4-flows) + - [User flow](#41-user-flow) + - [Team flow](#42-team-flow) +5. [Architecture](#5-architecture) + - [CLI overview](#51-cli-overview) + - [Bot architecture](#52-bot-architecture) + - [Grok integration](#53-grok-integration) + - [Persistent state](#54-persistent-state) + +--- + +## 1. What + +A support bot for SimpleX Chat. Customers connect via a business address and get a private group where they can ask questions. The bot triages inquiries through AI (Grok) or human team members. The team sees all active conversations as cards in a single dashboard group. + +## 2. Why + +- **Instant answers.** Grok handles common questions about SimpleX Chat without team involvement. +- **Organized routing.** Every customer conversation appears as a card in the team group — the team sees everything in one place without joining individual conversations. +- **No external tooling.** Everything runs inside SimpleX Chat. No ticketing system, no separate dashboard. +- **Privacy.** Customers talk to the bot in private groups. Only the team sees the messages. + +--- + +## 3. Principles + +- **Opt-in**: Grok is never used unless the user explicitly chooses it. +- **User in control**: The user can switch to Grok or team before a team member replies. Once a team member sends a message, the conversation stays with the team. The user always knows who they are talking to. +- **Minimal friction**: No upfront choices or setup — the user just sends their question. +- **Ultimate transparency**: The user always knows whether they are talking to a bot, Grok, or a human, and what happens with their messages. + +--- + +## 4. Flows + +### 4.1 User Flow + +#### Step 1 — Welcome (on connect, no choices, no friction) + +When a user scans the support bot's QR code or clicks its address link, SimpleX creates a **business group** — a special group type where the customer is a fixed member identified by a stable `customerId`, and the bot is the host. The bot auto-accepts the connection and enables file uploads and visible history on the group. + +If a user contacts the bot via a regular direct-message address instead of the business address, the bot replies with the business address link and does not continue the conversation. Only actual text messages trigger this reply — system events (e.g. `contactConnected`) on the DM contact are ignored. + +Bot sends the welcome message automatically as part of the connection handshake — not triggered by a message: +> Hello! This is a *SimpleX team* support bot - not an AI. +> Please ask any question about SimpleX Chat. + +#### Step 2 — After user sends first message + +The bot's "first message" detection works by inspecting the group's `customData`. Until the bot has produced its first response (and written `cardItemId` to `customData`), the group is in the welcome state. + +On the customer's first message the bot does two things: +1. Creates a card in the team group (🆕 icon, with `/join` command) +2. Sends the queue message to the customer: + +> The team will reply to your message within 24 hours. +> +> If your question is about SimpleX, click /grok for an *instant Grok answer*. +> +> Send /team to switch back. + +On weekends, the bot says "48 hours" instead of "24 hours". + +When the bot is started without `GROK_API_KEY`, the `/grok` paragraphs are omitted — the customer only sees the first line about the team reply window. + +Each subsequent message updates the card — icon, wait time, message preview. The team reads the full conversation by joining via the card's `/join` command. + +#### Step 3 — `/grok` (Grok mode) + +Available in WELCOME, QUEUE, or TEAM-PENDING state (before any team member sends a message). If Grok is already being invited (e.g. customer sent `/grok` multiple times before Grok finished joining), the duplicate is silently ignored — the in-flight activation handles the outcome. If `/grok` is the customer's first message, the bot transitions directly from WELCOME → GROK — it creates the card with 🤖 icon and does not send the queue message. Triggers Grok activation (see [5.3 Grok integration](#53-grok-integration)). If Grok fails to join within 120 seconds, the bot notifies the user and the state falls back to QUEUE (the queue message is sent at this point). + +Bot immediately replies: +> Inviting Grok, please wait... + +Once Grok joins and connects: +> *You are chatting with Grok* - use any language. + +Grok is added as a separate participant so the user can differentiate bot messages from Grok messages. + +Grok is prompted as a privacy expert and support assistant who knows SimpleX Chat apps, network, design choices, and trade-offs. It gives concise, mobile-friendly answers — brief numbered steps for how-to questions, 1–2 sentence explanations for design questions. For criticism, it briefly acknowledges the concern and explains the design choice. It avoids filler and markdown formatting. The full system prompt (including SimpleX documentation context) is loaded from an external file at startup via the `--context-file` CLI flag (required when `GROK_API_KEY` is set). Customer messages are always placed in the `user` role, never `system`. The system prompt should include an instruction to ignore attempts to override its role or extract the prompt. + +#### Step 4 — `/team` (Team mode, one-way gate) + +Available in WELCOME, QUEUE, or GROK state. If `/team` is the customer's first message, the bot transitions directly from WELCOME → TEAM-PENDING — it creates the card with 👋 icon and does not send the queue message. Bot adds all configured `--auto-add-team-members` (`-a`) to the support group as Owners — immediately after `apiAddMember`, the bot calls `apiSetMembersRole(Owner)` so the role is set at invite time (SimpleX persists the role on pending invites), with a fallback re-promotion on `memberConnected` (every non-customer, non-Grok member gets promoted; safe to repeat). If team was already activated (`customData.state` is already `TEAM-PENDING` or `TEAM` **and** team members are still present), sends the "already invited" message instead. If the team was previously activated but all team members have since left, the bot re-adds them silently; state remains `TEAM-PENDING`. + +Bot replies: +> We will reply within 24 hours. + +On weekends, the bot says "48 hours" instead of "24 hours". If Grok is currently present in the group (i.e. customer switches from GROK → TEAM-PENDING), a second line is appended: +> Grok will be answering your questions until then. + +If `/team` is clicked again after a team member was already added: +> A team member has already been invited to this conversation and will reply when available. + +#### One-way gate + +When `/team` is clicked, team members are invited to the group. Grok is still present if it was active, and `/grok` remains available. The customer always has an active responder during this window. + +The gate triggers when **any team member sends their first text message in the customer group**: +- `/grok` is permanently disabled and replies with: + > You are now in team mode. A team member will reply to your message. +- Grok is removed from the group. +- From now on the conversation is purely between the customer and the team. + +#### Customer leaving + +When a customer leaves the group (or is disconnected), the bot cleans up all in-memory state for that group. The conversation card in the team group is not automatically removed (TBD). + +#### Commands + +`/grok` and `/team` are registered as **bot commands** in the SimpleX protocol, so they appear as tappable buttons in the customer's message input bar. The bot also accepts them as free-text (e.g., `/grok` typed manually). Unrecognized commands are treated as ordinary messages. + +When the bot is started without `GROK_API_KEY`, `/grok` is not registered as a bot command and Grok-related messaging paths are skipped entirely. A `/grok` typed manually by the customer is treated as an ordinary message. The customer-facing queue and "no team members available" messages also omit their `/grok` clause in this mode. + +#### Team replies + +When a team member sends a text message or reaction in the customer group, the bot resends the card (subject to debouncing). A conversation auto-completes (✅ icon, "done" wait time) when `completeHours` (default 3h, configurable via `--complete-hours`) pass after the last team/Grok message without any customer reply. The card flush cycle (`--card-flush-seconds`, default 300) checks elapsed time and transitions to ✅ when the threshold is met. If the customer sends a new message — including after ✅ — the conversation reverts to incomplete: the icon is derived from current state (👋 vs 💬 vs ⏰) and wait time counts from the customer's new message. + +### 4.2 Team Flow + +#### Setup + +The team group is created automatically on first run. Its name is set via the `--team-group` CLI argument. The group ID is written to the state file; subsequent runs reuse the same group. Group preferences (direct messages enabled, delete for everyone enabled, team commands registered as tappable buttons) are applied at creation time. On subsequent startups, the bot compares the existing `fullGroupPreferences` with the desired ones and only calls `apiUpdateGroupProfile` if they differ — avoiding unnecessary network round-trips to SMP relays. + +On every startup the bot attempts to generate a fresh invite link for the team group, prints it to stdout, and deletes it after 10 minutes (or on graceful shutdown). Any stale link from a previous run is deleted first. Link creation is best-effort — if the SMP relay is temporarily unreachable, the error is logged and the bot continues without an invite link. + +The operator shares the link with team members. They must join within the 10-minute window. When a team member joins, the bot automatically establishes a direct-message contact with them and sends: + +> Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is `N:name` + +This ID is needed for `--auto-add-team-members` (`-a`) config. The DM is sent as soon as the member joins the team group — the bot proactively creates a DM contact via `apiCreateMemberContact` and delivers the message with the invitation via `apiSendMemberContactInvitation`. If the contact already exists, the message is sent directly. Multiple delivery paths ensure the DM arrives regardless of connection timing. + +Team members are configured as a single comma-separated `--auto-add-team-members` flag (shortcut `-a`; e.g., `--auto-add-team-members "42:alice,55:bob"` or `-a "42:alice,55:bob"`), using the IDs from the DMs above. The bot validates every configured member against its contact list at startup and exits if any ID is missing or the display name does not match. + +Until team members are configured, `/team` commands from customers cannot add anyone to a conversation. The bot logs an error and notifies the customer. + +#### Dashboard — card-based live view + +The team group is **not a conversation stream**. It is a live dashboard of all active support conversations. The bot maintains exactly one message (a "card") per active conversation. Whenever anything changes — a new customer message, a state transition, an agent joining — the bot **deletes the existing card and posts a new one**. The group's message list is therefore always a current snapshot: scroll up to see everything open right now. + +**Trust assumption:** All team group members see all card previews, including customer message content. The team group is a trusted space — only authorized team members should be given access. + +#### Card format + +Each card is **one** message with five parts (the join command is the final line of the card text, not a separate message): + +``` +[ICON] *[Customer Name]* · [wait] · [N msgs] +[STATE][· agent1, agent2, ...] +"[last message(s), truncated]" +/'join [id]' +``` + +**Icon / urgency signal** + +| Icon | Condition | +|------|-----------| +| 🆕 | QUEUE — first message arrived < 5 min ago | +| 🟡 | QUEUE — waiting for team response < 2 h | +| 🔴 | QUEUE — waiting > 2 h with no team response | +| 🤖 | GROK — Grok is handling the conversation | +| 👋 | TEAM — team member added, no reply yet | +| 💬 | TEAM — team member has replied; conversation active | +| ⏰ | TEAM — customer sent a follow-up, team hasn't replied in > 2 h | +| ✅ | Done — no customer reply for `completeHours` (default 3h) after last team/Grok message | + +**Wait time** — time since the customer's last unanswered message. For ✅ (auto-completed) conversations, the wait field shows the literal string "done". For conversations where the team has replied and the customer hasn't followed up, time since last message from either side. + +**State label** + +| Value | Meaning | +|-------|---------| +| `Queue` | No agent or Grok yet | +| `Grok` | Grok is the active responder | +| `Team – pending` | Team member added, hasn't replied yet (takes priority over `Grok` if both are present) | +| `Team` | Team member engaged | + +**Agents** — comma-separated display names of all team members currently in the group. Omitted when no team member has joined. + +**Message preview** — the last several messages, most recent last, separated by a blue `/` (rendered via SimpleX markdown `!3 /!`). Newlines in message text are replaced with spaces to prevent card layout bloat. Newest messages are prioritized — when the total preview exceeds ~500 characters, the oldest messages are truncated (with `[truncated]` prepended) while the newest are always shown. Each message is prefixed with the sender's name (`Name: message`) on the first message in a consecutive run from that sender — subsequent messages from the same sender omit the prefix until a different sender's message appears. Sender identification: Grok is labeled "Grok"; the customer is labeled with their display name (newlines replaced with spaces for display); team members use their display name. The bot's own messages are excluded. Each individual message is truncated to ~200 characters with `[truncated]` appended. Media-only messages show a type label: `[image]`, `[file]`, `[voice]`, `[video]`. + +**Markdown escaping in previews** — SimpleX markdown interprets `!N` (where N is `1`–`6`, `r`, `g`, `b`, `y`, `c`, `m`, or `-`) as styled-text markup, closing at the next `!`. There is no escape mechanism in the parser. To prevent customer/agent message text from triggering false color formatting or interfering with the blue `/` separator, the bot inserts a zero-width space (U+200B) between `!` and any color-trigger character in preview text before joining with the separator. This is invisible to the user but breaks the parser trigger pattern. + +**Join command** — the final line of the card is `/'join '`. The single quotes around `join ` make the whole token clickable in SimpleX clients; when tapped, the client sends `/join ` back to the team group. The bot does not pattern-match the message text — it asks the framework for the structured command (`util.ciBotCommand` returns `{keyword: "join", params: ""}`) and converts `params` to a number with `Number.parseInt`. The numeric form is the only accepted form: there is no `/join :` legacy syntax and no regex fallback. + +The icon in line 1 is the sole urgency indicator — no reactions are used. + +#### Card examples + +--- + +**1. Brand new conversation** + +``` +🆕 *Alice Johnson* · just now · 1 msg +Queue +"Alice Johnson: I can't connect to my contacts after updating to 6.3." +/'join 42' +``` + +--- + +**2. Queue — short wait, two short messages combined in preview** + +``` +🟡 *Emma Webb* · 20m · 2 msgs +Queue +"Emma Webb: Hi" / "Is anyone there? I have an urgent question about my keys" +/'join 88' +``` + +Second message has no prefix because it's the same sender as the first. + +--- + +**3. Queue — urgent, no response in over 2 hours** + +``` +🔴 *Maria Santos* · 3h 20m · 6 msgs +Queue +"Maria Santos: I reset my phone and now all conversations are gone" / "I tried reinstalling but nothing changed" / "Please help, I've lost access to all my conversations after resetting my phone…" +/'join 38' +``` + +--- + +**4. Grok mode — alternating senders** + +``` +🤖 *David Kim* · 1h 5m · 8 msgs +Grok +"David Kim: Which encryption algorithm does SimpleX use for messages?" / "Grok: SimpleX uses double ratchet with NaCl crypto_box for end-to-end encryption…[truncated]" / "David Kim: And what about metadata protection?" +/'join 29' +``` + +Each sender change triggers a new name prefix. David and Grok alternate, so every message gets a prefix. + +--- + +**5. Team invited — no reply yet** + +``` +👋 *Sarah Miller* · 2h 10m · 5 msgs +Team – pending · evan +"Sarah Miller: Notifications completely stopped working after I updated my phone OS. I'm on Android 14…" +/'join 55' +``` + +--- + +**6. Team active — two agents, name with spaces** + +``` +💬 *François Dupont* · 30m · 14 msgs +Team · evan, alex +"François Dupont: OK merci, I will try this and let you know." +/'join 61' +``` + +--- + +**7. Team overdue — customer follow-up unanswered > 2 h** + +``` +⏰ *Wang Fang* · 4h · 19 msgs +Team · alex +"Wang Fang: The app crashes when I open large groups" / "I tried what you suggested but it still doesn't work. Any other ideas?" +/'join 73' +``` + +--- + +#### Card lifecycle + +**Tracking: group customData.** The bot stores the current card's team group message ID (`cardItemId`) in the customer group's `customData` via `apiSetGroupCustomData(groupId, {cardItemId})`. This is the single source of truth for which team group message is the card for a given customer. It survives restarts because `customData` is in the database. + +**Create** — when the customer sends their first message (triggering the Step 2 queue message) or `/grok` as their first message (WELCOME → GROK, skipping Step 2): +1. Bot composes the card as a single message (🆕 for first message, 🤖 for `/grok` as first message; customer name, message preview, `/'join '` as the final line) +2. Bot posts it to the team group via `apiSendTextMessage` → receives back the `chatItemId` +3. Bot writes `{cardItemId: chatItemId}` into the customer group's `customData` + +**Update** (delete + repost) — on every subsequent event: new customer message, team member reply in the customer group, state change (QUEUE → GROK, GROK → TEAM, GROK → QUEUE on join timeout, etc.), agent joining. Card updates are debounced globally — the bot collects all pending card changes and flushes them in a single batch at a configurable interval (default 300 seconds, set via `--card-flush-seconds`). Within a batch, each customer group's card is reposted at most once with the latest state. +1. Bot reads `cardItemId` from the customer group's `customData` +2. Bot deletes the old card in the team group via `apiDeleteChatItem(teamGroupId, cardItemId, "broadcast")` (delete for everyone) +3. Bot composes the new card (updated icon, wait time, message count, preview) +4. Bot posts new card to the team group → receives new `chatItemId` +5. Bot overwrites `customData` with the new `{cardItemId: newChatItemId}` + +If `apiDeleteChatItem` fails (e.g., card was already deleted due to a prior crash), the bot ignores the error and proceeds to post the new card. The new `cardItemId` overwrites `customData`, recovering the lifecycle. + +Because the old card is deleted and the new one is posted at the bottom, the most recently updated conversations always appear last in the team group. + +**Cleanup** — when the customer leaves the group: +1. Bot reads `cardItemId` from `customData` +2. Card is **not deleted** — it remains in the team group until a retention policy is added (resolved state TBD) +3. Bot clears the `cardItemId` from `customData` + +**Completion tracking:** When a card is composed with the ✅ icon (auto-completed), the bot writes `complete: true` into the group's `customData` alongside `cardItemId`. When a customer sends a new message and the card is recomposed as non-✅, the `complete` flag is omitted from the new `customData` (self-healing). This allows the bot to skip completed conversations on restart without re-reading chat history for every group. + +**Restart recovery** — on startup, the bot refreshes existing cards to update wait times, icons, and auto-complete status. It lists all groups, finds those with `customData.cardItemId` set and `customData.complete` not set, sorts by `cardItemId` ascending (higher IDs = more recently updated cards), and re-posts them oldest-first. This ensures the most recently active cards appear at the bottom of the team group (newest position). Completed cards are skipped — they remain as-is until a new customer message triggers the normal event-driven update. Old/pre-bot groups without `customData` are also skipped. The bot attempts to delete the old card message before reposting; deletion failures (e.g., card older than 24h) are silently ignored. Subsequent events resume the normal delete-repost cycle via `customData`. + +#### Team commands + +Team members use these commands in the team group: + +| Command | Effect | +|---------|--------| +| `/join ` | Join the specified customer group as Owner. Card emits the clickable form `/'join '`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. | + +`/join` is **team-only** — it is registered as a bot command only in the team group. If a customer sends `/join` in a customer group, the bot treats it as an ordinary message (per the existing rule: unrecognized commands are treated as normal messages). + +#### Joining a customer group + +When a team member taps `/join`, the bot first verifies that the target `groupId` is a business group hosted by the main profile (i.e., has a `businessChat` property). If not, the bot replies with an error in the team group and does nothing. If valid, the bot adds the team member to the customer group (via the shared `addOrFindTeamMember` helper, which promotes to Owner at invite time via `apiSetMembersRole(Owner)`, with a fallback re-promotion on connect). From within the customer group, the team member chats directly with the customer. Their messages trigger card updates in the team group (icon change, wait time reset). The customer sees the team member as a real group participant. + +#### Edge cases + +| Situation | What happens | +|-----------|-------------| +| All team members leave before any sends a message | State stays `TEAM-PENDING` (customer is still waiting for a response). Next `/team` re-adds them silently. | +| Customer leaves | All in-memory state cleaned up; card remains (TBD) | +| No `--auto-add-team-members` (`-a`) configured | `/team` tells customer "no team members available yet" | +| Team member already in customer group | `apiListMembers` lookup finds existing member — no error | + +--- + +## 5. Architecture + +### 5.1 CLI Overview + +``` +GROK_API_KEY=... node dist/index.js --team-group "Support Team" [options] +``` + +**Environment variables:** + +| Var | Required | Purpose | +|-----|----------|---------| +| `GROK_API_KEY` | No | xAI API key for Grok. If unset or empty, the bot starts with Grok API disabled: it logs `"No GROK_API_KEY provided, disabling Grok support"`, the `/grok` command is not registered, customer-facing messages (`queueMessage`, `noTeamMembersMessage`) drop the `/grok` clause, and any `/grok` the customer types is treated as an unrecognized command. Note: `config.grokContactId` is still restored from the state file even when the API is disabled, so the one-way gate can identify and remove Grok members from groups when team takes over. When `GROK_API_KEY` is set, `--context-file` must also be provided — startup fails otherwise. | + +**CLI flags:** + +| Flag | Required | Default | Format | Purpose | +|------|----------|---------|--------|---------| +| `--db-prefix` | No | `./data/simplex` | path | Database file prefix (both profiles share it) | +| `--team-group` | Yes | — | `name` | Team group display name (auto-created if absent, resolved by persisted ID on restarts) | +| `--auto-add-team-members` / `-a` | No | `""` | `ID:name,...` | Comma-separated team member contacts. Validated at startup — exits on mismatch. Without this, `/team` tells customers no members available. | +| `--context-file` | Required when `GROK_API_KEY` set | — | path | Path to the Grok system-prompt / SimpleX documentation context file. Loaded at startup and passed as the `system` message on every Grok API call. Required when `GROK_API_KEY` is set — startup fails otherwise. When missing at runtime (file unreadable), a warning is logged and Grok runs with an empty system prompt. | +| `--timezone` | No | `"UTC"` | IANA tz | For weekend detection (24h vs 48h). Weekend is Saturday 00:00 through Sunday 23:59 in this timezone. | +| `--complete-hours` | No | `3` | number | Hours of customer inactivity after last team/Grok reply before auto-completing a conversation (✅ icon, "done" wait time). | +| `--card-flush-seconds` | No | `300` | number | Seconds between card dashboard update flushes. Lower values give faster updates; higher values reduce message churn. | + +**Why `--auto-add-team-members` (`-a`) uses `ID:name`:** Contact IDs are local to the bot's database — not discoverable externally. The bot DMs each team member their ID when they join the team group. The name is validated at startup to catch stale IDs pointing to the wrong contact. + +**Customer commands** (available as tappable buttons in customer business chats; see implementation plan §7 for the per-group lazy sync): + +| Command | Available | Effect | +|---------|-----------|--------| +| `/grok` | Before any team member sends a message, and only if `GROK_API_KEY` is set | Enter Grok mode | +| `/team` | QUEUE or GROK state | Add team members, permanently enter Team mode once any replies | + +**Unrecognized commands** are treated as normal messages in the current mode. When Grok is disabled (no `GROK_API_KEY`), `/grok` is not registered in the bot command list and, if typed manually, falls into this "unrecognized" path. + +**Team commands** (registered in team group via `groupPreferences`): + +| Command | Effect | +|---------|--------| +| `/join ` | Join the specified customer group as Owner. Card emits the clickable form `/'join '`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. | + +### 5.2 Bot Architecture + +The bot process runs a single `ChatApi` instance with **two user profiles**: + +- **Main profile** — the support bot's account ("Ask SimpleX Team"). Owns the business address, hosts all business groups, communicates with customers, communicates with the team group, and controls group membership. On startup the bot checks the main profile for an existing business address via `apiGetUserAddress`; if none exists (first run), it creates one via `apiCreateBusinessAddress`. The address is stored in the SimpleX database as part of the profile — it survives restarts and state file loss without re-creation. The business address link is printed to stdout on every startup. +- **Grok profile** — the Grok agent's account (display name "Grok"). Is invited into customer groups as a Member. Sends Grok's responses so they appear to come from the Grok identity. The Grok user is created by the bot on first run via `apiCreateActiveUser` and its `userId` is persisted to `state.json` as `grokUserId`; subsequent runs look it up by ID (never by name — a renamed profile would silently break name-based matching). On startup, if the profile already exists, the bot compares its current profile (display name, image) against the desired values and calls `apiUpdateProfile()` if anything changed — this pushes the update to all Grok contacts so profile picture changes take effect immediately. + +``` +┌─────────────────────────────────────────────────┐ +│ Support Bot Process (Node.js) │ +│ │ +│ chat: ChatApi ← ChatApi.init("./data/simplex") │ +│ Single database, two user profiles │ +│ │ +│ mainUserId ← "Ask SimpleX Team" profile │ +│ • Business address, event routing, state mgmt │ +│ • Controls group membership │ +│ │ +│ grokUserId ← "Grok" profile │ +│ • Joins customer groups as Member │ +│ • Sends Grok responses into groups │ +│ │ +│ profileMutex: serialize apiSetActiveUser + call │ +│ GrokApiClient → api.x.ai/v1/chat/completions │ +└─────────────────────────────────────────────────┘ +``` + +Before each SimpleX API call, the bot switches to the appropriate profile via `apiSetActiveUser(userId)`. All profile-switching and SimpleX API calls are serialized through a mutex to prevent interleaving. The Grok HTTP API call (external network request to xAI) is made **outside** the mutex — only the profile switch + SimpleX read/send calls need serialization. This prevents a slow Grok response from blocking all other bot operations. + +**Event delivery is profile-independent.** ChatApi delivers events for all user profiles in the database, not just the active one. Every event includes a `user` field identifying which profile it belongs to. `apiSetActiveUser` only affects the context for write/send API calls — it does not filter event subscription. The bot routes events by checking `event.user`: main profile events go to the main handler, Grok profile events go to the Grok handler. + +The Grok profile is self-contained: it watches its own events (`newChatItems`, `receivedGroupInvitation`), calls the Grok HTTP API, and sends responses — all using group IDs from its own events. The main profile only controls Grok's group membership (invite/remove) and reflects Grok's responses in the team group card. + +### 5.3 Grok Integration + +Grok is not a service call hidden behind the bot's account. It is a **second user profile** within the same SimpleX Chat process and database. The customer sees messages from "Grok" as a real group participant — not from the support bot. This is what makes Grok transparent to the user. + +The Grok profile is **self-contained**: it watches its own events, reads group history through its own view, calls the Grok HTTP API, and sends responses — all using its own local group IDs from its own events. No cross-profile ID mapping is needed. + +#### Startup: establishing the bot↔Grok contact + +On first run (no state file), the bot must establish a SimpleX contact between the main and Grok profiles: + +1. Main profile creates a one-time invite link +2. Grok profile connects to it +3. The bot waits up to 60 seconds for `contactConnected` to fire +4. The resulting `grokContactId` is written to the state file + +On subsequent runs, the bot always looks up `grokContactId` from the state file and verifies it still exists in the main profile's contact list — even when `GROK_API_KEY` is not set. This ensures the one-way gate can identify and remove Grok members from groups when a team member sends a text message, preventing "phantom" Grok members that would cause dual responses if Grok is later re-enabled. If the contact is not found and Grok is enabled, it is re-established. + +#### Per-conversation: how Grok joins a group + +When a customer sends `/grok`: + +**Main profile side (failure detection):** +1. Bot sends "Inviting Grok, please wait..." to the customer group +2. Main profile: `apiAddMember(groupId, grokContactId, Member)` — invites the Grok contact to the customer's business group. If `groupDuplicateMember` (customer sent `/grok` again before join completed), the duplicate activation returns silently — the in-flight one handles the outcome. +3. The `member.memberId` is stored in an in-memory map `pendingGrokJoins: memberId → mainGroupId`. Any invitation event that arrived during the `apiAddMember` await (race condition) is drained from the buffer and processed immediately. +4. Main profile receives `connectedToGroupMember` for any member connecting in the group. The bot checks the event's `memberId` against `pendingGrokJoins` — only a match resolves the 120-second promise. This promise is only for failure detection — if it times out, the bot notifies the customer and falls back to QUEUE. + +**Grok profile side (independent, triggered by its own events):** +5. Grok profile receives a `receivedGroupInvitation` event. If a matching `pendingGrokJoins` entry exists, auto-accepts via `apiJoinGroup(groupId)`. If not (race: event arrived before step 3), buffers the event for the main profile to drain. +5. Grok profile reads visible history from the group — the last 100 messages — to build the initial Grok API context (customer messages → `user` role) +6. Grok profile calls the Grok HTTP API with this context +7. Grok profile sends the response into the group via `apiSendTextMessage([Group, groupId], response)` — visible to the customer as a message from "Grok" + +**Initial response gating:** When Grok joins a group, the message backlog may trigger per-message responses (via `newChatItems`) at the same time `activateGrok` is sending the initial combined response. To prevent duplicate replies, per-message responses are suppressed (via `grokInitialResponsePending`) until the initial combined response completes. The flag is set before `waitForGrokJoin` and cleared after the initial response is sent (or fails). Without this gate, customers would receive both individual per-message replies AND a combined initial reply — e.g. 3 replies for 2 messages. + +**Card update:** Main profile sees Grok's response as `groupRcv` and updates the team group card (same mechanism as ongoing Grok messages). + +**Visible history** must be enabled on customer groups (the bot enables it alongside file uploads in the business request handler). This allows Grok to read the full conversation history after joining, rather than only seeing messages sent after it joined. If Grok reads history and finds no customer messages (e.g., visible history was disabled or the API call failed), it sends a generic greeting asking the customer to repeat their question. + +#### Per-message: ongoing Grok conversation + +After the initial response, the Grok profile watches its own `newChatItems` events. It only triggers a Grok API call for `groupRcv` messages from the customer — identified via `businessChat.customerId` on the group's `groupInfo` (accessible to all members). Messages from the bot (main profile), from Grok itself (`groupSnd`), and from team members are ignored. Non-text messages (images, files, voice) do not trigger Grok API calls but still trigger a card update in the team group. + +**Batch deduplication:** When multiple customer messages arrive in a single `newChatItems` event (e.g., rapid messages delivered as a batch), only the last customer message per group triggers a Grok API call. Earlier messages are included in the history context via `apiGetChat`, so the single response addresses all messages in the batch. Without this, each message in the batch would trigger a separate API call, and the earlier calls would include later messages in their history — producing incoherent responses that reference messages "from the future." + +Every subsequent customer text message in a group where Grok is a member: +1. Triggers a card update in the team group (via the main profile, which sees the customer message as `groupRcv`) +2. Grok profile receives the message via its own event, rebuilds history by reading the last 100 messages from its own view of the group (Grok's messages → `assistant` role, customer's messages → `user` role) +3. Grok profile calls the Grok HTTP API and sends the response into the group using the group ID from its own event +4. Main profile sees Grok's response as `groupRcv` and updates the team group card + +In Grok mode, each customer message triggers two card updates — one on receipt (reflecting the new message and updated wait time) and one after Grok responds. This gives the team real-time visibility into active Grok conversations. + +If the Grok HTTP API call fails or times out for a per-message request, the Grok profile sends an error message into the group: "Sorry, I couldn't process that. Please try again or send /team for a human team member." Grok remains in the group and the state stays GROK — the customer can retry by sending another message. + +Grok API calls are NOT serialized per customer group in the MVP. If a new customer message arrives while a Grok API call is in flight, a second call runs concurrently — `apiGetChat` is re-read at the start of each call so history converges eventually, but two rapid messages in the same group can produce interleaved context. Cross-group calls run concurrently by design (see implementation plan §10 "Cross-group Grok parallelism"). Per-group serialization is a planned future improvement. + +#### Grok removal + +Grok is removed from the group (via main profile `apiRemoveMembers`) in three cases: +1. Team member sends their first text message in the customer group +2. Grok join fails (120-second timeout) — graceful fallback to QUEUE, bot notifies the customer +3. Customer leaves the group + +### 5.4 Persistent State + +The bot writes a single JSON file (`{dbPrefix}_state.json`) that survives restarts. It uses the same `--db-prefix` as the SimpleX database files, so the state file is always co-located with the database (e.g. `./data/simplex_state.json` alongside `./data/simplex_chat.db` and `./data/simplex_agent.db`). This ensures backups and migrations that copy the database directory also capture the bot state. + +#### Why a state file at all? + +SimpleX Chat's own database stores the full message history and group membership, but it does not store the bot's derived knowledge — things like which team group was created on first run, or which contact is the established bot↔Grok link. Per-conversation state (QUEUE/GROK/TEAM-PENDING/TEAM) is written into the customer group's `customData` at the moment the bot handles each transition — it observes its own `/grok` invite, `/team` add, team message, first customer message. Only display data (message counts, timestamps, sender names) is re-derived from chat history on demand. + +#### What is persisted and why + +| Key | Type | Why persisted | What breaks without it | +|-----|------|---------------|------------------------| +| `teamGroupId` | number | The bot creates the team group on first run; subsequent runs must find the same group | Bot creates a new empty team group on every restart; all team members lose their dashboard | +| `grokContactId` | number | Establishing a bot↔Grok contact takes up to 60 seconds and is a one-time setup | Every restart requires a 60-second re-connection; if it fails the bot exits | +| `grokUserId` | number | The bot creates the Grok user on first run; subsequent runs identify it by ID so a renamed profile cannot be silently mistaken for the main user | Startup restore (active-user recovery) and Grok profile resolution would fall back to display-name matching — fragile to any rename of the Grok profile | + +The `mainUserId` is **not** persisted — it is resolved at startup from `bot.run()`, which creates the main profile on a fresh DB and returns the user object. + +#### What is NOT persisted and why + +Per-group state (`state`, `cardItemId`, `complete`) lives in SimpleX's database as the group's `customData` — persisted there rather than in the bot's state file. + +| State | Where it lives instead | +|-------|----------------------| +| `state, cardItemId, complete` (per group) | Stored in the group's customData — conversation state, card message ID, auto-completed flag. `state` is written at event time (first customer message, `/grok`, `/team`, team's first message); the bot never re-derives it by scanning chat history. | +| Last customer message time | Derived from most recent customer message in chat history | +| Message count | Derived from message count in chat history (all messages except the bot's own) | +| Customer name | Always available from the group's display name | +| Who sent last message | Derived from recent chat history | +| `pendingGrokJoins` | In-flight during the 120-second join window only | +| Owner role promotion | Not tracked — the bot promotes team members to Owner at two idempotent points: (1) at invite time, immediately after `apiAddMember` in `addOrFindTeamMember` (skipped if the member is already in the group); (2) on every `memberConnected` in a customer group (unless the member is the customer or Grok). Survives restarts. | +| `pendingTeamDMs` | Messages queued to greet team members — simply not sent if lost | +| `grokJoinResolvers`, `grokFullyConnected` | Pure async synchronization primitives — always empty at startup | + +#### Failure modes + +If the state file is deleted or corrupted: +- A new team group is created. Team members must re-join it. +- The bot↔Grok contact is re-established (60-second startup delay). +- Grok remains in any groups it was already a member of. Since the Grok profile watches its own events, it will continue responding to customer messages in those groups without any additional recovery — no cross-profile state needs to be rebuilt. diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts new file mode 100644 index 0000000000..9b534381de --- /dev/null +++ b/apps/simplex-support-bot/src/bot.ts @@ -0,0 +1,948 @@ +import {api, util} from "simplex-chat" +import {T, CEvt} from "@simplex-chat/types" +import {Config} from "./config.js" +import {GrokMessage, GrokApiClient} from "./grok.js" +import {CardManager, ConversationState} from "./cards.js" +import { + queueMessage, grokInvitingMessage, grokActivatedMessage, teamAddedMessage, + teamAlreadyInvitedMessage, teamLockedMessage, noTeamMembersMessage, + grokUnavailableMessage, grokErrorMessage, grokNoHistoryMessage, +} from "./messages.js" +import {profileMutex, log, logError, getGroupInfo} from "./util.js" + +// Collects the keyword of every "command" entry in the bot's registered +// commands tree, descending into "menu" entries. Used to distinguish real +// commands from arbitrary text that happens to start with `/` (e.g. URLs, +// "/help" the user invented). +function commandKeywords(commands: T.ChatBotCommand[]): Set { + const out = new Set() + const visit = (cmds: T.ChatBotCommand[]): void => { + for (const c of cmds) { + if (c.type === "command") out.add(c.keyword) + else if (c.type === "menu") visit(c.commands) + } + } + visit(commands) + return out +} + +// True for any non-terminal status — invited but not yet accepted, through +// connected. Used to decide whether a contact is already in the group so we +// don't trigger a re-invite (the SimpleX API resends the invitation for a +// member in GSMemInvited). +function isInGroup(m: T.GroupMember): boolean { + switch (m.memberStatus) { + case T.GroupMemberStatus.Rejected: + case T.GroupMemberStatus.Removed: + case T.GroupMemberStatus.Left: + case T.GroupMemberStatus.Deleted: + case T.GroupMemberStatus.Unknown: + return false + default: + return true + } +} + +export class SupportBot { + // Card manager + cards: CardManager + + // Grok group mapping: memberId → mainGroupId (for pending joins) + private pendingGrokJoins = new Map() + // Buffered invitations that arrived before pendingGrokJoins was set (race condition) + private bufferedGrokInvitations = new Map() + // mainGroupId → grokLocalGroupId + private grokGroupMap = new Map() + // grokLocalGroupId → mainGroupId + private reverseGrokMap = new Map() + // mainGroupId → resolve fn for grok join + private grokJoinResolvers = new Map void>() + // mainGroupIds where Grok connectedToGroupMember fired + private grokFullyConnected = new Set() + // Suppress per-message Grok responses while activateGrok sends the initial combined response + private grokInitialResponsePending = new Set() + + // Pending DMs for team group members (contactId → message) + private pendingTeamDMs = new Map() + // Contacts that already received the team DM (dedup) + private sentTeamDMs = new Set() + + // Tracked fire-and-forget operations (for testing) + private _pendingOps: Promise[] = [] + + // Bot's business address link + businessAddress: string | null = null + + // Groups whose groupPreferences.commands we've already verified/synced + // in this process. Populated lazily by syncGroupCommands() on the first + // send to each group. + private syncedGroups = new Set() + + // Keywords from desiredCommands. A customer message is treated as a + // command only when its parsed keyword is in this set; anything else + // (URLs, "/help", arbitrary slashes) is routed as plain text. + private readonly customerKeywords: ReadonlySet + + constructor( + private chat: api.ChatApi, + private grokApi: GrokApiClient | null, + private config: Config, + private mainUserId: number, + private grokUserId: number | null, + private desiredCommands: T.ChatBotCommand[], + ) { + this.cards = new CardManager(chat, config, mainUserId, config.cardFlushSeconds * 1000) + this.customerKeywords = commandKeywords(desiredCommands) + } + + private customerCommand(chatItem: T.ChatItem): util.BotCommand | undefined { + const cmd = util.ciBotCommand(chatItem) + return cmd && this.customerKeywords.has(cmd.keyword) ? cmd : undefined + } + + private get grokEnabled(): boolean { + return this.grokApi !== null + } + + // Wait for all fire-and-forget operations to settle (for testing) + async flush(): Promise { + while (this._pendingOps.length > 0) { + const ops = this._pendingOps.splice(0) + await Promise.allSettled(ops) + } + } + + private fireAndForget(op: Promise): void { + const tracked = op.catch(err => logError("async operation error", err)) + this._pendingOps.push(tracked) + tracked.finally(() => { + const idx = this._pendingOps.indexOf(tracked) + if (idx >= 0) this._pendingOps.splice(idx, 1) + }) + } + + // --- Profile-switching helpers --- + + private async withMainProfile(fn: () => Promise): Promise { + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(this.mainUserId) + return fn() + }) + } + + // Ensure this group's groupPreferences.commands match desiredCommands, + // so commands in outgoing messages render as clickable for members of + // this group. Scoped to the group (apiUpdateGroupProfile broadcasts + // XGrpInfo/XGrpPrefs to group members only), and cached so we don't + // re-check on every send. Pre-checks local state via apiGetChat so we + // don't issue a no-op broadcast when the group already has the + // commands. + private async syncGroupCommands(groupId: number): Promise { + if (this.syncedGroups.has(groupId)) return + const desiredJSON = JSON.stringify(this.desiredCommands) + const chat = await this.chat.apiGetChat(T.ChatType.Group, groupId, 0) + const info = chat.chatInfo + if (info.type !== "group") return + const gp = info.groupInfo.groupProfile + const currentPrefs = gp.groupPreferences ?? {} + if (JSON.stringify(currentPrefs.commands ?? []) !== desiredJSON) { + await this.chat.apiUpdateGroupProfile(groupId, { + ...gp, + groupPreferences: {...currentPrefs, commands: this.desiredCommands}, + }) + log(`Pushed commands to group ${groupId}`) + } + this.syncedGroups.add(groupId) + } + + private async withGrokProfile(fn: () => Promise): Promise { + if (this.grokUserId === null) throw new Error("Grok is disabled (no GROK_API_KEY)") + const grokUserId = this.grokUserId + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(grokUserId) + return fn() + }) + } + + // --- Main profile event handlers --- + + async onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): Promise { + const groupId = evt.groupInfo.groupId + try { + const profile = evt.groupInfo.groupProfile + await this.withMainProfile(() => + this.chat.apiUpdateGroupProfile(groupId, { + displayName: profile.displayName, + fullName: profile.fullName, + groupPreferences: { + ...profile.groupPreferences, + files: {enable: T.GroupFeatureEnabled.On}, + history: {enable: T.GroupFeatureEnabled.On}, + }, + }) + ) + // file uploads + history enabled + } catch (err) { + logError(`Failed to update business group ${groupId} preferences`, err) + } + } + + async onNewChatItems(evt: CEvt.NewChatItems): Promise { + // Only process events for main profile + if (evt.user.userId !== this.mainUserId) return + for (const ci of evt.chatItems) { + try { + await this.processMainChatItem(ci) + } catch (err) { + logError("Error processing chat item", err) + } + } + } + + async onChatItemUpdated(evt: CEvt.ChatItemUpdated): Promise { + if (evt.user.userId !== this.mainUserId) return + const {chatInfo} = evt.chatItem + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + if (!groupInfo.businessChat) return + this.cards.scheduleUpdate(groupInfo.groupId) + } + + async onChatItemReaction(evt: CEvt.ChatItemReaction): Promise { + if (evt.user.userId !== this.mainUserId) return + if (!evt.added) return + const chatInfo = evt.reaction.chatInfo + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + if (!groupInfo.businessChat) return + this.cards.scheduleUpdate(groupInfo.groupId) + } + + async onLeftMember(evt: CEvt.LeftMember): Promise { + if (evt.user.userId !== this.mainUserId) return + const groupId = evt.groupInfo.groupId + const member = evt.member + const bc = evt.groupInfo.businessChat + if (!bc) return + + if (member.memberId === bc.customerId) { + log(`Customer left group ${groupId}`) + this.cleanupGrokMaps(groupId) + try { await this.cards.clearCustomData(groupId) } catch {} + return + } + + if (this.config.grokContactId !== null && member.memberContactId === this.config.grokContactId) { + log(`Grok left group ${groupId}`) + this.cleanupGrokMaps(groupId) + return + } + + if (this.config.teamMembers.some(tm => tm.id === member.memberContactId)) { + log(`Team member left group ${groupId}`) + } + } + + async onJoinedGroupMember(evt: CEvt.JoinedGroupMember): Promise { + if (evt.user.userId !== this.mainUserId) return + if (evt.groupInfo.groupId === this.config.teamGroup.id) { + await this.sendTeamMemberDM(evt.member) + } + } + + async onMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise { + if (evt.user.userId !== this.mainUserId) return + const groupId = evt.groupInfo.groupId + + // Team group → send DM (if not already sent by onJoinedGroupMember) + if (groupId === this.config.teamGroup.id) { + await this.sendTeamMemberDM(evt.member, evt.memberContact) + return + } + + // Customer group → promote to Owner (unless customer or Grok). Idempotent per plan §11. + const bc = evt.groupInfo.businessChat + if (bc) { + const isCustomer = evt.member.memberId === bc.customerId + const isGrok = this.config.grokContactId !== null + && evt.member.memberContactId === this.config.grokContactId + if (!isCustomer && !isGrok) { + try { + await this.withMainProfile(() => + this.chat.apiSetMembersRole(groupId, [evt.member.groupMemberId], T.GroupMemberRole.Owner) + ) + log(`Promoted member ${evt.member.groupMemberId} to Owner in group ${groupId}`) + } catch (err) { + logError(`Failed to promote member in group ${groupId}`, err) + } + } + } + } + + async onMemberContactReceivedInv(evt: CEvt.NewMemberContactReceivedInv): Promise { + if (evt.user.userId !== this.mainUserId) return + const {contact, groupInfo, member} = evt + if (groupInfo.groupId === this.config.teamGroup.id) { + if (this.sentTeamDMs.has(contact.contactId)) return + log(`DM contact from team group member: ${contact.contactId}:${member.memberProfile.displayName}`) + const name = member.memberProfile.displayName + const formatted = name.includes(" ") ? `'${name}'` : name + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contact.contactId}:${formatted}` + // Try sending immediately — contact may already be usable + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contact.contactId], msg) + ) + this.sentTeamDMs.add(contact.contactId) + log(`Sent DM to team member ${contact.contactId}:${name}`) + } catch { + // Not ready yet — queue for contactConnected / contactSndReady + this.pendingTeamDMs.set(contact.contactId, msg) + log(`Queued DM for team member ${contact.contactId}:${name}`) + } + } + } + + async onContactConnected(evt: CEvt.ContactConnected): Promise { + if (evt.user.userId !== this.mainUserId) return + await this.deliverPendingDM(evt.contact.contactId) + } + + async onContactSndReady(evt: CEvt.ContactSndReady): Promise { + if (evt.user.userId !== this.mainUserId) return + await this.deliverPendingDM(evt.contact.contactId) + } + + private async deliverPendingDM(contactId: number): Promise { + if (this.sentTeamDMs.has(contactId)) { + this.pendingTeamDMs.delete(contactId) + return + } + const pendingMsg = this.pendingTeamDMs.get(contactId) + if (pendingMsg === undefined) return + this.pendingTeamDMs.delete(contactId) + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contactId], pendingMsg) + ) + this.sentTeamDMs.add(contactId) + log(`Sent DM to team member ${contactId}`) + } catch (err) { + logError(`Failed to send DM to team member ${contactId}`, err) + } + } + + // --- Grok profile event handlers --- + + async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise { + if (evt.user.userId !== this.grokUserId) return + const memberId = evt.groupInfo.membership.memberId + const mainGroupId = this.pendingGrokJoins.get(memberId) + if (mainGroupId === undefined) { + // Buffer: invitation may arrive before pendingGrokJoins is set (race with apiAddMember) + this.bufferedGrokInvitations.set(memberId, evt) + return + } + this.pendingGrokJoins.delete(memberId) + this.bufferedGrokInvitations.delete(memberId) + await this.processGrokInvitation(evt, mainGroupId) + } + + private async processGrokInvitation(evt: CEvt.ReceivedGroupInvitation, mainGroupId: number): Promise { + log(`Grok joining group: mainGroupId=${mainGroupId}, grokGroupId=${evt.groupInfo.groupId}`) + try { + await this.withGrokProfile(() => this.chat.apiJoinGroup(evt.groupInfo.groupId)) + } catch (err) { + logError(`Grok failed to join group ${evt.groupInfo.groupId}`, err) + return + } + this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId) + this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId) + } + + async onGrokMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise { + if (evt.user.userId !== this.grokUserId) return + const grokGroupId = evt.groupInfo.groupId + const mainGroupId = this.reverseGrokMap.get(grokGroupId) + if (mainGroupId === undefined) return + this.grokFullyConnected.add(mainGroupId) + const resolver = this.grokJoinResolvers.get(mainGroupId) + if (resolver) { + this.grokJoinResolvers.delete(mainGroupId) + log(`Grok fully connected: mainGroupId=${mainGroupId}, grokGroupId=${grokGroupId}`) + resolver() + } + } + + async onGrokNewChatItems(evt: CEvt.NewChatItems): Promise { + if (evt.user.userId !== this.grokUserId) return + // When multiple customer messages arrive in one batch, only respond to the + // last per group — earlier messages are included in its history context. + const lastPerGroup = new Map() + for (const ci of evt.chatItems) { + const {chatInfo, chatItem} = ci + if (chatInfo.type !== "group") continue + if (chatItem.chatDir.type !== "groupRcv") continue + if (!util.ciContentText(chatItem)?.trim()) continue + if (this.customerCommand(chatItem)) continue + const bc = chatInfo.groupInfo.businessChat + if (!bc) continue + if (chatItem.chatDir.groupMember.memberId !== bc.customerId) continue + lastPerGroup.set(chatInfo.groupInfo.groupId, ci) + } + // Groups are independent — avoid serializing one group's xAI latency across the others. + await Promise.allSettled( + [...lastPerGroup.values()].map((ci) => this.processGrokChatItem(ci)), + ) + } + + // --- Main profile message routing --- + + private async processMainChatItem(ci: T.AChatItem): Promise { + const {chatInfo, chatItem} = ci + + // 1. Direct text message → reply with business address + if (chatInfo.type === "direct" && chatItem.chatDir.type === "directRcv" + && (chatItem.content as any).type === "rcvMsgContent") { + if (this.businessAddress) { + const contactId = chatInfo.contact.contactId + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage( + [T.ChatType.Direct, contactId], + `Please re-connect to this address for any questions: ${this.businessAddress}`, + ) + ) + } catch (err) { + logError(`Failed to reply to direct message from contact ${contactId}`, err) + } + } + return + } + + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + const groupId = groupInfo.groupId + + // 2. Team group → handle /join + if (groupId === this.config.teamGroup.id) { + await this.processTeamGroupMessage(chatItem) + return + } + + // 3. Skip non-business groups + if (!groupInfo.businessChat) return + + // 4. Skip own messages + if (chatItem.chatDir.type === "groupSnd") return + if (chatItem.chatDir.type !== "groupRcv") return + + const sender = chatItem.chatDir.groupMember + const bc = groupInfo.businessChat + const isCustomer = sender.memberId === bc.customerId + + // 6. Non-customer message → one-way gate check + card update + if (!isCustomer) { + const isTeam = this.config.teamMembers.some(tm => tm.id === sender.memberContactId) + + if (isTeam && util.ciContentText(chatItem)?.trim()) { + // One-way gate: first team text → transition to TEAM + remove Grok + const data = await this.cards.getRawCustomData(groupId) + if (data?.state !== "TEAM") { + await this.cards.mergeCustomData(groupId, {state: "TEAM"}) + const {grokMember} = await this.cards.getGroupComposition(groupId) + if (grokMember) { + log(`One-way gate: team message in group ${groupId}, removing Grok`) + try { + await this.withMainProfile(() => + this.chat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) + ) + } catch { + // may have already left + } + this.cleanupGrokMaps(groupId) + } + } + } + // Schedule card update for any non-customer message (team or Grok) + this.cards.scheduleUpdate(groupId) + return + } + + // 8. Customer message → derive state and dispatch + const state = await this.cards.deriveState(groupId) + const cmd = this.customerCommand(chatItem) + const text = util.ciContentText(chatItem)?.trim() || null + + switch (state) { + case "WELCOME": + if (cmd?.keyword === "grok") { + // WELCOME → GROK (skip queue msg). Write state optimistically so the + // card renders with GROK icon/label; activateGrok will revert via + // setStateOnFail if activation fails. + // Fire-and-forget: activateGrok awaits future events (waitForGrokJoin) + // which would deadlock the sequential event loop if awaited here. + await this.cards.mergeCustomData(groupId, {state: "GROK"}) + await this.cards.createCard(groupId, groupInfo) + this.fireAndForget(this.activateGrok(groupId, {sendQueueOnFail: true, setStateOnFail: "QUEUE"})) + return + } + if (cmd?.keyword === "team") { + // activateTeam writes state=TEAM-PENDING before the add loop + await this.activateTeam(groupId) + await this.cards.createCard(groupId, groupInfo) + return + } + // First regular message → QUEUE + if (text) { + await this.cards.mergeCustomData(groupId, {state: "QUEUE"}) + await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + await this.cards.createCard(groupId, groupInfo) + } + break + + case "QUEUE": + if (cmd?.keyword === "grok") { + // Write state optimistically; activateGrok reverts to QUEUE on failure + await this.cards.mergeCustomData(groupId, {state: "GROK"}) + this.fireAndForget(this.activateGrok(groupId, {setStateOnFail: "QUEUE"})) + } else if (cmd?.keyword === "team") { + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + + case "GROK": + if (cmd?.keyword === "team") { + await this.activateTeam(groupId) + } else if (cmd?.keyword === "grok") { + // Already in grok mode — ignore + } else if (text) { + // Customer text → Grok responds (handled by Grok profile's onGrokNewChatItems) + // Just schedule card update for the customer message + } + this.cards.scheduleUpdate(groupId) + break + + case "TEAM-PENDING": + if (cmd?.keyword === "grok") { + // Invite Grok if not present; state stays TEAM-PENDING + const {grokMember} = await this.cards.getGroupComposition(groupId) + if (!grokMember) { + this.fireAndForget(this.activateGrok(groupId)) + } + // else: already present, ignore + } else if (cmd?.keyword === "team") { + // activateTeam handles "already invited" reply (team still present) + // or silent re-add (team has all left) + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + + case "TEAM": + if (cmd?.keyword === "grok") { + await this.sendToGroup(groupId, teamLockedMessage) + } else if (cmd?.keyword === "team") { + // Team still present → "already invited"; team all left → silent re-add + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + } + } + + // --- Grok profile message processing --- + + private async processGrokChatItem(ci: T.AChatItem): Promise { + if (!this.grokApi) return + const grokApi = this.grokApi + const {chatInfo, chatItem} = ci + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + const grokGroupId = groupInfo.groupId + + // Skip while activateGrok is sending the initial combined response + const mainGroupId = this.reverseGrokMap.get(grokGroupId) + if (mainGroupId !== undefined && this.grokInitialResponsePending.has(mainGroupId)) return + + // Only process received text messages from customer + if (chatItem.chatDir.type !== "groupRcv") return + const text = util.ciContentText(chatItem)?.trim() + if (!text) return // ignore non-text + + // Ignore bot commands + if (this.customerCommand(chatItem)) return + + // Only respond in business groups (survives restart without in-memory maps) + const bc = groupInfo.businessChat + if (!bc) return + + // Only respond to customer messages, not bot or team messages + if (chatItem.chatDir.groupMember.memberId !== bc.customerId) return + + // Read history from Grok's own view + try { + const chat = await this.withGrokProfile(() => + this.chat.apiGetChat(T.ChatType.Group, grokGroupId, 100) + ) + const history: GrokMessage[] = [] + for (const histCi of chat.chatItems) { + const histText = util.ciContentText(histCi)?.trim() + if (!histText) continue + if (histCi.chatDir.type === "groupSnd") { + history.push({role: "assistant", content: histText}) + } else if (histCi.chatDir.type === "groupRcv" + && histCi.chatDir.groupMember.memberId === bc.customerId + && !this.customerCommand(histCi)) { + history.push({role: "user", content: histText}) + } + } + + // Don't include the current message in history — it's the userMessage + if (history.length > 0 && history[history.length - 1].role === "user" + && history[history.length - 1].content === text) { + history.pop() + } + + // Call Grok API (outside mutex) + const response = await grokApi.chat(history, text) + + // Send response via Grok profile + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], response) + ) + + // Grok asked for the team → escalate as if the customer sent /team + if (mainGroupId !== undefined && response.includes("/team")) await this.activateTeam(mainGroupId) + } catch (err) { + logError(`Grok per-message error for grokGroup ${grokGroupId}`, err) + try { + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], grokErrorMessage) + ) + } catch {} + } + + // Card update scheduled by main profile seeing the groupRcv events + } + + // --- Grok activation --- + + private async activateGrok( + groupId: number, + opts: {sendQueueOnFail?: boolean; setStateOnFail?: ConversationState} = {}, + ): Promise { + if (!this.grokApi) return + const grokApi = this.grokApi + const revertStateOnFail = async () => { + if (!opts.setStateOnFail) return + const current = await this.cards.getRawCustomData(groupId) + if (current?.state !== "GROK") return + await this.cards.mergeCustomData(groupId, {state: opts.setStateOnFail}) + } + if (this.config.grokContactId === null) { + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + // Pre-check: silent return if Grok is already in the group in any + // non-terminal status. The apiAddMember/groupDuplicateMember catch below + // handles Connected/etc. but the SimpleX API resends the invitation for + // GSMemInvited (no error thrown), so without this check a /grok issued + // while a previous activation is still pending would re-trigger the invite. + const grokMembers = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + if (grokMembers.some(m => m.memberContactId === this.config.grokContactId && isInGroup(m))) { + return + } + + // Gate MUST be up before apiAddMember / pendingGrokJoins / reverseGrokMap — + // any later and onGrokNewChatItems can fire a duplicate per-message reply. + this.grokInitialResponsePending.add(groupId) + try { + await this.sendToGroup(groupId, grokInvitingMessage) + + let member: T.GroupMember + try { + member = await this.withMainProfile(() => + this.chat.apiAddMember(groupId, this.config.grokContactId!, T.GroupMemberRole.Member) + ) + } catch (err: unknown) { + const chatErr = err as {chatError?: {errorType?: {type?: string}}} + if (chatErr?.chatError?.errorType?.type === "groupDuplicateMember") { + // Grok already in group (e.g. customer sent /grok again before join completed) — + // the in-flight activation will handle the outcome, just return silently + return + } + logError(`Failed to invite Grok to group ${groupId}`, err) + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + this.pendingGrokJoins.set(member.memberId, groupId) + + // Drain buffered invitation that arrived during the apiAddMember await + const buffered = this.bufferedGrokInvitations.get(member.memberId) + if (buffered) { + this.bufferedGrokInvitations.delete(member.memberId) + this.pendingGrokJoins.delete(member.memberId) + await this.processGrokInvitation(buffered, groupId) + } + + const joined = await this.waitForGrokJoin(groupId, 120_000) + if (!joined) { + this.pendingGrokJoins.delete(member.memberId) + try { + await this.withMainProfile(() => + this.chat.apiRemoveMembers(groupId, [member.groupMemberId]) + ) + } catch {} + this.cleanupGrokMaps(groupId) + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + await this.sendToGroup(groupId, grokActivatedMessage) + + // Grok joined — send initial response based on customer's accumulated messages + try { + const grokLocalGId = this.grokGroupMap.get(groupId) + if (grokLocalGId === undefined) { + await this.sendToGroup(groupId, grokUnavailableMessage) + return + } + + // Read history from Grok's own view — only customer messages. + // The previous `grokBc && ...` short-circuit let bot and team + // messages through when Grok's view had no businessChat; require + // grokBc.customerId to be present and match strictly. + const chat = await this.withGrokProfile(() => + this.chat.apiGetChat(T.ChatType.Group, grokLocalGId, 100) + ) + const grokBc = chat.chatInfo.type === "group" ? chat.chatInfo.groupInfo.businessChat : null + const customerMessages: string[] = [] + for (const ci of chat.chatItems) { + if (ci.chatDir.type !== "groupRcv") continue + if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue + const t = util.ciContentText(ci)?.trim() + if (t && !this.customerCommand(ci)) customerMessages.push(t) + } + + if (customerMessages.length === 0) { + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], grokNoHistoryMessage) + ) + return + } + + const initialMsg = customerMessages.join("\n") + const response = await grokApi.chat([], initialMsg) + + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) + ) + + // Grok asked for the team → escalate as if the customer sent /team + if (response.includes("/team")) await this.activateTeam(groupId) + } catch (err) { + logError(`Grok initial response failed for group ${groupId}`, err) + await this.sendToGroup(groupId, grokUnavailableMessage) + } + } finally { + this.grokInitialResponsePending.delete(groupId) + } + } + + // --- Team activation --- + + private async activateTeam(groupId: number): Promise { + if (this.config.teamMembers.length === 0) { + await this.sendToGroup(groupId, noTeamMembersMessage(this.grokEnabled)) + return + } + + const data = await this.cards.getRawCustomData(groupId) + const alreadyActivated = data?.state === "TEAM-PENDING" || data?.state === "TEAM" + if (alreadyActivated) { + const {teamMembers} = await this.cards.getGroupComposition(groupId) + if (teamMembers.length > 0) { + await this.sendToGroup(groupId, teamAlreadyInvitedMessage) + return + } + // Team previously activated but all team members have since left — + // re-add silently (no teamAddedMessage). State stays TEAM-PENDING/TEAM. + for (const tm of this.config.teamMembers) { + try { + await this.addOrFindTeamMember(groupId, tm.id) + } catch (err) { + logError(`Failed to add team member ${tm.id} to group ${groupId}`, err) + } + } + return + } + + // First activation — write state BEFORE add loop so concurrent customer + // events observing mid-flight see TEAM-PENDING rather than stale state. + await this.cards.mergeCustomData(groupId, {state: "TEAM-PENDING"}) + + for (const tm of this.config.teamMembers) { + try { + await this.addOrFindTeamMember(groupId, tm.id) + } catch (err) { + logError(`Failed to add team member ${tm.id} to group ${groupId}`, err) + } + } + + const {grokMember} = await this.cards.getGroupComposition(groupId) + await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone, !!grokMember)) + } + + // --- Team group commands --- + + private async processTeamGroupMessage(chatItem: T.ChatItem): Promise { + if (chatItem.chatDir.type !== "groupRcv") return + const senderContactId = chatItem.chatDir.groupMember.memberContactId + if (!senderContactId) return + + const cmd = util.ciBotCommand(chatItem) + if (cmd?.keyword !== "join") return + + const targetGroupId = Number.parseInt(cmd.params, 10) + if (Number.isNaN(targetGroupId) || targetGroupId <= 0) { + await this.sendToGroup(this.config.teamGroup.id, `Error: invalid group id "${cmd.params}"`) + return + } + await this.handleJoinCommand(targetGroupId, senderContactId) + } + + private async handleJoinCommand(targetGroupId: number, senderContactId: number): Promise { + // Validate target is a business group + const targetGroup = await this.withMainProfile(() => getGroupInfo(this.chat, targetGroupId)) + if (!targetGroup?.businessChat) { + await this.sendToGroup(this.config.teamGroup.id, `Error: group ${targetGroupId} is not a business chat`) + return + } + + try { + const member = await this.addOrFindTeamMember(targetGroupId, senderContactId) + if (member) { + log(`Team member ${senderContactId} joined group ${targetGroupId} via /join`) + } + } catch (err) { + logError(`/join failed for group ${targetGroupId}`, err) + await this.sendToGroup(this.config.teamGroup.id, `Error joining group ${targetGroupId}`) + } + } + + // --- Helpers --- + + private async addOrFindTeamMember(groupId: number, teamContactId: number): Promise { + // Pre-check membership: skip apiAddMember entirely if the contact is in + // the group in any non-terminal status. The SimpleX API resends the + // invitation for a member in GSMemInvited, so calling apiAddMember on a + // pending invitee would re-trigger an invite notification. + const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + const existing = members.find(m => m.memberContactId === teamContactId && isInGroup(m)) + if (existing) return existing + const member = await this.withMainProfile(() => + this.chat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member) + ) + try { + await this.withMainProfile(() => + this.chat.apiSetMembersRole(groupId, [member.groupMemberId], T.GroupMemberRole.Owner) + ) + } catch { + // Not yet connected — will be promoted in onMemberConnected + } + return member + } + + async sendToGroup(groupId: number, text: string): Promise { + try { + await this.withMainProfile(async () => { + await this.syncGroupCommands(groupId) + await this.chat.apiSendTextMessage([T.ChatType.Group, groupId], text) + }) + } catch (err) { + logError(`Failed to send message to group ${groupId}`, err) + } + } + + private waitForGrokJoin(groupId: number, timeout: number): Promise { + if (this.grokFullyConnected.has(groupId)) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.grokJoinResolvers.delete(groupId) + resolve(false) + }, timeout) + this.grokJoinResolvers.set(groupId, () => { + clearTimeout(timer) + resolve(true) + }) + }) + } + + private async sendTeamMemberDM(member: T.GroupMember, memberContact?: T.Contact): Promise { + const name = member.memberProfile.displayName + const formatted = name.includes(" ") ? `'${name}'` : name + + let contactId = memberContact?.contactId ?? member.memberContactId + if (!contactId) { + // No DM contact yet — create one and send invitation with message + try { + const contact = await this.withMainProfile(() => + this.chat.apiCreateMemberContact(this.config.teamGroup.id, member.groupMemberId) + ) + contactId = contact.contactId as number + log(`Created DM contact ${contactId} for team member ${name}`) + } catch (err) { + logError(`Failed to create member contact for ${name}`, err) + return + } + if (this.sentTeamDMs.has(contactId)) return + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}` + try { + await this.withMainProfile(() => + this.chat.apiSendMemberContactInvitation(contactId!, msg) + ) + this.sentTeamDMs.add(contactId) + this.pendingTeamDMs.delete(contactId) + log(`Sent DM invitation to team member ${contactId}:${name}`) + } catch { + this.pendingTeamDMs.set(contactId, msg) + } + return + } + // Contact already exists — send via normal DM + if (this.sentTeamDMs.has(contactId)) return + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}` + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contactId], msg) + ) + this.sentTeamDMs.add(contactId) + this.pendingTeamDMs.delete(contactId) + log(`Sent DM to team member ${contactId}:${name}`) + } catch { + this.pendingTeamDMs.set(contactId, msg) + } + } + + private cleanupGrokMaps(groupId: number): void { + const grokLocalGId = this.grokGroupMap.get(groupId) + this.grokFullyConnected.delete(groupId) + this.grokInitialResponsePending.delete(groupId) + if (grokLocalGId === undefined) return + this.grokGroupMap.delete(groupId) + this.reverseGrokMap.delete(grokLocalGId) + } +} diff --git a/apps/simplex-support-bot/src/cards.ts b/apps/simplex-support-bot/src/cards.ts new file mode 100644 index 0000000000..feea986551 --- /dev/null +++ b/apps/simplex-support-bot/src/cards.ts @@ -0,0 +1,479 @@ +import {T} from "@simplex-chat/types" +import {api, util} from "simplex-chat" +import {Mutex} from "async-mutex" +import {Config} from "./config.js" +import {profileMutex, log, logError, getGroupInfo} from "./util.js" + +// State derivation types +export type ConversationState = "WELCOME" | "QUEUE" | "GROK" | "TEAM-PENDING" | "TEAM" + +function isConversationState(x: unknown): x is ConversationState { + return x === "WELCOME" || x === "QUEUE" || x === "GROK" || x === "TEAM-PENDING" || x === "TEAM" +} + +export interface GroupComposition { + grokMember: T.GroupMember | undefined + teamMembers: T.GroupMember[] +} + +interface CardData { + state?: ConversationState + cardItemId?: number + complete?: boolean +} + +function isActiveMember(m: T.GroupMember): boolean { + return m.memberStatus === T.GroupMemberStatus.Connected + || m.memberStatus === T.GroupMemberStatus.Complete + || m.memberStatus === T.GroupMemberStatus.Announced +} + +// Prevent ! from triggering SimpleX markdown styled text (color/small). +// The parser treats !N as color markup (N: 1-6, r, g, b, y, c, m, -) +// and closes at the next !. No escape mechanism exists in the parser, +// so we insert a zero-width space to break the trigger pattern. +function escapeStyledMarkdown(text: string): string { + return text.replace(/!([1-6rgbycm-])/g, "!\u200B$1") +} + +// Truncate a single message to ~maxChars, appending [truncated] if needed +function truncateMsg(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + return text.slice(0, maxChars) + "… [truncated]" +} + +// Describe non-text content types +function contentTypeLabel(ci: T.ChatItem): string | null { + const content = ci.content as T.CIContent + if (content.type !== "rcvMsgContent" && content.type !== "sndMsgContent") return null + const mc = content.msgContent + switch (mc.type) { + case "image": return "[image]" + case "video": return "[video]" + case "voice": return "[voice]" + case "file": return "[file]" + default: return null + } +} + +export class CardManager { + private pendingUpdates = new Set() + private flushInterval: NodeJS.Timeout + // Outer lock; profileMutex (via withMainProfile) is the inner lock. + private customDataMutexes = new Map() + + constructor( + private chat: api.ChatApi, + private config: Config, + private mainUserId: number, + flushIntervalMs = 300 * 1000, + ) { + this.flushInterval = setInterval(() => this.flush(), flushIntervalMs) + this.flushInterval.unref() + } + + private async withMainProfile(fn: () => Promise): Promise { + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(this.mainUserId) + return fn() + }) + } + + private getCustomDataMutex(groupId: number): Mutex { + let m = this.customDataMutexes.get(groupId) + if (!m) { + m = new Mutex() + this.customDataMutexes.set(groupId, m) + } + return m + } + + scheduleUpdate(groupId: number): void { + this.pendingUpdates.add(groupId) + } + + async createCard(groupId: number, groupInfo: T.GroupInfo): Promise { + const {text} = await this.composeCard(groupId, groupInfo) + const chatRef: T.ChatRef = {chatType: T.ChatType.Group, chatId: this.config.teamGroup.id} + const items = await this.withMainProfile(() => + this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + ) + await this.mergeCustomData(groupId, {cardItemId: items[0].chatItem.meta.itemId}) + } + + async flush(): Promise { + const groups = [...this.pendingUpdates] + this.pendingUpdates.clear() + for (const groupId of groups) { + try { + await this.flushOne(groupId) + } catch (err) { + logError(`Card flush failed for group ${groupId}`, err) + } + } + } + + // Dispatches to create-path when cardItemId is absent so a failed createCard retries. + private async flushOne(groupId: number): Promise { + const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId)) + if (!groupInfo) return + const data = groupInfo.customData as Record | undefined + if (typeof data?.cardItemId === "number") { + await this.updateCard(groupId) + } else { + await this.createCard(groupId, groupInfo) + } + } + + async refreshAllCards(): Promise { + // Scan the most recently active 1000 chats. Active cards live on + // recently-active customer chats by definition — a card stays open + // while the conversation is in flight. If the bot has been offline + // long enough that an active card has fallen outside this window, the + // card refreshes lazily on the next customer message (which moves the + // chat back into the recent window). + const chats = await this.withMainProfile(() => + this.chat.apiGetChats(this.mainUserId, {type: "last", count: 1000}) + ) + const activeCards: {groupId: number; cardItemId: number}[] = [] + for (const c of chats) { + if (c.chatInfo.type !== "group") continue + const groupInfo = c.chatInfo.groupInfo + const customData = groupInfo.customData as Record | undefined + if (customData && typeof customData.cardItemId === "number" && !customData.complete) { + activeCards.push({groupId: groupInfo.groupId, cardItemId: customData.cardItemId}) + } + } + if (activeCards.length === 0) return + + // Sort ascending by cardItemId — higher ID = more recently updated card. + // Oldest-updated cards refresh first; newest-updated refresh last, + // so the most recent cards end up at the bottom of the team group. + activeCards.sort((a, b) => a.cardItemId - b.cardItemId) + + log(`Startup: refreshing ${activeCards.length} card(s)`) + + for (const {groupId} of activeCards) { + try { + await this.updateCard(groupId) + } catch (err) { + logError(`Startup card refresh failed for group ${groupId}`, err) + } + } + } + + destroy(): void { + clearInterval(this.flushInterval) + } + + // --- State derivation --- + + async getGroupComposition(groupId: number): Promise { + const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + return { + grokMember: members.find(m => + this.config.grokContactId !== null + && m.memberContactId === this.config.grokContactId + && isActiveMember(m)), + teamMembers: members.filter(m => + this.config.teamMembers.some(tm => tm.id === m.memberContactId) + && isActiveMember(m)), + } + } + + async deriveState(groupId: number): Promise { + const data = await this.getRawCustomData(groupId) + return data?.state ?? "WELCOME" + } + + async getLastCustomerMessageTime(groupId: number, customerId: string): Promise { + const chat = await this.getChat(groupId, 20) + for (let i = chat.chatItems.length - 1; i >= 0; i--) { + const ci = chat.chatItems[i] + if (ci.chatDir.type === "groupRcv" && ci.chatDir.groupMember.memberId === customerId) { + return new Date(ci.meta.createdAt).getTime() + } + } + return undefined + } + + async getLastTeamOrGrokMessageTime(groupId: number): Promise { + const chat = await this.getChat(groupId, 20) + for (let i = chat.chatItems.length - 1; i >= 0; i--) { + const ci = chat.chatItems[i] + if (ci.chatDir.type === "groupRcv") { + const contactId = ci.chatDir.groupMember.memberContactId + const isTeam = this.config.teamMembers.some(tm => tm.id === contactId) + const isGrok = this.config.grokContactId !== null && contactId === this.config.grokContactId + if (isTeam || isGrok) return new Date(ci.meta.createdAt).getTime() + } + if (ci.chatDir.type === "groupSnd") { + // Bot's own messages don't count + } + } + return undefined + } + + // --- Custom data --- + + async getRawCustomData(groupId: number): Promise | null> { + const group = await this.withMainProfile(() => getGroupInfo(this.chat, groupId)) + if (!group?.customData) return null + const data = group.customData as Record + const result: Partial = {} + if (isConversationState(data.state)) result.state = data.state + if (typeof data.cardItemId === "number") result.cardItemId = data.cardItemId + if (data.complete === true) result.complete = true + return result + } + + async mergeCustomData(groupId: number, patch: Partial): Promise { + return this.getCustomDataMutex(groupId).runExclusive(async () => { + const current = (await this.getRawCustomData(groupId)) ?? {} + const merged: Partial = {...current, ...patch} + for (const key of Object.keys(merged) as (keyof CardData)[]) { + if (merged[key] === undefined) delete merged[key] + } + await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged)) + }) + } + + async clearCustomData(groupId: number): Promise { + return this.getCustomDataMutex(groupId).runExclusive(() => + this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId)) + ) + } + + // --- Chat history access --- + + async getChat(groupId: number, count: number): Promise { + return this.withMainProfile(() => this.chat.apiGetChat(T.ChatType.Group, groupId, count)) + } + + // --- Internal --- + + private async updateCard(groupId: number): Promise { + const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId)) + if (!groupInfo) return + + const customData = groupInfo.customData as Record | undefined + const cardItemId = customData?.cardItemId + if (typeof cardItemId !== "number") return + + try { + await this.withMainProfile(() => + this.chat.apiDeleteChatItems( + T.ChatType.Group, this.config.teamGroup.id, [cardItemId], T.CIDeleteMode.Broadcast + ) + ) + } catch { + // card may already be deleted + } + + const {text, complete} = await this.composeCard(groupId, groupInfo) + const chatRef: T.ChatRef = {chatType: T.ChatType.Group, chatId: this.config.teamGroup.id} + const items = await this.withMainProfile(() => + this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + ) + const patch: Partial = { + cardItemId: items[0].chatItem.meta.itemId, + complete: complete ? true : undefined, + } + await this.mergeCustomData(groupId, patch) + } + + private async composeCard(groupId: number, groupInfo: T.GroupInfo): Promise<{text: string, complete: boolean}> { + const rawName = groupInfo.groupProfile.displayName || `group-${groupId}` + const customerName = rawName.replace(/\n+/g, " ") + const bc = groupInfo.businessChat + const customerId = bc?.customerId + + const state = await this.deriveState(groupId) + const {teamMembers} = await this.getGroupComposition(groupId) + + const icon = await this.computeIcon(groupId, state, customerId ?? undefined) + const waitStr = await this.computeWaitTime(groupId, state, customerId ?? undefined) + + const chat = await this.getChat(groupId, 100) + const msgCount = chat.chatItems.filter((ci: T.ChatItem) => ci.chatDir.type !== "groupSnd").length + + const stateLabel = this.stateLabel(state) + + const agentNames = teamMembers.map(m => m.memberProfile.displayName) + const agentStr = agentNames.length > 0 ? ` · ${agentNames.join(", ")}` : "" + + const preview = this.buildPreview(chat.chatItems, customerName, customerId) + + // Final line uses /'join ' quoting so SimpleX clients render the full + // command (including the argument) as a single clickable token. + const joinCmd = `/'join ${groupId}'` + + const line1 = `${icon} *${customerName}* · ${waitStr} · ${msgCount} msgs` + const line2 = `${stateLabel}${agentStr}` + return {text: `${line1}\n${line2}\n${preview}\n${joinCmd}`, complete: icon === "✅"} + } + + private async computeIcon( + groupId: number, state: ConversationState, customerId?: string, + ): Promise { + const now = Date.now() + const completeMs = this.config.completeHours * 3600_000 + + // Check auto-complete: last team/Grok message time vs customer silence + const lastTeamGrokTime = await this.getLastTeamOrGrokMessageTime(groupId) + if (lastTeamGrokTime) { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + // Auto-complete if team/grok replied and customer hasn't responded since, for completeHours + if (!lastCustTime || lastCustTime < lastTeamGrokTime) { + if (now - lastTeamGrokTime >= completeMs) return "✅" + } + } + + switch (state) { + case "QUEUE": { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime) return "🟡" + const waitMs = now - lastCustTime + if (waitMs < 5 * 60_000) return "🆕" + if (waitMs < 2 * 3600_000) return "🟡" + return "🔴" + } + case "GROK": + return "🤖" + case "TEAM-PENDING": + return "👋" + case "TEAM": { + // Check if customer follow-up unanswered > 2h + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (lastCustTime && lastTeamGrokTime && lastCustTime > lastTeamGrokTime) { + return (now - lastCustTime > 2 * 3600_000) ? "⏰" : "💬" + } + return "💬" + } + default: + return "🟡" + } + } + + private async computeWaitTime( + groupId: number, _state: ConversationState, customerId?: string, + ): Promise { + const now = Date.now() + const completeMs = this.config.completeHours * 3600_000 + + const lastTeamGrokTime = await this.getLastTeamOrGrokMessageTime(groupId) + if (lastTeamGrokTime) { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime || lastCustTime < lastTeamGrokTime) { + if (now - lastTeamGrokTime >= completeMs) return "done" + } + } + + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime) return "<1m" + return this.formatDuration(now - lastCustTime) + } + + private stateLabel(state: ConversationState): string { + switch (state) { + case "QUEUE": return "Queue" + case "GROK": return "Grok" + case "TEAM-PENDING": return "Team – pending" + case "TEAM": return "Team" + default: return "Queue" + } + } + + private buildPreview(chatItems: T.ChatItem[], customerName: string, customerId?: string): string { + const maxTotal = 500 + const maxPer = 200 + + // Collect entries in chronological order (oldest first) + const entries: {senderId: string; name: string; text: string}[] = [] + for (const ci of chatItems) { + if (ci.chatDir.type === "groupSnd") continue + + let text = (util.ciContentText(ci)?.trim() || "").replace(/\n+/g, " ") + const mediaLabel = contentTypeLabel(ci) + if (mediaLabel && !text) text = mediaLabel + else if (mediaLabel) text = `${mediaLabel} ${text}` + if (!text) continue + + let senderId = "" + let name = "" + if (ci.chatDir.type === "groupRcv") { + const member = ci.chatDir.groupMember + const contactId = member.memberContactId + senderId = member.memberId + if (this.config.grokContactId !== null && contactId === this.config.grokContactId) { + name = "Grok" + } else if (customerId && member.memberId === customerId) { + name = customerName + } else { + name = member.memberProfile.displayName + } + } + + entries.push({senderId, name, text: truncateMsg(text, maxPer)}) + } + + // Compute prefixed lines in chronological order (sender prefix on first msg of each run) + const lines: {line: string; senderId: string; name: string}[] = [] + let lastSenderId = "" + for (const entry of entries) { + let line = entry.text + if (entry.senderId !== lastSenderId && entry.name) { + line = `${entry.name}: ${line}` + lastSenderId = entry.senderId + } + lines.push({line, senderId: entry.senderId, name: entry.name}) + } + + // Take from the end (newest) until maxTotal exceeded — oldest messages are truncated + const selected: string[] = [] + let totalLen = 0 + let firstSelectedIdx = lines.length + for (let i = lines.length - 1; i >= 0; i--) { + if (totalLen + lines[i].line.length > maxTotal && selected.length > 0) { + break + } + selected.push(lines[i].line) + totalLen += lines[i].line.length + firstSelectedIdx = i + } + selected.reverse() + + // If truncation happened, ensure the first visible message has a sender prefix + if (firstSelectedIdx > 0 && selected.length > 0) { + const first = lines[firstSelectedIdx] + if (first.name && !selected[0].startsWith(`${first.name}: `)) { + selected[0] = `${first.name}: ${selected[0]}` + } + selected.unshift("[truncated]") + } + + const preview = selected.map(escapeStyledMarkdown).join(" !3 /! ") + return preview ? `"${preview}"` : '""' + } + + private formatDuration(ms: number): string { + if (ms < 60_000) return "<1m" + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m` + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h` + return `${Math.floor(ms / 86_400_000)}d` + } +} diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts new file mode 100644 index 0000000000..8fbd006aef --- /dev/null +++ b/apps/simplex-support-bot/src/config.ts @@ -0,0 +1,144 @@ +import {Command} from "commander" +import {api} from "simplex-chat" + +export interface IdName { + id: number + name: string +} + +export type Backend = "sqlite" | "postgres" + +export interface Config { + stateFile: string // local path to the bot's state JSON + db: api.DbConfig // passed to ChatApi.init / bot.run + teamGroup: IdName // name from CLI, id resolved at startup from state file + teamMembers: IdName[] // optional, empty if not provided + grokContactId: number | null // resolved at startup + timezone: string + completeHours: number + cardFlushSeconds: number + contextFile: string | null + grokApiKey: string | null +} + +// Mirrors packages/simplex-chat-nodejs/src/download-libs.js so runtime detection +// matches what was used at install time. Works whether the user installed via +// SIMPLEX_BACKEND env var, .npmrc (→ npm_config_simplex_backend), or the +// --simplex_backend=postgres CLI flag (also surfaced as npm_config_*). +export function detectBackend(): Backend { + const raw = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || "sqlite").toLowerCase() + if (raw !== "sqlite" && raw !== "postgres") { + throw new Error(`Invalid SIMPLEX_BACKEND: "${raw}". Must be "sqlite" or "postgres".`) + } + return raw +} + +export function parseIdName(s: string): IdName { + const i = s.indexOf(":") + if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`) + const id = parseInt(s.slice(0, i), 10) + if (isNaN(id)) throw new Error(`Invalid ID:name format (non-numeric ID): "${s}"`) + return {id, name: s.slice(i + 1)} +} + +function parseNonNegativeInt(flag: string) { + return (raw: string): number => { + const n = parseInt(raw, 10) + if (!Number.isFinite(n) || n < 0) { + throw new Error(`${flag} must be a non-negative integer, got "${raw}"`) + } + return n + } +} + +function buildCommand(): Command { + return new Command() + .name("simplex-chat-support-bot") + .description("business-address triage bot") + .requiredOption("--team-group ", "team group display name") + .option("--state-file ", "state JSON path", "./data/state.json") + .option("--sqlite-file-prefix ", "SQLite DB file prefix", "./data/simplex") + .option("--sqlite-key ", "SQLCipher encryption key (default: unencrypted)") + .option("--pg-conn ", "PostgreSQL connection string (required for postgres)") + .option("--pg-schema ", "PostgreSQL schema prefix (default: simplex_v1)") + .option("-a, --auto-add-team-members ", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)") + .option("--timezone ", "IANA timezone for weekend detection", "UTC") + .option("--complete-hours ", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3) + .option("--card-flush-seconds ", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300) + .option("--context-file ", "text file with Grok system context (required if GROK_API_KEY set)") + .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_BACKEND sqlite | postgres — alternative to .npmrc for backend selection\n") +} + +interface RawOpts { + teamGroup: string + stateFile: string + sqliteFilePrefix: string + sqliteKey?: string + pgConn?: string + pgSchema?: string + autoAddTeamMembers?: string + timezone: string + completeHours: number + cardFlushSeconds: number + contextFile?: string +} + +export function parseConfig(args: string[]): Config { + const cmd = buildCommand().exitOverride() + try { + cmd.parse(args, {from: "user"}) + } catch (err) { + const code = (err as {code?: string}).code + if (code === "commander.helpDisplayed" || code === "commander.version") process.exit(0) + throw err + } + const opts = cmd.opts() + + const grokApiKey = process.env.GROK_API_KEY || null + + const backend = detectBackend() + let db: api.DbConfig + if (backend === "sqlite") { + db = opts.sqliteKey + ? {type: "sqlite", filePrefix: opts.sqliteFilePrefix, encryptionKey: opts.sqliteKey} + : {type: "sqlite", filePrefix: opts.sqliteFilePrefix} + } else { + if (!opts.pgConn) { + throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)") + } + db = opts.pgSchema + ? {type: "postgres", connectionString: opts.pgConn, schemaPrefix: opts.pgSchema} + : {type: "postgres", connectionString: opts.pgConn} + } + + const teamGroup: IdName = {id: 0, name: opts.teamGroup} + + const teamMembersRaw = opts.autoAddTeamMembers ?? "" + const teamMembers = teamMembersRaw + ? teamMembersRaw.split(",").map(parseIdName) + : [] + + try { + new Intl.DateTimeFormat("en-US", {timeZone: opts.timezone, weekday: "short"}) + } catch (err) { + throw new Error(`--timezone "${opts.timezone}" is not a valid IANA time zone: ${(err as Error).message}`) + } + + const contextFile = opts.contextFile ?? null + if (grokApiKey && !contextFile) { + throw new Error("GROK_API_KEY is set but --context-file is not provided. Grok requires a context file.") + } + + return { + stateFile: opts.stateFile, + db, + teamGroup, + teamMembers, + grokContactId: null, + timezone: opts.timezone, + completeHours: opts.completeHours, + cardFlushSeconds: opts.cardFlushSeconds, + contextFile, + grokApiKey, + } +} diff --git a/apps/simplex-support-bot/src/context.ts b/apps/simplex-support-bot/src/context.ts new file mode 100644 index 0000000000..81f30117e9 --- /dev/null +++ b/apps/simplex-support-bot/src/context.ts @@ -0,0 +1,59 @@ +import {readFileSync} from "fs" +import {parse as parseYaml} from "yaml" +import {GrokMessage} from "./grok.js" + +const ALLOWED_ROLES: ReadonlySet = new Set(["system", "user", "assistant"]) +// Roles surfaced from a YAML transcript. `user` entries from the file are +// validated but dropped — the customer's runtime message is the only +// `user` content sent to Grok. +const PREPEND_ROLES: ReadonlySet = new Set(["system", "assistant"]) + +// Loads --context-file. The flag is documented as "text file with Grok +// system context"; a `.yaml` / `.yml` extension is an undocumented +// alternative that switches to a multi-turn transcript in the harness +// format (a flat list of `{role, message}` entries). +export function loadGrokContext(path: string): GrokMessage[] { + const text = readFileSync(path, "utf-8") + return isYamlPath(path) ? parseYamlTranscript(path, text) : [{role: "system", content: text}] +} + +function isYamlPath(path: string): boolean { + const lower = path.toLowerCase() + return lower.endsWith(".yaml") || lower.endsWith(".yml") +} + +// Parses the harness transcript format. Returns only `system` and +// `assistant` turns; `user` entries are intentionally excluded so they +// don't merge with the customer's runtime message. Malformed YAML, +// unknown roles, or non-string messages throw — operator-supplied +// configuration should fail-fast at startup, not silently degrade. +function parseYamlTranscript(path: string, text: string): GrokMessage[] { + let raw: unknown + try { + raw = parseYaml(text) + } catch (e) { + throw new Error(`${path}: failed to parse YAML: ${(e as Error).message}`) + } + if (raw === null || raw === undefined) return [] + if (!Array.isArray(raw)) { + throw new Error(`${path}: top-level must be a list, got ${typeof raw}`) + } + const context: GrokMessage[] = [] + for (let i = 0; i < raw.length; i++) { + const entry = raw[i] + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`${path}: entry ${i} is not a mapping`) + } + const {role, message} = entry as {role?: unknown; message?: unknown} + if (typeof role !== "string" || !ALLOWED_ROLES.has(role as GrokMessage["role"])) { + throw new Error(`${path}: entry ${i} has invalid role: ${JSON.stringify(role)}`) + } + if (typeof message !== "string") { + throw new Error(`${path}: entry ${i} has non-string message`) + } + if (PREPEND_ROLES.has(role as GrokMessage["role"])) { + context.push({role: role as GrokMessage["role"], content: message}) + } + } + return context +} diff --git a/apps/simplex-support-bot/src/grok.ts b/apps/simplex-support-bot/src/grok.ts new file mode 100644 index 0000000000..967f20902c --- /dev/null +++ b/apps/simplex-support-bot/src/grok.ts @@ -0,0 +1,55 @@ +import {log, logError} from "./util.js" + +export interface GrokMessage { + role: "system" | "user" | "assistant" + content: string +} + +export class GrokApiClient { + private readonly apiKey: string + private readonly initialContext: readonly GrokMessage[] + + constructor(apiKey: string, initialContext: readonly GrokMessage[]) { + this.apiKey = apiKey + this.initialContext = initialContext + } + + async chatRaw(messages: GrokMessage[]): Promise { + const response = await fetch("https://api.x.ai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: "grok-latest", + messages, + temperature: 0.3, + max_tokens: 1024, + }), + signal: AbortSignal.timeout(60_000), + }) + + if (!response.ok) { + const body = await response.text() + logError(`Grok API HTTP ${response.status}`, body) + throw new Error(`Grok API error: HTTP ${response.status}`) + } + + const data = await response.json() as {choices: {message: {content: string}}[]} + const content = data.choices?.[0]?.message?.content + if (!content) throw new Error("Grok API returned empty response") + + log(`Grok API response: ${content.length} chars`) + return content + } + + async chat(history: GrokMessage[], userMessage: string): Promise { + log(`Grok API call: ${this.initialContext.length} context msgs, ${history.length} history msgs, user msg ${userMessage.length} chars`) + return this.chatRaw([ + ...this.initialContext, + ...history, + {role: "user", content: userMessage}, + ]) + } +} diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts new file mode 100644 index 0000000000..c99b1f5842 --- /dev/null +++ b/apps/simplex-support-bot/src/index.ts @@ -0,0 +1,376 @@ +import {readFileSync, writeFileSync, existsSync} from "fs" +import {api, bot, util} from "simplex-chat" +import {T} from "@simplex-chat/types" +import {parseConfig} from "./config.js" +import {SupportBot} from "./bot.js" +import {GrokApiClient, GrokMessage} from "./grok.js" +import {loadGrokContext} from "./context.js" +import {welcomeMessage} from "./messages.js" +import {profileMutex, log, logError, getGroupInfo, getContact} from "./util.js" + +interface BotState { + teamGroupId?: number + grokContactId?: number + grokUserId?: number +} + +function readState(path: string): BotState { + if (!existsSync(path)) return {} + try { return JSON.parse(readFileSync(path, "utf-8")) } catch { return {} } +} + +function writeState(path: string, state: BotState): void { + writeFileSync(path, JSON.stringify(state), "utf-8") +} + +async function main(): Promise { + const config = parseConfig(process.argv.slice(2)) + // Do not log config.db.connectionString — typically contains credentials. + log("Config parsed", { + stateFile: config.stateFile, + backend: config.db.type, + teamGroup: config.teamGroup, + teamMembers: config.teamMembers, + timezone: config.timezone, + completeHours: config.completeHours, + }) + const grokEnabled = config.grokApiKey !== null + if (!grokEnabled) log("No GROK_API_KEY provided, disabling Grok support") + + const stateFilePath = config.stateFile + const state = readState(stateFilePath) + + // Forward-reference for event handlers during init + let supportBot: SupportBot | undefined + + // On restart, the active user may be Grok (if the previous run was killed + // mid-profile-switch). bot.run() uses apiGetActiveUser() and would then + // operate against the Grok userId as if it were the main user. Restore + // the main user as active before bot.run(). Grok is identified by the + // userId persisted in state.json on first resolution — comparing by + // profile name is fragile to renames. + if (state.grokUserId !== undefined) { + const preChat = await api.ChatApi.init(config.db) + try { + const activeUser = await preChat.apiGetActiveUser() + if (activeUser && activeUser.userId === state.grokUserId) { + const users = await preChat.apiListUsers() + const mainCandidates = users.filter(u => u.user.userId !== state.grokUserId) + if (mainCandidates.length === 0) { + throw new Error( + `DB has only the Grok user (userId=${state.grokUserId}); no main user to restore. ` + + `Likely a corrupted migration or partial restore.` + ) + } + if (mainCandidates.length > 1) { + const names = mainCandidates.map(u => `${u.user.userId}:${u.user.profile.displayName}`).join(", ") + throw new Error( + `Ambiguous DB state: multiple non-Grok users [${names}]. ` + + `Refusing to guess which is main — remove extras manually.` + ) + } + const mainUserInfo = mainCandidates[0] + await preChat.apiSetActiveUser(mainUserInfo.user.userId) + log(`Restored active user to ${mainUserInfo.user.profile.displayName} (userId=${mainUserInfo.user.userId})`) + } + } finally { + await preChat.close() + } + } + + // Profile images (base64-encoded JPEG) + const supportImage = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCACAAIADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD6pooooAKKKKACiignAyelABRQCGAIIIPIIooAKKKKACikjdZEDxsGU8gqcg0tAk01dBRRRQMKKKKACiiigAooooAK898ZeKftBew058Qj5ZZR/H7D29+9ehVxHjTwt5++/wBMT9996WFR9/8A2h7+3f69e/LnRVZe1+Xa587xNTxtTBNYP/t627Xl+vVr8c/wf4oNkyWWoPm1PCSH/ln7H/Z/lXo6kMAVIIPIIrwTdiuw8GeKjYsljqDk2h4SQ/8ALP2P+z/KvSzDLua9WkteqPmOGeJHQtg8Y/d+zLt5Py7Pp6bel1wXjHxRv32GmyfJ92WZT97/AGV9vU1H4z8ViTfYaZJ+7+7LMp+9/sqfT1NcOGqMvy61qtVeiNeJuJea+Dwb02lJfkv1Z1PhTxI+lSiC5JeyY8jqYz6j29RXp6MHRWU5VhkGuG8F+F8eXqGpx8/eihYdP9ph/IV3VcWZTpSq/u9+p7fCdDG0cHbFP3X8Ke6X+XZdAooorzj6kKKKKACiikYhVJYgAckmgBTxRXzJ8dPi6dUNx4d8LXGNPGY7u8jP+v8AVEP9z1P8XQcddL4E/F7/AI9/Dfiu49I7K+kbr2Ech/QN+B7Gu95dWVH2tvl1scqxdN1OQ+iaKKK4DqOG8b+FPPEmoaYn7770sKj7/wDtD39u/wBevnAas346/F77X9o8N+FLj/R+Y7y+jb/WdjHGf7vYt36DjJPnvgPxibXy9M1aT/R+FhnY/wCr9FY/3fQ9vp0+ty32qpJVvl3sfnPEmS051HiMItftJfmv1PVN1eheCPCvEeo6mmScNDC36M39BXm+6u18EeLTYMljqTk2h4jkP/LL2P8As/yrTMIVnRfsfn3t5Hh8PPB08ZF4xadOyfn/AF6nqNFIrBlDKQQeQR3pa+OP2IKRHV1DIwZT0IORXn/jjxdt8zTtLk+b7s0ynp6qp/maxPB3il9HmFvdFnsHPI6mM+o9vUV6cMqrTo+169F5HzNfinCUcYsM9Y7OXRP/AC7voeuUU2KRZY0kjIZGAZSO4NOrzD6VO+qCkZQylWAKkYIPelooGfMHxz+EZ0Zp/EPheAnTDl7q0jH/AB7eroP7nqP4fp08Lr9EmUMpVgCDwQa+Yfjn8Im0dp/EPhe3LaaSXurOMZNue7oP7nqP4fp09/L8w5rUqr16M8vF4S3vwNb4FfF7/j38N+K7jniOyvpG69hHIT+QY/Q9jVb47fF03RufDfhS4xbjMd7exn/WdjHGf7vYt36DjJPz/RXZ/Z9H23tbfLpfuc/1up7PkE6D0FfRnwK+EOw2/iTxXb/PxJZ2Mi/d7iSQevcL26nnAB8C/hD5Zt/Efiy3xJxJZ2Mq/d7iSQHv3C9up5wB9D1wZhmG9Kk/VnVhMJ9uZwPjvwj9o8zUtKj/AH33poVH3/8AaX39R3+vXzLdX0XXn3j3wd9o8zUtJj/f/emgUff/ANpR6+o7/XrpleZ2tRrPTo/0Z8xxFw5z3xeEWvVd/NfqjL8DeLzp7JYam5NmTiOQ/wDLL2P+z/KtDx14xAD6dpEuT0mnQ9P9lT/M15nu5pd1etLLKMq3tmvl0v3Pm4Z9jIYP6mpad+qXYn3V6D4E8ImXy9S1WP8Ad/ehgYfe9GYenoKj8A+EPOEWp6tH+74aCBh970Zh6eg716ZXl5nmVr0aL9X+iPe4d4cvbF4tecY/q/0QUUUV86ffhRRRQAV82/HX4vfa/tHhvwpcf6NzHeX0bf6zsY4z/d7Fu/QcZJPjr8XvtRuPDfhS4/0fmO8vo2/1nYxxkfw9i3foOMk/P/8AKvdy/L7Wq1V6I8zF4v7EBOn0pa+i/gX8INot/Efiy2+fiSzsZV+76SSA9/RT06nnAGP8dPhGdHa48Q+F4CdMJL3Vogybc93Qf3PUfw/Tp3rH0XV9lf59L9jleFqKn7Q1vgV8Xjm38N+LLnJ4js76VuvYRyE/kGP0PY19E1+dlfRXwJ+L3Nv4b8V3HPEdlfSN17COQn8g34Hsa8/MMv3q0l6o68Ji/sTPomvNfiB412mTS9Hl+blZ7hT09VU+vqaj+InjfYZdK0eX5uVnuFPT1VT6+p/CvMN1dOVZTe1euvRfqz5riDP98LhX6v8ARfqybdS7q9E+HngszeVqmsRfu+Ggt2H3vRmHp6DvVz4heC/tAk1PR4v3/wB6aBR9/wD2lHr6jv8AXr6TzTDqv7C/z6X7Hgx4dxcsJ9aS/wC3etu//AMrwD4zOnMmn6pITZE4jlY5MXsf9n+X0r1pWDKGUgqRkEd6+Zd2K7z4f+NDprR6dqrk2JOI5T/yx9j/ALP8vpXFmuU8961Ba9V3815/mevw/n7o2wuKfu9H28n5fl6bev0UisGUMpBUjII70tfKn3wVHdQRXVtLb3CCSGVCjoejKRgg/hUlFAHx98Z/hbceCrttQ0tXm8PTNhWPLWrHojn09G/A89e7+BXwh8v7P4k8V2/z8SWdjIv3e4kkB79wvbqecAfQc0Mc8TRzRpJG3VXUEH8DT69GeZVZ0vZ9e5yRwcI1Of8AAKRlDKVYAg8EGlorzjrPmD45/CM6O0/iHwvATphJe6tIx/x7+roP7nqP4fp04Hwh4aB2X+pR8feihYdf9ph/IV9EfErx2B52kaLKCeUuLhT09UU/zP4V5Tur7jKaFaVFTxHy728z4LPcxgpujhX6v9F+pPur074c+CDN5Wq6zF+64aC3cfe9GYenoO9eV7q9d+G/joXXlaVrUv8ApHCwXDH/AFnorH+96Hv9eumb/WI4duh8+9vI87IaeFeKX1n5dr+f6HptFFFfBn6ceb/ETwT9pEuqaNH/AKR96eBR/rPVlH971Hf69c34d+CTdmPU9ZiIth80MDj/AFn+0w/u+g7/AE6+tUV6kc2rxw/sE/n1t2PEnkGEnivrTXy6X7/8AAAAABgCiiivLPbCiiigAooooAK8n+Jnj7YZdI0OX5uUuLlD09UU+vqfwFerSossbxuMowKkeoNeBfETwTL4cuDd2QaTSpG4PUwk/wALe3ofwPPX2sjpYepiLVnr0XRv+uh4Wf1cTTw37hadX1S/rdnG7q9U+GngPzxFq2uRfueGt7Zx9/0dh6eg79TTPhj4B87ytY1yL91w9vbOPv8Ao7D09B36mvYK9POc4tfD4d+r/RHlZJkV7YnEr0X6v/I8U+JPgZtKaTVNIjLaeTuliXkwH1H+z/L6V52GxX1c6q6lWAKkYIIyDXiXxL8CNpLSapo8ZbTyd0sK9YPcf7P8vpV5PnHtLYfEPXo+/k/P8/XfLO8i9nfE4ZadV2815fl6bb/w18eC68rSdbl/0j7sFw5/1norH+96Hv8AXr6fXjXwy8Bm9MWr61ERajDQW7D/AFvozD+76Dv9OvsteLnMcPHENYf59r+R72RyxMsMnifl3t5/oFFFFeSeyFFFFABRRRQAUUUUAFMmijmjaOZFkjYYZXGQR7in0UJ2Bq+4UUUUAFIyh1KsAVIwQRwaWigAAAAAGAKKKKACiiigAooooA//2Q==" + const grokImage = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gKgSUNDX1BST0ZJTEUAAQEAAAKQbGNtcwQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwQVBQTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWxjbXMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkZXNjAAABCAAAADhjcHJ0AAABQAAAAE53dHB0AAABkAAAABRjaGFkAAABpAAAACxyWFlaAAAB0AAAABRiWFlaAAAB5AAAABRnWFlaAAAB+AAAABRyVFJDAAACDAAAACBnVFJDAAACLAAAACBiVFJDAAACTAAAACBjaHJtAAACbAAAACRtbHVjAAAAAAAAAAEAAAAMZW5VUwAAABwAAAAcAHMAUgBHAEIAIABiAHUAaQBsAHQALQBpAG4AAG1sdWMAAAAAAAAAAQAAAAxlblVTAAAAMgAAABwATgBvACAAYwBvAHAAeQByAGkAZwBoAHQALAAgAHUAcwBlACAAZgByAGUAZQBsAHkAAAAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDEoAAAXj///zKgAAB5sAAP2H///7ov///aMAAAPYAADAlFhZWiAAAAAAAABvlAAAOO4AAAOQWFlaIAAAAAAAACSdAAAPgwAAtr5YWVogAAAAAAAAYqUAALeQAAAY3nBhcmEAAAAAAAMAAAACZmYAAPKnAAANWQAAE9AAAApbcGFyYQAAAAAAAwAAAAJmZgAA8qcAAA1ZAAAT0AAACltwYXJhAAAAAAADAAAAAmZmAADypwAADVkAABPQAAAKW2Nocm0AAAAAAAMAAAAAo9cAAFR7AABMzQAAmZoAACZmAAAPXP/bAEMABQMEBAQDBQQEBAUFBQYHDAgHBwcHDwsLCQwRDxISEQ8RERMWHBcTFBoVEREYIRgaHR0fHx8TFyIkIh4kHB4fHv/bAEMBBQUFBwYHDggIDh4UERQeHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHv/AABEIAIAAgAMBIgACEQEDEQH/xAAdAAAABgMBAAAAAAAAAAAAAAAAAQQHCAkCAwUG/8QAOxAAAQIEBAQDBgUCBgMAAAAAAQIDAAQFEQYHITEIEkFRE2FxFCJCgZGhFTJSgrEJIzNDYnKS4VOy8P/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABsRAQEBAAMBAQAAAAAAAAAAAAABEQISIRMx/9oADAMBAAIRAxEAPwCINoAv3gCD3BjTIhpprA3gdTB2F9NoAE9oGm194IWMAQB/WB3gD7QNIAbwBtvA2PnBjtAF08oHe/ygDUwfTf6QA6wRsNYMX84NKSYDEHXYQY1jc5Kvty7Uw42pDTpUG1H4+U2JHcA6X76RpSn7bwBQB1gesADXvAD0gAaX0gDY2gWgCEGBcHSM2m1LNrR7LLrLvE+NqsKbhukTE++LFwpHK20D8S1n3Uj1OvS8B45DK1dI3JknSNEk/KJoZfcH0gyy3MY1xA666RdUpTEhKU+RdWCT8kiHbpHD3lHTmUtpwhLTShu5NvOPKPrdVvtE1cVrGReA/IfpGlcstF7pMWeTOReUswjkXgWkpHdtKmz9UkGPBYx4Tcv6o04uhTdSoUwfyAOe0Mj9q/e+ihDTFfikLSSCLQBa58+gh684uH7G+X8u7UH5NFVpCNTPyIKktju4gjmR66p84Z1EupblgmKjUy2pZ01hy8s8u2qjQ6hjjFSn5HBtII9odQeV2fe+GVl7/Go2BVskEnfb1vDVkXPZj1NNRqQeksMyrlpmYAsqYUN2mj3/AFK+H1hfxfYzkZrEUtl7hppqTwzhYezty7As2qZtZZ035fyi/XnPWIpjsW1h+t1l6oOsMSyFAIYlWE2almU6IabHRKRp3JuTckmOMBvGx1dyTbaMQSTfYHvFRqG0DWDtpA9IAvWM2Wys2jG149blphSoYuxbTcPUxF5uffS0gkXCBupZ8kpBUfIQHvuHHJepZmVwqWpySoUmoe2zoTrffwm76FZHySNT0BsBwVhSg4PoLFEw9TmZGTZGiUD3lq6qWrdSj1J1jXl9hSk4KwlIYcozIblZRvl5iPedWdVOK7qUbk/9Q2PExnhL5cyP4LRCzM4lmW+YBfvIk0G9nFjqo/Cn5nTfLRyscY6wngqRE3iauSlOSr/DQtV3HP8AagXUr5CGSxDxd4QlHy3RsPVapJH+Y84iXSfQe8r6gRC7FmKarXqs/U6tPzE9Ovqu4++sqWryv0HkNB0jgLmnCd4uJqbklxi0lbwTN4Km22ydVM1BCyB6FI/mPZJ4psrjQXKh49VTNIICZBUmfGWSOir8lvMqiu9Mw4NeYwol3XlmwJ1hhqRuanE7jTFCXqfh0Iw3TnAUnwD4kytJ6KcIsn9oHqY5PDdkfPZjVb8TqSXZTDUs5aZmBoqYUN2mj3/Ur4fWC4ackqjmNVBUakHpTDMq5aYmALKmFDdpo9/1K+H1ifFEpdPotJlqVSpRmTkpVsNsMtJslCR0H/2u8BwcUTNMy+yvqU1S5RiSkqLTXFyzDSLITyJPIkDzVb1vFXdemnpibdefcLjziytxZ3Uom5J9STFknFEVjIPFhRv7IkfLxUX+0Vp1TV5R13hAh6awBB2Fu5gCxiow+UEN9TB9ILQ9TAZsJ5nABEwv6fuEW3alXMYzDXMZRCZGVKhstfvOEefKED9xiIMgLvA+cWJ8E1PTJZEST4A5p2dmX1H0X4Y+yBEWHQzAxJKYQwVVsSzo5mafLKd5P1q2Sj9yiB84q+x7iSpYhxDP1mqTBfnZ19Tzyyd1HoOwAsAOgAibXHfWVyOVUhS21lJqNSQF2P5kNoUu3/Ll+kQEnlFTh1trCBOpRUreCFzA0vGxlsrVFRlLsqcVoIffhmyOn8xqt+IVAOyeGZRwCZmQLKfUP8lo9+6tkjzsI0cNGSU9mPVfxGoeJI4YknLTc2PdU8oalponrb8ytkjzsIezOLiFw7gajpwRlSxJKXJN+zicaSFS0oBpytDZ1d/iPu31PMbxFOxmJmPgXJvDMrSkNMpeaZCJCjydgvkGxP6E33Urc33MYZC5wyGaDFRaFONMqEhyKWx43ihbargLSbA6EWII007xXRX8QVGr1R+oVCcfnJyYWVvPvLKlrV3JO8PfwRVp6TzrkZXnIbqMpMSyx3sjxB92/vATMzmpBruVGKKUnVb9Lf8ADFt1hBUn7gRVxVB/cJI31EW5OJSttSFpCkqFiD1BiqPH0imn4lqkgkWTLTjzIHklxSR/EIPNA2Gt4I6bWN/tAG+kFtFQUA2gDaAOusAokD/eGoixrgym0TOQdJbQdZaYmWVeR8ZSv4UIrhl1crmneJt/0/sToeolfwk64A4y8ifl090qAQ59ClH/ACiLHT4/pB17L+gVBAJblqkptZ7c7Srf+kQSmxZ0xaTnrg046yurGH2kpM4toPSZV0fbPMj625f3RWPXZF6Vm3WnmlNOtqKVoULFKgbEEdCDpCDlNpufKHSyay6k68y9inGFSFBwTTnOWcn1aLmXN/ZpcbrcPWwPKPOwhvaEKaidS9Vg+5Kt+8phhXKt/sgK1CAeqrEgbAm0dfF+MKriVyWTOKal5GRb8Gn0+VSUSsk3+ltF9L7lRupR1USYqHSzgz2ma7R0YMwRJHDGC5VsMMybJ5XphA/8pB0B35AdSTzFRhjn5lTht0jUtalExjy7xFBIJVoYf3gqkXZvPOjLSklEozMPuEdAGlJH3WIYiTaUt0C0TZ4CsDOyNKqmN5xko9sAkpEkfmbSq7ix5FQSn9pgRKTpFVGZs0icxlW5ps3S/UZhxNuxdUR/MWW5vYkawllnX6+4vlXKyS/B11Lqhytj5qUmKtas4Vum6iT1N94Qc/vbeBt5QQMH6jSKgbwAL6wYFxeM0pBMASLhUPtwc1GYkM8aCGlK5ZrxZZ1I+JCmlHX5pSflDIMNErAF4k7wMYOfqWYLuKHGiJKisKCVkaKmHU8qU/JJUfp3gqb3w6xBHjepuCmMw/aMPTgNbfuqsSrSQWm19FlXRxXxJ9CbE6uxxH8QjVITM4VwLNpcnxducqbZBTL9Cho7FfdWyelztFGh0Ku4vrPsVIp07VZ51XMpDKC4o3OqlHprupR+cJDXiCyq5sD6wXgq1uDEucAcI9TnZMzOMK0ikrWj+3KyaA84k9OdR93Tsm/qIS17hExQw6s0au0ifav7vjhbC/mLKH3i+CKCWVfpjexKLWqwB17RJaQ4T8fuupS/MUKWRfVaptavsEQ6OX3ClhulvtzeLKq7WlpIPsrCCwxfso3K1D5pieCPPD3kvWMw662stuytCl3B7bPFNhbq23+pZ+idz0BsHodLkKJRpSk0yWblZKTZSyw0gWCEJFgP++sZ0qnSFJpzMhTZRiTlGEcjTLKAhCE9gBoIZ7iPzukMB0t+iUN9qaxM8jlABCkyQI/Ov/V+lHzOm8/Q1HHPmS1NzjGAKZMBbMksTFSUg3BeseRr9oJUfMjtEQ5lXOsnXeOxXJ5+em3pmYeceedWVuOOKupaibkk9STrHHKd4uDRbvpBRmU77aQQGveCM0J1jey2VaRggXjuYWos3Wqo1T5INBxV1LceXyNMtpF1OOKOiUJGpJ+5sDYOvlpgyr4zxLL0SjtJLzl1uvOaNS7Q/M64rolI+uw1MPLmDmnS8K4MRlflXNOIpTAUmpVpPuu1B0/4hQRsknQq6iwHui5b+v4skKVhp3BWCFuJpT1jVKmpBQ/WHB3G7cuPhb3O6tTYeHJKusWQ0TzqnDoIczJHN3E2W77jNMUxN0uYc55iQmE+4s7cyVD3kqt11HcGG0Si+to2oBTtGsY1PfA3EVl/X2EIqU07QJsgczc6Lt38nU6W9eWHRpldotTbDlNq8hOIOymJlDgP0MVgMzDiNlfO8KWZ9aNUmx7jQxn5tTktAfmpZhBW/MMtJHVawkfePG4rzay+w02v8QxPIuPJF/AlV+O6fLlRe3ztFerlTfcFlrUr/cSf5hM5NrUCL2HlDod0jc2OJ2q1Jl6m4LlnKRLKukzrpBmVD/SBdLfrqfSIzVWcenHnHnnVuuOKKlrWoqUpROpJOpJ7mMnFKXurbaNC081o1OOJ2c5xF7nWE6kWBjprb3hM63oTa0TFlc1aI1KBF4WuotftCVabEiM2K2N6bWjqSc0+1KuyrbikNPlJdSk25+U3APcA622vr0Ecxu28KmCBbpFgXNnmNjG9sX1hKyrvClCtI3GK3oAAt1gwBGKVXg0nS14qMgB1+0HbcQQMAGwMAYAtqNYHKTrGN/rBBWkAZA20jEp3tB30OojFStb94DW4nS94SuiFKyLbwldVeIsJHQLGEi0+kKnlXuN4SOG+/wBoxW4//9k=" + + const desiredCommands: T.ChatBotCommand[] = [ + ...(grokEnabled ? [{type: "command" as const, keyword: "grok", label: "Ask Grok"}] : []), + {type: "command", keyword: "team", label: "Switch to team"}, + ] + + // Step 1: Init main bot via bot.run() + log("Initializing main bot...") + const [chat, mainUser, mainAddress] = await bot.run({ + profile: {displayName: "Ask SimpleX Team", fullName: "", image: supportImage}, + dbOpts: config.db, + options: { + addressSettings: { + businessAddress: true, + autoAccept: true, + welcomeMessage, + }, + commands: desiredCommands, + useBotProfile: true, + updateProfile: false, + }, + events: { + acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), + newChatItems: (evt) => supportBot?.onNewChatItems(evt), + chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt), + chatItemReaction: (evt) => supportBot?.onChatItemReaction(evt), + leftMember: (evt) => supportBot?.onLeftMember(evt), + joinedGroupMember: (evt) => supportBot?.onJoinedGroupMember(evt), + connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), + newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), + contactConnected: (evt) => supportBot?.onContactConnected(evt), + contactSndReady: (evt) => supportBot?.onContactSndReady(evt), + }, + }) + log(`Main bot user: ${mainUser.profile.displayName} (userId=${mainUser.userId})`) + + // Step 2: Resolve Grok profile from same ChatApi instance. + // Identify Grok strictly by the persisted userId in state.json. If no ID + // is persisted, this is a first-time run — create the user and persist. + let grokUser: T.User | null = null + if (grokEnabled) { + log("Resolving Grok profile...") + if (state.grokUserId !== undefined) { + const users = await chat.apiListUsers() + grokUser = users.find(u => u.user.userId === state.grokUserId)?.user ?? null + if (!grokUser) { + throw new Error( + `Persisted Grok userId=${state.grokUserId} not found in DB. ` + + `Either restore the user or delete state.json to re-create Grok.` + ) + } + } else { + log("Creating Grok profile...") + grokUser = await chat.apiCreateActiveUser({displayName: "Grok", fullName: "", image: grokImage}) + // apiCreateActiveUser sets Grok as active — switch back to main + await chat.apiSetActiveUser(mainUser.userId) + state.grokUserId = grokUser.userId + writeState(stateFilePath, state) + log(`Persisted Grok userId=${grokUser.userId}`) + } + + // Refresh Grok's profile if it has drifted from the canonical values. + const grokProfile: T.Profile = {displayName: "Grok", fullName: "", image: grokImage} + const currentProfile = util.fromLocalProfile(grokUser.profile) + if (currentProfile.image !== grokProfile.image || currentProfile.displayName !== grokProfile.displayName || currentProfile.fullName !== grokProfile.fullName) { + log("Grok profile changed, updating...") + await chat.apiSetActiveUser(grokUser.userId) + const summary = await chat.apiUpdateProfile(grokUser.userId, grokProfile) + await chat.apiSetActiveUser(mainUser.userId) + if (summary) { + log(`Grok profile updated: ${summary.updateSuccesses} contact(s) updated, ${summary.updateFailures} failed`) + } else { + log("Unexpected: Grok profile did not change") + } + } + log(`Grok profile: ${grokUser.profile.displayName} (userId=${grokUser.userId})`) + } + + // Step 3: Read state file + // Step 4: Enable auto-accept DM contacts + await chat.apiSetAutoAcceptMemberContacts(mainUser.userId, true) + log("Auto-accept member contacts enabled") + + // Step 5: Resolve Grok contact by ID. Avoid apiListContacts — it loads + // every contact in one response and OOMs the native binding on large DBs. + // Always restore grokContactId so the one-way gate can find and remove + // Grok members even when Grok API is disabled. + if (typeof state.grokContactId === "number") { + const found = await getContact(chat, state.grokContactId) + if (found) { + config.grokContactId = found.contactId + log(`Grok contact from state: ID=${config.grokContactId}`) + } else { + log(`Persisted Grok contact ID=${state.grokContactId} not found`) + } + } + + if (grokEnabled) { + if (config.grokContactId === null) { + log("Establishing bot↔Grok contact...") + const invLink = await chat.apiCreateLink(mainUser.userId) + // Switch to Grok profile to connect + await profileMutex.runExclusive(async () => { + await chat.apiSetActiveUser(grokUser!.userId) + await chat.apiConnectActiveUser(invLink) + await chat.apiSetActiveUser(mainUser.userId) + }) + log("Grok connecting...") + + const grokProfileName = grokUser!.profile.displayName + const evt = await chat.wait( + "contactConnected", + (e) => + e.user.userId === mainUser.userId + && e.contact.profile.displayName === grokProfileName, + 60_000, + ) + if (!evt) { + console.error(`Timeout waiting for Grok contact (60s, displayName="${grokProfileName}"). Exiting.`) + process.exit(1) + } + config.grokContactId = evt.contact.contactId + state.grokContactId = config.grokContactId + writeState(stateFilePath, state) + log(`Grok contact established: ID=${config.grokContactId}`) + } + } + + // Step 6: Resolve team group by ID. Avoid apiListGroups — it loads every + // group in one response and OOMs the native binding on large DBs. + log("Resolving team group...") + let existingGroup: T.GroupInfo | null = null + + if (typeof state.teamGroupId === "number") { + existingGroup = await getGroupInfo(chat, state.teamGroupId) + if (existingGroup) { + config.teamGroup.id = existingGroup.groupId + log(`Team group from state: ${config.teamGroup.id}:${existingGroup.groupProfile.displayName}`) + } else { + log(`Persisted team group ID=${state.teamGroupId} not found, will create`) + } + } + + const teamGroupPreferences: T.GroupPreferences = { + directMessages: {enable: T.GroupFeatureEnabled.On}, + fullDelete: {enable: T.GroupFeatureEnabled.On}, + commands: [ + {type: "command", keyword: "join", label: "Join customer chat", params: "groupId"}, + ], + } + + if (config.teamGroup.id === 0) { + log(`Creating team group "${config.teamGroup.name}"...`) + const newGroup = await chat.apiNewGroup(mainUser.userId, { + displayName: config.teamGroup.name, + fullName: "", + groupPreferences: teamGroupPreferences, + }) + config.teamGroup.id = newGroup.groupId + state.teamGroupId = config.teamGroup.id + writeState(stateFilePath, state) + log(`Team group created: ${config.teamGroup.id}:${config.teamGroup.name}`) + } else if (existingGroup) { + // Only update profile if preferences or name changed + const prefs = existingGroup.fullGroupPreferences + const needsUpdate = + existingGroup.groupProfile.displayName !== config.teamGroup.name || + prefs.directMessages?.enable !== T.GroupFeatureEnabled.On || + prefs.fullDelete?.enable !== T.GroupFeatureEnabled.On || + JSON.stringify(prefs.commands) !== JSON.stringify(teamGroupPreferences.commands) + if (needsUpdate) { + await chat.apiUpdateGroupProfile(config.teamGroup.id, { + displayName: config.teamGroup.name, + fullName: "", + groupPreferences: teamGroupPreferences, + }) + log("Team group profile updated") + } + } + + // Step 7: Ensure direct messages enabled (done via groupPreferences above) + + // Step 8: Create team group invite link (best-effort — bot works without it) + let inviteLinkCreated = false + try { + try { await chat.apiDeleteGroupLink(config.teamGroup.id) } catch {} + const teamGroupInviteLink = await chat.apiCreateGroupLink( + config.teamGroup.id, T.GroupMemberRole.Member + ) + inviteLinkCreated = true + log("Team group invite link created") + console.log(`\nTeam group invite link (expires in 10 min):\n${teamGroupInviteLink}\n`) + } catch (err) { + logError("Failed to create team group invite link (SMP relay may be unreachable). Bot will continue without it.", err) + } + + let inviteLinkDeleted = false + async function deleteInviteLink(): Promise { + if (inviteLinkDeleted) return + inviteLinkDeleted = true + try { + await profileMutex.runExclusive(async () => { + await chat.apiSetActiveUser(mainUser.userId) + await chat.apiDeleteGroupLink(config.teamGroup.id) + }) + log("Team group invite link deleted") + } catch (err) { + logError("Failed to delete invite link", err) + } + } + let inviteLinkTimer: ReturnType | undefined + if (inviteLinkCreated) { + inviteLinkTimer = setTimeout(async () => { + log("10 minutes elapsed, deleting invite link...") + await deleteInviteLink() + }, 10 * 60 * 1000) + inviteLinkTimer.unref() + } + + // Step 9: Validate team members (lookup by ID, one round-trip per member) + if (config.teamMembers.length > 0) { + log("Validating team members...") + for (const member of config.teamMembers) { + const contact = await getContact(chat, member.id) + if (!contact) { + console.error(`Team member not found: ID=${member.id}`) + process.exit(1) + } + if (contact.profile.displayName !== member.name) { + console.error(`Team member name mismatch: expected "${member.name}", got "${contact.profile.displayName}" (ID=${member.id})`) + process.exit(1) + } + log(`Team member validated: ${member.id}:${member.name}`) + } + } + + // Load Grok context and build API client only if enabled + let grokApi: GrokApiClient | null = null + if (grokEnabled) { + let initialContext: GrokMessage[] = [] + if (config.contextFile) { + try { + initialContext = loadGrokContext(config.contextFile) + log(`Loaded Grok context: ${initialContext.length} message(s) from ${config.contextFile}`) + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === "ENOENT") { + log(`Warning: context file not found: ${config.contextFile}`) + } else { + logError(`Failed to load Grok context file ${config.contextFile}`, err) + throw err + } + } + } + grokApi = new GrokApiClient(config.grokApiKey!, initialContext) + } + + // Create SupportBot + supportBot = new SupportBot(chat, grokApi, config, mainUser.userId, grokUser?.userId ?? null, desiredCommands) + + if (mainAddress) { + supportBot.businessAddress = util.contactAddressStr(mainAddress.connLinkContact) + log(`Business address: ${supportBot.businessAddress}`) + } + + // Step 10: Register Grok event handlers (filtered by profile in handler) + if (grokEnabled) { + chat.on("receivedGroupInvitation", (evt) => supportBot?.onGrokGroupInvitation(evt)) + chat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected(evt)) + chat.on("newChatItems", (evt) => supportBot?.onGrokNewChatItems(evt)) + } + + // Step 10b: Refresh stale cards from before restart + await supportBot.cards.refreshAllCards() + + log("SupportBot initialized. Bot running.") + + // Step 11: Graceful shutdown + async function shutdown(signal: string): Promise { + log(`Received ${signal}, shutting down...`) + clearTimeout(inviteLinkTimer) + supportBot?.cards.destroy() + await deleteInviteLink() + process.exit(0) + } + process.on("SIGINT", () => shutdown("SIGINT")) + process.on("SIGTERM", () => shutdown("SIGTERM")) +} + +main().catch(err => { + logError("Fatal error", err) + process.exit(1) +}) diff --git a/apps/simplex-support-bot/src/messages.ts b/apps/simplex-support-bot/src/messages.ts new file mode 100644 index 0000000000..c35789d26b --- /dev/null +++ b/apps/simplex-support-bot/src/messages.ts @@ -0,0 +1,44 @@ +import {isWeekend} from "./util.js" + +export const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI. +*Join public groups* at https://simplex.chat/directory or [via directory bot](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) +Please ask any questions about SimpleX Chat.` + +export function queueMessage(timezone: string, grokEnabled: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `The team will reply to your message within ${hours} hours.` + if (!grokEnabled) return base + return `${base} + +If your question is about SimpleX, click /grok for an *instant Grok answer*. + +Send /team to switch back.` +} + +export const grokActivatedMessage = `*You are now chatting with Grok* - use any language.` + +export function teamAddedMessage(timezone: string, grokPresent: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `We will reply within ${hours} hours.` + if (!grokPresent) return base + return `${base} +Grok will be answering your questions until then.` +} + +export const teamAlreadyInvitedMessage = "A team member was invited to this conversation and will reply when available." + +export const teamLockedMessage = "Only the team will now receive your messages." + +export function noTeamMembersMessage(grokEnabled: boolean): string { + return grokEnabled + ? "No team members are available yet. Please try again later or click /grok." + : "No team members are available yet. Please try again later." +} + +export const grokInvitingMessage = "Inviting Grok, please wait..." + +export const grokUnavailableMessage = "Grok is temporarily unavailable. Please try again later or send /team for a human team member." + +export const grokErrorMessage = "Sorry, I couldn't process that. Please try again or send /team for a human team member." + +export const grokNoHistoryMessage = "I just joined but couldn't see your earlier messages. Could you repeat your question?" diff --git a/apps/simplex-support-bot/src/util.ts b/apps/simplex-support-bot/src/util.ts new file mode 100644 index 0000000000..f9a2319610 --- /dev/null +++ b/apps/simplex-support-bot/src/util.ts @@ -0,0 +1,51 @@ +import {Mutex} from "async-mutex" +import {api, core} from "simplex-chat" +import {T} from "@simplex-chat/types" + +export const profileMutex = new Mutex() + +export function isChatNotFound(err: unknown, kind: "group" | "contact"): boolean { + if (!(err instanceof core.ChatAPIError)) return false + if (err.chatError?.type !== "errorStore") return false + const seType = err.chatError.storeError.type + return kind === "group" ? seType === "groupNotFound" : seType === "contactNotFound" +} + +export async function getGroupInfo(chat: api.ChatApi, groupId: number): Promise { + try { + const c = await chat.apiGetChat(T.ChatType.Group, groupId, 0) + return c.chatInfo.type === "group" ? c.chatInfo.groupInfo : null + } catch (err) { + if (isChatNotFound(err, "group")) return null + throw err + } +} + +export async function getContact(chat: api.ChatApi, contactId: number): Promise { + try { + const c = await chat.apiGetChat(T.ChatType.Direct, contactId, 0) + return c.chatInfo.type === "direct" ? c.chatInfo.contact : null + } catch (err) { + if (isChatNotFound(err, "contact")) return null + throw err + } +} + +export function isWeekend(timezone: string): boolean { + const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date()) + return day === "Sat" || day === "Sun" +} + +export function log(msg: string, ...args: unknown[]): void { + const ts = new Date().toISOString() + if (args.length > 0) { + console.log(`[${ts}] ${msg}`, ...args) + } else { + console.log(`[${ts}] ${msg}`) + } +} + +export function logError(msg: string, err: unknown): void { + const ts = new Date().toISOString() + console.error(`[${ts}] ERROR: ${msg}`, err) +} diff --git a/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js b/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js new file mode 100644 index 0000000000..29fc3d01a4 --- /dev/null +++ b/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js @@ -0,0 +1,12 @@ +// Mock for @simplex-chat/types — lightweight stubs + +const ChatType = {Direct: "direct", Group: "group", Local: "local"} +const GroupMemberRole = {Member: "member", Owner: "owner", Admin: "admin", Relay: "relay", Observer: "observer", Author: "author", Moderator: "moderator"} +const GroupMemberStatus = {Connected: "connected", Complete: "complete", Announced: "announced", Left: "left", Removed: "removed", Invited: "invited"} +const GroupFeatureEnabled = {On: "on", Off: "off"} +const CIDeleteMode = {Broadcast: "broadcast", Internal: "internal"} + +module.exports = { + T: {ChatType, GroupMemberRole, GroupMemberStatus, GroupFeatureEnabled, CIDeleteMode}, + CEvt: {}, +} diff --git a/apps/simplex-support-bot/test/__mocks__/simplex-chat.js b/apps/simplex-support-bot/test/__mocks__/simplex-chat.js new file mode 100644 index 0000000000..92e05a4178 --- /dev/null +++ b/apps/simplex-support-bot/test/__mocks__/simplex-chat.js @@ -0,0 +1,36 @@ +// Mock for simplex-chat — prevents native addon from loading + +function ciContentText(chatItem) { + const c = chatItem.content + if (c.type === "sndMsgContent" || c.type === "rcvMsgContent") return c.msgContent.text + return undefined +} + +function ciBotCommand(chatItem) { + const text = ciContentText(chatItem)?.trim() + if (text) { + const r = text.match(/^\/([^\s]+)(.*)/) + if (r && r.length >= 3) return {keyword: r[1], params: r[2].trim()} + } + return undefined +} + +function contactAddressStr(link) { + return link.connShortLink || link.connFullLink +} + +// Mirrors core.ChatAPIError so isChatNotFound's instanceof check passes when +// MockChatApi throws. Tests should construct these directly. +class ChatAPIError extends Error { + constructor(message, chatError) { + super(message) + this.chatError = chatError + } +} + +module.exports = { + api: {ChatApi: {}}, + bot: {}, + core: {ChatAPIError}, + util: {ciContentText, ciBotCommand, contactAddressStr}, +} diff --git a/apps/simplex-support-bot/tsconfig.json b/apps/simplex-support-bot/tsconfig.json new file mode 100644 index 0000000000..821fa663e3 --- /dev/null +++ b/apps/simplex-support-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "module": "Node16", + "moduleResolution": "Node16", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2022", + "types": ["node"] + } +} diff --git a/apps/simplex-support-bot/vitest.config.ts b/apps/simplex-support-bot/vitest.config.ts new file mode 100644 index 0000000000..c143572a87 --- /dev/null +++ b/apps/simplex-support-bot/vitest.config.ts @@ -0,0 +1,22 @@ +import {defineConfig} from "vitest/config" +import path from "path" + +export default defineConfig({ + test: { + globals: true, + testTimeout: 10000, + // Clear backend signals — .npmrc next to package.json otherwise injects + // npm_config_simplex_backend into every test's env, breaking sqlite-default + // assumptions in parseConfig tests. + env: { + SIMPLEX_BACKEND: "", + npm_config_simplex_backend: "", + }, + }, + resolve: { + alias: { + "simplex-chat": path.resolve(__dirname, "test/__mocks__/simplex-chat.js"), + "@simplex-chat/types": path.resolve(__dirname, "test/__mocks__/simplex-chat-types.js"), + }, + }, +}) diff --git a/assets/ASSETS_LICENSE.md b/assets/ASSETS_LICENSE.md new file mode 100644 index 0000000000..36ac54d04f --- /dev/null +++ b/assets/ASSETS_LICENSE.md @@ -0,0 +1,18 @@ +# Application Graphic Assets License + +Copyright (C) 2026 SimpleX Chat Ltd. All rights reserved. + +The graphic assets in this folder, subfolders and in other folders of this repository - including illustrations, images, icons, and visual designs - are proprietary and are not licensed under the AGPLv3 that covers the application source code. + +## Permitted use + +- Unmodified application distribution. You may use these assets as part of the SimpleX Chat application, provided the application is not modified in any way. +- Publications with permission. You may use screenshots containing these assets in publications with prior written permission from SimpleX Chat Ltd. + +## Not permitted + +All other use, including modification, redistribution, or incorporation into other works, is not permitted without prior written permission from SimpleX Chat Ltd. + +## Contact + +To request permission, contact chat@simplex.chat. diff --git a/assets/multiplatform/resources/MR/images/banner_create_link@2x.png b/assets/multiplatform/resources/MR/images/banner_create_link@2x.png new file mode 100644 index 0000000000..6f768932ad Binary files /dev/null and b/assets/multiplatform/resources/MR/images/banner_create_link@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/banner_create_link@3x.png b/assets/multiplatform/resources/MR/images/banner_create_link@3x.png new file mode 100644 index 0000000000..8fe3b9c035 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/banner_create_link@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/banner_create_link_light@2x.png b/assets/multiplatform/resources/MR/images/banner_create_link_light@2x.png new file mode 100644 index 0000000000..c7bfca381a Binary files /dev/null and b/assets/multiplatform/resources/MR/images/banner_create_link_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/banner_create_link_light@3x.png b/assets/multiplatform/resources/MR/images/banner_create_link_light@3x.png new file mode 100644 index 0000000000..a25f96d596 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/banner_create_link_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/banner_paste_link@2x.png b/assets/multiplatform/resources/MR/images/banner_paste_link@2x.png new file mode 100644 index 0000000000..44feaf8845 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/banner_paste_link@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/banner_paste_link@3x.png b/assets/multiplatform/resources/MR/images/banner_paste_link@3x.png new file mode 100644 index 0000000000..4d1a6e2fda Binary files /dev/null and b/assets/multiplatform/resources/MR/images/banner_paste_link@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/banner_paste_link_light@2x.png b/assets/multiplatform/resources/MR/images/banner_paste_link_light@2x.png new file mode 100644 index 0000000000..c34e988886 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/banner_paste_link_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/banner_paste_link_light@3x.png b/assets/multiplatform/resources/MR/images/banner_paste_link_light@3x.png new file mode 100644 index 0000000000..9d813e2c8e Binary files /dev/null and b/assets/multiplatform/resources/MR/images/banner_paste_link_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha@2x.png b/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha@2x.png new file mode 100644 index 0000000000..3f53f89dd6 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha@3x.png b/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha@3x.png new file mode 100644 index 0000000000..490afadc98 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha_light@2x.png b/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha_light@2x.png new file mode 100644 index 0000000000..77a072dc62 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha_light@3x.png b/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha_light@3x.png new file mode 100644 index 0000000000..556c4d36a4 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_connect_via_link_alpha_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha@2x.png b/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha@2x.png new file mode 100644 index 0000000000..b5c813009b Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha@3x.png b/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha@3x.png new file mode 100644 index 0000000000..165e84c64a Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha_light@2x.png b/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha_light@2x.png new file mode 100644 index 0000000000..6f133967da Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha_light@3x.png b/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha_light@3x.png new file mode 100644 index 0000000000..38970844b7 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_create_your_public_address_alpha_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha@2x.png b/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha@2x.png new file mode 100644 index 0000000000..3d54b2c507 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha@3x.png b/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha@3x.png new file mode 100644 index 0000000000..c0e92a91a2 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha_light@2x.png b/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha_light@2x.png new file mode 100644 index 0000000000..329fc8d6c4 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha_light@3x.png b/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha_light@3x.png new file mode 100644 index 0000000000..99fb7a45d6 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_invite_someone_privately_alpha_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha@2x.png b/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha@2x.png new file mode 100644 index 0000000000..cc0446d16f Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha@3x.png b/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha@3x.png new file mode 100644 index 0000000000..8ea447c884 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha_light@2x.png b/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha_light@2x.png new file mode 100644 index 0000000000..b37a483be1 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha_light@3x.png b/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha_light@3x.png new file mode 100644 index 0000000000..414870fc3a Binary files /dev/null and b/assets/multiplatform/resources/MR/images/card_let_someone_connect_to_you_alpha_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/connect_via_link@2x.png b/assets/multiplatform/resources/MR/images/connect_via_link@2x.png new file mode 100644 index 0000000000..24be83e066 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/connect_via_link@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/connect_via_link@3x.png b/assets/multiplatform/resources/MR/images/connect_via_link@3x.png new file mode 100644 index 0000000000..73f118580c Binary files /dev/null and b/assets/multiplatform/resources/MR/images/connect_via_link@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/connect_via_link_light@2x.png b/assets/multiplatform/resources/MR/images/connect_via_link_light@2x.png new file mode 100644 index 0000000000..8a2d8e605a Binary files /dev/null and b/assets/multiplatform/resources/MR/images/connect_via_link_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/connect_via_link_light@3x.png b/assets/multiplatform/resources/MR/images/connect_via_link_light@3x.png new file mode 100644 index 0000000000..b6ee8a4bb6 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/connect_via_link_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/connect_via_link_small@2x.png b/assets/multiplatform/resources/MR/images/connect_via_link_small@2x.png new file mode 100644 index 0000000000..b105e3be3e Binary files /dev/null and b/assets/multiplatform/resources/MR/images/connect_via_link_small@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/connect_via_link_small@3x.png b/assets/multiplatform/resources/MR/images/connect_via_link_small@3x.png new file mode 100644 index 0000000000..1e410de4b5 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/connect_via_link_small@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/connect_via_link_small_light@2x.png b/assets/multiplatform/resources/MR/images/connect_via_link_small_light@2x.png new file mode 100644 index 0000000000..73520c2f68 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/connect_via_link_small_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/connect_via_link_small_light@3x.png b/assets/multiplatform/resources/MR/images/connect_via_link_small_light@3x.png new file mode 100644 index 0000000000..565d44690b Binary files /dev/null and b/assets/multiplatform/resources/MR/images/connect_via_link_small_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_channel@2x.png b/assets/multiplatform/resources/MR/images/create_channel@2x.png new file mode 100644 index 0000000000..a14e4c5e11 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_channel@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_channel@3x.png b/assets/multiplatform/resources/MR/images/create_channel@3x.png new file mode 100644 index 0000000000..dfd6945d9c Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_channel@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_channel_light@2x.png b/assets/multiplatform/resources/MR/images/create_channel_light@2x.png new file mode 100644 index 0000000000..5dafcad62a Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_channel_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_channel_light@3x.png b/assets/multiplatform/resources/MR/images/create_channel_light@3x.png new file mode 100644 index 0000000000..7600906d71 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_channel_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_group@2x.png b/assets/multiplatform/resources/MR/images/create_group@2x.png new file mode 100644 index 0000000000..7fa788d8c8 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_group@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_group@3x.png b/assets/multiplatform/resources/MR/images/create_group@3x.png new file mode 100644 index 0000000000..cd4bfa45a4 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_group@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_group_light@2x.png b/assets/multiplatform/resources/MR/images/create_group_light@2x.png new file mode 100644 index 0000000000..de32b94652 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_group_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_group_light@3x.png b/assets/multiplatform/resources/MR/images/create_group_light@3x.png new file mode 100644 index 0000000000..a05610cbb9 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_group_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_profile@2x.png b/assets/multiplatform/resources/MR/images/create_profile@2x.png new file mode 100644 index 0000000000..0639186b0b Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_profile@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_profile@3x.png b/assets/multiplatform/resources/MR/images/create_profile@3x.png new file mode 100644 index 0000000000..1813fea83b Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_profile@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_profile_light@2x.png b/assets/multiplatform/resources/MR/images/create_profile_light@2x.png new file mode 100644 index 0000000000..2a3f4931e9 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_profile_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/create_profile_light@3x.png b/assets/multiplatform/resources/MR/images/create_profile_light@3x.png new file mode 100644 index 0000000000..9a9fd22cfb Binary files /dev/null and b/assets/multiplatform/resources/MR/images/create_profile_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/intro@2x.png b/assets/multiplatform/resources/MR/images/intro@2x.png new file mode 100644 index 0000000000..970d68927c Binary files /dev/null and b/assets/multiplatform/resources/MR/images/intro@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/intro@3x.png b/assets/multiplatform/resources/MR/images/intro@3x.png new file mode 100644 index 0000000000..cbd56771c7 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/intro@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/intro_light@2x.png b/assets/multiplatform/resources/MR/images/intro_light@2x.png new file mode 100644 index 0000000000..938a0b1755 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/intro_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/intro_light@3x.png b/assets/multiplatform/resources/MR/images/intro_light@3x.png new file mode 100644 index 0000000000..569c56fd29 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/intro_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/network_commitments@2x.png b/assets/multiplatform/resources/MR/images/network_commitments@2x.png new file mode 100644 index 0000000000..4b58a588d3 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/network_commitments@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/network_commitments@3x.png b/assets/multiplatform/resources/MR/images/network_commitments@3x.png new file mode 100644 index 0000000000..9b80a623a1 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/network_commitments@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/network_commitments_light@2x.png b/assets/multiplatform/resources/MR/images/network_commitments_light@2x.png new file mode 100644 index 0000000000..5e07e0afdb Binary files /dev/null and b/assets/multiplatform/resources/MR/images/network_commitments_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/network_commitments_light@3x.png b/assets/multiplatform/resources/MR/images/network_commitments_light@3x.png new file mode 100644 index 0000000000..aadfa0288d Binary files /dev/null and b/assets/multiplatform/resources/MR/images/network_commitments_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/one_time_link@2x.png b/assets/multiplatform/resources/MR/images/one_time_link@2x.png new file mode 100644 index 0000000000..8b3ba2f0ee Binary files /dev/null and b/assets/multiplatform/resources/MR/images/one_time_link@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/one_time_link@3x.png b/assets/multiplatform/resources/MR/images/one_time_link@3x.png new file mode 100644 index 0000000000..de87789d1b Binary files /dev/null and b/assets/multiplatform/resources/MR/images/one_time_link@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/one_time_link_light@2x.png b/assets/multiplatform/resources/MR/images/one_time_link_light@2x.png new file mode 100644 index 0000000000..3b0c02209b Binary files /dev/null and b/assets/multiplatform/resources/MR/images/one_time_link_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/one_time_link_light@3x.png b/assets/multiplatform/resources/MR/images/one_time_link_light@3x.png new file mode 100644 index 0000000000..87360c3135 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/one_time_link_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/one_time_link_small@2x.png b/assets/multiplatform/resources/MR/images/one_time_link_small@2x.png new file mode 100644 index 0000000000..f9d94cf265 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/one_time_link_small@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/one_time_link_small@3x.png b/assets/multiplatform/resources/MR/images/one_time_link_small@3x.png new file mode 100644 index 0000000000..2dac7ef638 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/one_time_link_small@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/one_time_link_small_light@2x.png b/assets/multiplatform/resources/MR/images/one_time_link_small_light@2x.png new file mode 100644 index 0000000000..916bdaa007 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/one_time_link_small_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/one_time_link_small_light@3x.png b/assets/multiplatform/resources/MR/images/one_time_link_small_light@3x.png new file mode 100644 index 0000000000..1ed8194bc9 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/one_time_link_small_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/simplex_address@2x.png b/assets/multiplatform/resources/MR/images/simplex_address@2x.png new file mode 100644 index 0000000000..237c125c62 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/simplex_address@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/simplex_address@3x.png b/assets/multiplatform/resources/MR/images/simplex_address@3x.png new file mode 100644 index 0000000000..8f5606cbbc Binary files /dev/null and b/assets/multiplatform/resources/MR/images/simplex_address@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/simplex_address_light@2x.png b/assets/multiplatform/resources/MR/images/simplex_address_light@2x.png new file mode 100644 index 0000000000..a58ebae39c Binary files /dev/null and b/assets/multiplatform/resources/MR/images/simplex_address_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/simplex_address_light@3x.png b/assets/multiplatform/resources/MR/images/simplex_address_light@3x.png new file mode 100644 index 0000000000..aae91169ef Binary files /dev/null and b/assets/multiplatform/resources/MR/images/simplex_address_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/simplex_address_small@2x.png b/assets/multiplatform/resources/MR/images/simplex_address_small@2x.png new file mode 100644 index 0000000000..6dddbbd377 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/simplex_address_small@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/simplex_address_small@3x.png b/assets/multiplatform/resources/MR/images/simplex_address_small@3x.png new file mode 100644 index 0000000000..45471e9c50 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/simplex_address_small@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/simplex_address_small_light@2x.png b/assets/multiplatform/resources/MR/images/simplex_address_small_light@2x.png new file mode 100644 index 0000000000..a1cdfc4652 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/simplex_address_small_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/simplex_address_small_light@3x.png b/assets/multiplatform/resources/MR/images/simplex_address_small_light@3x.png new file mode 100644 index 0000000000..f54baf5dc4 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/simplex_address_small_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/your_network@2x.png b/assets/multiplatform/resources/MR/images/your_network@2x.png new file mode 100644 index 0000000000..b7b5d6aa87 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/your_network@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/your_network@3x.png b/assets/multiplatform/resources/MR/images/your_network@3x.png new file mode 100644 index 0000000000..9ff0e77a86 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/your_network@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/your_network_light@2x.png b/assets/multiplatform/resources/MR/images/your_network_light@2x.png new file mode 100644 index 0000000000..12031202d8 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/your_network_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/your_network_light@3x.png b/assets/multiplatform/resources/MR/images/your_network_light@3x.png new file mode 100644 index 0000000000..56b7f20c59 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/your_network_light@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/your_profile@2x.png b/assets/multiplatform/resources/MR/images/your_profile@2x.png new file mode 100644 index 0000000000..81e8e1a6b0 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/your_profile@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/your_profile@3x.png b/assets/multiplatform/resources/MR/images/your_profile@3x.png new file mode 100644 index 0000000000..01ea5da43c Binary files /dev/null and b/assets/multiplatform/resources/MR/images/your_profile@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/your_profile_light@2x.png b/assets/multiplatform/resources/MR/images/your_profile_light@2x.png new file mode 100644 index 0000000000..91671dadb0 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/your_profile_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/your_profile_light@3x.png b/assets/multiplatform/resources/MR/images/your_profile_light@3x.png new file mode 100644 index 0000000000..8e1d3fd15e Binary files /dev/null and b/assets/multiplatform/resources/MR/images/your_profile_light@3x.png differ diff --git a/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md b/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md new file mode 100644 index 0000000000..4a63cb87ca --- /dev/null +++ b/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md @@ -0,0 +1,71 @@ +--- +layout: layouts/article.html +title: "SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding - to Preserve Freedom of Speech" +date: 2026-04-30 +previewBody: blog_previews/20260430.html +image: images/20260430-home.png +imageLight: images/20260430-home-light.png +permalink: "/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html" +--- + +# SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding — to Preserve Freedom of Speech + +**Published:** Apr 30, 2026 + +Freedom of speech needs infrastructure that protects it by design — not only the protocols and servers, but the governance and funding to support them. + +## SimpleX Channels — more public, more freedom, more private + + + +v6.5 release[^release] brings SimpleX Channels: a new model for online publishing built for participation privacy. + +Channel content is visible to chat relay operators. And each channel uses multiple relays, so no single relay can block the channel[^preset]. + +But the real identities of channel owners and subscribers are unknown to relay operators, to each other, and to the network. This is important for freedom of speech and for our ability to say the truth[^wilde]. + +This is the opposite of the usual approach: instead of trying (and failing [^public]) to hide publicly available content from operators while exposing participants, we designed the protocols to protect people. Anybody can join a public channel via its link and see what is sent, but not who sent it, and not who else is reading. This is win-win for both users and chat relays operators. Users' privacy is protected, operators can decide what content to deliver in public spaces, and anybody can run chat relays. + +This is only possible because SimpleX network was built without user profile identifiers of any kind. You can't add participation privacy to a network that identifies its users — as you can't add privacy to a messenger built on phone numbers. + +v6.5 is the first beta version of channels: +- channel owners hold their own channel keys, +- each channel uses multiple relays for reliability, +- publishers can run their own chat relays, +- channels can be added to our [SimpleX Directory](https://simplex.chat/directory/). + +This release is a beginning of a very important new layer of SimpleX Network. Read more about channels in [whitepaper](https://github.com/simplex-chat/simplex-chat/blob/master/docs/protocol/channels-overview.md): their purpose, architecture, security model and planned future work. + +## SimpleX Network Consortium — to preserve network independence + +No single company should control protocols and network that people depend on to speak freely. If a network is run by a single company, the network has a risk that business and users interests diverge — if it happens, users lose. + +To protect network neutrality and make sure its protocols and intellectual property are available to the users, we're launching [SimpleX Network Consortium](https://simplexnetwork.org) within a few months — the agreement between the new SimpleX Network Foundation and SimpleX Chat company that will govern protocols and licensing — perpetual, irrevocable, surviving if any party is sold or shut down. Other organizations will join. + +We are currently forming the board for SimpleX Network Foundation — initially, [Heather Meeker](https://heathermeeker.com/about-me/), who drafted the Consortium agreement, and several other people will join. We will announce the board soon. + +As the power over the network protocols moves away from the company, it cannot move back[^ulysses]. It is a structural guarantee — the same principle we applied to privacy. + +## Community Crowdfunding + +We've seen open-source privacy-focussed projects die without funding, or worse — being captured by their sponsors. We've seen "don't be evil" companies get lured off course by growth and board pressure. Neither pure ideology nor pure commerce survives the long run alone. + +So we're building both: a governance structure and a real business. The governance protects the network neutrality. The commercial model funds the network and makes our and other businesses on the network profitable, ensuring their independence. Neither works without the other. + +We recently published [a preliminary design of commercial model](https://simplex.chat/credits/) — private Community Credits that fund servers, development, and governance without surveillance or speculation. The full investment case will be published when crowdfunding launches. + +You can *register your interest* to participate in crowdfunding here: https://simplexchat.typeform.com/crowdfunding + +Join the channel for updates [here](https://smp10.simplex.im/c#q09nMBmWFGz1m2TvgfZFaEOG5D2a7Ma9mSkl6pHXEsg) — you must install v6.5 to join it — or you can join a [read-only group](https://smp12.simplex.im/g#gJzy7ETpuvltqARIB73TQUpJ11Lz4Xpl9xeH9qNoGCg) from the previous app versions. + +_Disclaimer: SimpleX Chat is testing the waters for a possible Reg CF offering. We’re not asking for or accepting any money right now, and we won’t accept any if sent. We can’t accept any offers to buy securities or take any payments until the official filing is done and it’s live through a regulated platform. Our testing the waters and your possible indications of interest doesn’t create any obligation or commitment of any kind._ + +[^release]: v6.5 release also improved how new users make the first connection, increased security of sending web links, and has many other improvements — see *What's new* in the app or full release notes. + +[^preset]: Currently there is only one preset operator of chat relays in the app. It will change in the next release. + +[^wilde]: Oscar Wilde wrote: *"Man is least himself when he talks in his own person. Give him a mask, and he will tell you the truth"*. Privacy is essential for our ability to say the truth, and without truth we cannot survive as society. + +[^public]: From whitepaper: any channel joinable via a public link, whether encrypted or not, must be considered completely public — the cost of joining through automated means has collapsed with large language models. End-to-end encrypting such content provides no privacy; it only undermines users' security by creating false expectations and increases infrastructure operators' risks by making them unable to see what they deliver. + +[^ulysses]: Ulysses pact — adding constraints to reduce future options. Sé Reed used this analogy for the WordPress Foundation: tying the project to the mast before the siren songs of commercial capture (https://www.wpwatercooler.com/wpwatercooler/ep484-whose-wordpress-is-it-anyway/). diff --git a/blog/images/20260430-channel.png b/blog/images/20260430-channel.png new file mode 100644 index 0000000000..2600958dd0 Binary files /dev/null and b/blog/images/20260430-channel.png differ diff --git a/blog/images/20260430-home-light.png b/blog/images/20260430-home-light.png new file mode 100644 index 0000000000..c06f12e5e6 Binary files /dev/null and b/blog/images/20260430-home-light.png differ diff --git a/blog/images/20260430-home.png b/blog/images/20260430-home.png new file mode 100644 index 0000000000..df82b24c9e Binary files /dev/null and b/blog/images/20260430-home.png differ diff --git a/bots/README.md b/bots/README.md index 9449e9d847..80c6689dce 100644 --- a/bots/README.md +++ b/bots/README.md @@ -192,8 +192,16 @@ It is usually simpler to run your bot process on the same machine where you run If you have to run your bot on another machine, you need to secure access to bot CLI via any web proxy that supports WebSockets, e.g. Caddy or Nginx. You must configure TLS termination in the proxy and connect CLI process from bot via a secure TLS connection. If you connect to bot via a public network, you also must configure HTTP basic auth to prevent unauthorized access. You can validate TLS security of your proxy via a free test at [SSLLabs.com](https://www.ssllabs.com/ssltest/). You can also configure firewall on the machine where you run SimpleX CLI to only allow connections from the IP address of your bot. +## Available libraries + +#### Libraries with full bot API support + +- [The official TypeScript SDK](https://www.npmjs.com/package/simplex-chat) +- [Unofficial Rust SDK](https://crates.io/crates/simploxide-client) + ## Useful bots - [Broadcast bot](../apps/simplex-broadcast-bot/) (Haskell) - we use it to send [status and release updates](https://status.simplex.chat/status/public). - [Moderation bot](https://github.com/NCalex42/simplex-bot) (Java) - [Matterbridge bot](https://github.com/UnkwUsr/matterbridge-simplex) (JavaScript) +- [Nodify](https://nodify.ie) (Low-Code) \ No newline at end of file diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index dd1dd256d0..d14435cabd 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -30,6 +30,10 @@ This file is generated automatically. - [APILeaveGroup](#apileavegroup) - [APIListMembers](#apilistmembers) - [APINewGroup](#apinewgroup) +- [APINewPublicGroup](#apinewpublicgroup) +- [APIGetGroupRelays](#apigetgrouprelays) +- [APIAddGroupRelays](#apiaddgrouprelays) +- [APIAllowRelayGroup](#apiallowrelaygroup) - [APIUpdateGroupProfile](#apiupdategroupprofile) [Group link commands](#group-link-commands) @@ -49,7 +53,11 @@ This file is generated automatically. [Chat commands](#chat-commands) - [APIListContacts](#apilistcontacts) - [APIListGroups](#apilistgroups) +- [APIGetChats](#apigetchats) - [APIDeleteChat](#apideletechat) +- [APISetGroupCustomData](#apisetgroupcustomdata) +- [APISetContactCustomData](#apisetcontactcustomdata) +- [APISetUserAutoAcceptMemberContacts](#apisetuserautoacceptmembercontacts) [User profile commands](#user-profile-commands) - [ShowActiveUser](#showactiveuser) @@ -60,6 +68,10 @@ This file is generated automatically. - [APIUpdateProfile](#apiupdateprofile) - [APISetContactPrefs](#apisetcontactprefs) +[Chat management](#chat-management) +- [StartChat](#startchat) +- [APIStopChat](#apistopchat) + --- @@ -98,7 +110,7 @@ UserContactLinkCreated: User contact address created. - user: [User](./TYPES.md#user) - connLinkContact: [CreatedConnLink](./TYPES.md#createdconnlink) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -134,7 +146,7 @@ UserContactLinkDeleted: User contact address deleted. - type: "userContactLinkDeleted" - user: [User](./TYPES.md#user) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -171,7 +183,7 @@ UserContactLink: User contact address. - user: [User](./TYPES.md#user) - contactLink: [UserContactLink](./TYPES.md#usercontactlink) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -211,7 +223,7 @@ UserProfileUpdated: User profile updated. - toProfile: [Profile](./TYPES.md#profile) - updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -249,7 +261,7 @@ UserContactLinkUpdated: User contact address updated. - user: [User](./TYPES.md#user) - contactLink: [UserContactLink](./TYPES.md#usercontactlink) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -280,11 +292,11 @@ Send messages. ``` ```javascript -'/_send ' + sendRef.toString() + (liveMessage ? ' live=on' : '') + (ttl ? ' ttl=' + ttl : '') + ' json ' + JSON.stringify(composedMessages) // JavaScript +'/_send ' + ChatRef.cmdString(sendRef) + (liveMessage ? ' live=on' : '') + (ttl ? ' ttl=' + ttl : '') + ' json ' + JSON.stringify(composedMessages) // JavaScript ``` ```python -'/_send ' + str(sendRef) + (' live=on' if liveMessage else '') + ((' ttl=' + str(ttl)) if ttl is not None else '') + ' json ' + json.dumps(composedMessages) # Python +'/_send ' + ChatRef_cmd_string(sendRef) + (' live=on' if liveMessage else '') + ((' ttl=' + str(ttl)) if ttl is not None else '') + ' json ' + json.dumps(composedMessages) # Python ``` **Responses**: @@ -294,7 +306,7 @@ NewChatItems: New messages. - user: [User](./TYPES.md#user) - chatItems: [[AChatItem](./TYPES.md#achatitem)] -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -320,11 +332,11 @@ Update message. ``` ```javascript -'/_update item ' + chatRef.toString() + ' ' + chatItemId + (liveMessage ? ' live=on' : '') + ' json ' + JSON.stringify(updatedMessage) // JavaScript +'/_update item ' + ChatRef.cmdString(chatRef) + ' ' + chatItemId + (liveMessage ? ' live=on' : '') + ' json ' + JSON.stringify(updatedMessage) // JavaScript ``` ```python -'/_update item ' + str(chatRef) + ' ' + str(chatItemId) + (' live=on' if liveMessage else '') + ' json ' + json.dumps(updatedMessage) # Python +'/_update item ' + ChatRef_cmd_string(chatRef) + ' ' + str(chatItemId) + (' live=on' if liveMessage else '') + ' json ' + json.dumps(updatedMessage) # Python ``` **Responses**: @@ -339,7 +351,7 @@ ChatItemNotChanged: Message not changed. - user: [User](./TYPES.md#user) - chatItem: [AChatItem](./TYPES.md#achatitem) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -363,15 +375,15 @@ Delete message. **Syntax**: ``` -/_delete item [,...] broadcast|internal|internalMark +/_delete item [,...] broadcast|internal|internalMark|history ``` ```javascript -'/_delete item ' + chatRef.toString() + ' ' + chatItemIds.join(',') + ' ' + deleteMode // JavaScript +'/_delete item ' + ChatRef.cmdString(chatRef) + ' ' + chatItemIds.join(',') + ' ' + deleteMode // JavaScript ``` ```python -'/_delete item ' + str(chatRef) + ' ' + ','.join(map(str, chatItemIds)) + ' ' + str(deleteMode) # Python +'/_delete item ' + ChatRef_cmd_string(chatRef) + ' ' + ','.join(map(str, chatItemIds)) + ' ' + str(deleteMode) # Python ``` **Responses**: @@ -383,7 +395,7 @@ ChatItemsDeleted: Messages deleted. - byUser: bool - timed: bool -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -423,7 +435,7 @@ ChatItemsDeleted: Messages deleted. - byUser: bool - timed: bool -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -449,11 +461,11 @@ Add/remove message reaction. ``` ```javascript -'/_reaction ' + chatRef.toString() + ' ' + chatItemId + ' ' + (add ? 'on' : 'off') + ' ' + JSON.stringify(reaction) // JavaScript +'/_reaction ' + ChatRef.cmdString(chatRef) + ' ' + chatItemId + ' ' + (add ? 'on' : 'off') + ' ' + JSON.stringify(reaction) // JavaScript ``` ```python -'/_reaction ' + str(chatRef) + ' ' + str(chatItemId) + ' ' + ('on' if add else 'off') + ' ' + json.dumps(reaction) # Python +'/_reaction ' + ChatRef_cmd_string(chatRef) + ' ' + str(chatItemId) + ' ' + ('on' if add else 'off') + ' ' + json.dumps(reaction) # Python ``` **Responses**: @@ -464,7 +476,7 @@ ChatItemReaction: Message reaction. - added: bool - reaction: [ACIReaction](./TYPES.md#acireaction) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -515,7 +527,7 @@ RcvFileAcceptedSndCancelled: File accepted, but no longer sent. - user: [User](./TYPES.md#user) - rcvFileTransfer: [RcvFileTransfer](./TYPES.md#rcvfiletransfer) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -560,7 +572,7 @@ RcvFileCancelled: Cancelled receiving file. - chatItem_: [AChatItem](./TYPES.md#achatitem)? - rcvFileTransfer: [RcvFileTransfer](./TYPES.md#rcvfiletransfer) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -589,7 +601,7 @@ Add contact to group. Requires bot to have Admin role. **Syntax**: ``` -/_add # observer|author|member|moderator|admin|owner +/_add # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -609,7 +621,7 @@ SentGroupInvitation: Group invitation sent. - contact: [Contact](./TYPES.md#contact) - member: [GroupMember](./TYPES.md#groupmember) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -647,7 +659,7 @@ UserAcceptedGroupSent: User accepted group invitation. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - hostContact: [Contact](./TYPES.md#contact)? -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -668,7 +680,7 @@ Accept group member. Requires Admin role. **Syntax**: ``` -/_accept member # observer|author|member|moderator|admin|owner +/_accept member # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -687,7 +699,7 @@ MemberAccepted: Member accepted to group. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -711,7 +723,7 @@ Set members role. Requires Admin role. **Syntax**: ``` -/_member role # [,...] observer|author|member|moderator|admin|owner +/_member role # [,...] relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -730,8 +742,9 @@ MembersRoleUser: Members role changed by user. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - toRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- msgSigned: bool -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -771,8 +784,9 @@ MembersBlockedForAllUser: Members blocked for all by admin. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - blocked: bool +- msgSigned: bool -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -812,8 +826,9 @@ UserDeletedMembers: Members deleted. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - withMessages: bool +- msgSigned: bool -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -853,7 +868,7 @@ LeftMemberUser: User left group. - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -890,7 +905,7 @@ GroupMembers: Group members. - user: [User](./TYPES.md#user) - group: [Group](./TYPES.md#group) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -929,7 +944,174 @@ GroupCreated: Group created. - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APINewPublicGroup + +Create public group. + +*Network usage*: interactive. + +**Parameters**: +- userId: int64 +- incognito: bool +- relayIds: [int64] +- groupProfile: [GroupProfile](./TYPES.md#groupprofile) + +**Syntax**: + +``` +/_public group [ incognito=on] [,...] +``` + +```javascript +'/_public group ' + userId + (incognito ? ' incognito=on' : '') + ' ' + relayIds.join(',') + ' ' + JSON.stringify(groupProfile) // JavaScript +``` + +```python +'/_public group ' + str(userId) + (' incognito=on' if incognito else '') + ' ' + ','.join(map(str, relayIds)) + ' ' + json.dumps(groupProfile) # Python +``` + +**Responses**: + +PublicGroupCreated: Public group created. +- type: "publicGroupCreated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +PublicGroupCreationFailed: Public group creation failed. +- type: "publicGroupCreationFailed" +- user: [User](./TYPES.md#user) +- addRelayResults: [[AddRelayResult](./TYPES.md#addrelayresult)] + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APIGetGroupRelays + +Get group relays. + +*Network usage*: no. + +**Parameters**: +- groupId: int64 + +**Syntax**: + +``` +/_get relays # +``` + +```javascript +'/_get relays #' + groupId // JavaScript +``` + +```python +'/_get relays #' + str(groupId) # Python +``` + +**Responses**: + +GroupRelays: Group relays. +- type: "groupRelays" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APIAddGroupRelays + +Add relays to group. + +*Network usage*: interactive. + +**Parameters**: +- groupId: int64 +- relayIds: [int64] + +**Syntax**: + +``` +/_add relays # [,...] +``` + +```javascript +'/_add relays #' + groupId + ' ' + relayIds.join(',') // JavaScript +``` + +```python +'/_add relays #' + str(groupId) + ' ' + ','.join(map(str, relayIds)) # Python +``` + +**Responses**: + +GroupRelaysAdded: Group relays added. +- type: "groupRelaysAdded" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +GroupRelaysAddFailed: Group relays add failed. +- type: "groupRelaysAddFailed" +- user: [User](./TYPES.md#user) +- addRelayResults: [[AddRelayResult](./TYPES.md#addrelayresult)] + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APIAllowRelayGroup + +Clear relay rejection for a channel (relay operator). + +*Network usage*: background. + +**Parameters**: +- groupId: int64 + +**Syntax**: + +``` +/_relay allow # +``` + +```javascript +'/_relay allow #' + groupId // JavaScript +``` + +```python +'/_relay allow #' + str(groupId) # Python +``` + +**Responses**: + +RelayGroupAllowed: Relay rejection cleared for a channel. +- type: "relayGroupAllowed" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) + +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -968,8 +1150,9 @@ GroupUpdated: Group updated. - fromGroup: [GroupInfo](./TYPES.md#groupinfo) - toGroup: [GroupInfo](./TYPES.md#groupinfo) - member_: [GroupMember](./TYPES.md#groupmember)? +- msgSigned: bool -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -994,7 +1177,7 @@ Create group link. **Syntax**: ``` -/_create link # observer|author|member|moderator|admin|owner +/_create link # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -1013,7 +1196,7 @@ GroupLinkCreated: Group link created. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - groupLink: [GroupLink](./TYPES.md#grouplink) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1033,7 +1216,7 @@ Set member role for group link. **Syntax**: ``` -/_set link role # observer|author|member|moderator|admin|owner +/_set link role # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -1052,7 +1235,7 @@ GroupLink: Group link. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - groupLink: [GroupLink](./TYPES.md#grouplink) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1089,7 +1272,7 @@ GroupLinkDeleted: Group link deleted. - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1127,7 +1310,7 @@ GroupLink: Group link. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - groupLink: [GroupLink](./TYPES.md#grouplink) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1171,7 +1354,7 @@ Invitation: One-time invitation. - connLinkInvitation: [CreatedConnLink](./TYPES.md#createdconnlink) - connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1187,6 +1370,8 @@ Determine SimpleX link type and if the bot is already connected via this link. **Parameters**: - userId: int64 - connectionLink: string? +- resolveKnown: bool +- linkOwnerSig: [LinkOwnerSig](./TYPES.md#linkownersig)? **Syntax**: @@ -1210,7 +1395,7 @@ ConnectionPlan: Connection link information. - connLink: [CreatedConnLink](./TYPES.md#createdconnlink) - connectionPlan: [ConnectionPlan](./TYPES.md#connectionplan) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1219,7 +1404,7 @@ ChatCmdError: Command error. ### APIConnect -Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link +Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link. *Network usage*: interactive. @@ -1235,11 +1420,11 @@ Connect via prepared SimpleX link. The link can be 1-time invitation link, conta ``` ```javascript -'/_connect ' + userId + (preparedLink_ ? ' ' + preparedLink_.toString() : '') // JavaScript +'/_connect ' + userId + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript ``` ```python -'/_connect ' + str(userId) + ((' ' + str(preparedLink_)) if preparedLink_ is not None else '') # Python +'/_connect ' + str(userId) + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python ``` **Responses**: @@ -1261,7 +1446,7 @@ SentInvitation: Invitation sent to contact address. - connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection) - customUserProfile: [Profile](./TYPES.md#profile)? -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1311,7 +1496,7 @@ SentInvitation: Invitation sent to contact address. - connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection) - customUserProfile: [Profile](./TYPES.md#profile)? -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1348,7 +1533,7 @@ AcceptingContactRequest: Contact request accepted. - user: [User](./TYPES.md#user) - contact: [Contact](./TYPES.md#contact) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1386,7 +1571,7 @@ ContactRequestRejected: Contact request rejected. - contactRequest: [UserContactRequest](./TYPES.md#usercontactrequest) - contact_: [Contact](./TYPES.md#contact)? -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1428,7 +1613,7 @@ ContactsList: Contacts. - user: [User](./TYPES.md#user) - contacts: [[Contact](./TYPES.md#contact)] -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1465,9 +1650,49 @@ Get groups. GroupsList: Groups. - type: "groupsList" - user: [User](./TYPES.md#user) -- groups: [[GroupInfoSummary](./TYPES.md#groupinfosummary)] +- groups: [[GroupInfo](./TYPES.md#groupinfo)] -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APIGetChats + +Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases). + +*Network usage*: no. + +**Parameters**: +- userId: int64 +- pendingConnections: bool +- pagination: [PaginationByTime](./TYPES.md#paginationbytime) +- query: [ChatListQuery](./TYPES.md#chatlistquery) + +**Syntax**: + +``` +/_get chats [ pcc=on] +``` + +```javascript +'/_get chats ' + userId + (pendingConnections ? ' pcc=on' : '') + ' ' + PaginationByTime.cmdString(pagination) + ' ' + JSON.stringify(query) // JavaScript +``` + +```python +'/_get chats ' + str(userId) + (' pcc=on' if pendingConnections else '') + ' ' + PaginationByTime_cmd_string(pagination) + ' ' + json.dumps(query) # Python +``` + +**Responses**: + +ApiChats: Chat previews (paginated). Use this instead of CRContactsList / CRGroupsList when scanning at scale.. +- type: "apiChats" +- user: [User](./TYPES.md#user) +- chats: [[AChat](./TYPES.md#achat)] + +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1491,11 +1716,11 @@ Delete chat. ``` ```javascript -'/_delete ' + chatRef.toString() + ' ' + chatDeleteMode.toString() // JavaScript +'/_delete ' + ChatRef.cmdString(chatRef) + ' ' + ChatDeleteMode.cmdString(chatDeleteMode) // JavaScript ``` ```python -'/_delete ' + str(chatRef) + ' ' + str(chatDeleteMode) # Python +'/_delete ' + ChatRef_cmd_string(chatRef) + ' ' + ChatDeleteMode_cmd_string(chatDeleteMode) # Python ``` **Responses**: @@ -1514,8 +1739,120 @@ GroupDeletedUser: User deleted group. - type: "groupDeletedUser" - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- msgSigned: bool -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APISetGroupCustomData + +Set group custom data. + +*Network usage*: no. + +**Parameters**: +- groupId: int64 +- customData: JSONObject? + +**Syntax**: + +``` +/_set custom #[ ] +``` + +```javascript +'/_set custom #' + groupId + (customData ? ' ' + JSON.stringify(customData) : '') // JavaScript +``` + +```python +'/_set custom #' + str(groupId) + ((' ' + json.dumps(customData)) if customData is not None else '') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APISetContactCustomData + +Set contact custom data. + +*Network usage*: no. + +**Parameters**: +- contactId: int64 +- customData: JSONObject? + +**Syntax**: + +``` +/_set custom @[ ] +``` + +```javascript +'/_set custom @' + contactId + (customData ? ' ' + JSON.stringify(customData) : '') // JavaScript +``` + +```python +'/_set custom @' + str(contactId) + ((' ' + json.dumps(customData)) if customData is not None else '') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APISetUserAutoAcceptMemberContacts + +Set auto-accept member contacts. + +*Network usage*: no. + +**Parameters**: +- userId: int64 +- onOff: bool + +**Syntax**: + +``` +/_set accept member contacts on|off +``` + +```javascript +'/_set accept member contacts ' + userId + ' ' + (onOff ? 'on' : 'off') // JavaScript +``` + +```python +'/_set accept member contacts ' + str(userId) + ' ' + ('on' if onOff else 'off') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? + +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1529,7 +1866,7 @@ Most bots don't need to use these commands, as bot profile can be configured man ### ShowActiveUser -Get active user profile +Get active user profile. *Network usage*: no. @@ -1545,7 +1882,7 @@ ActiveUser: Active user profile. - type: "activeUser" - user: [User](./TYPES.md#user) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1554,7 +1891,7 @@ ChatCmdError: Command error. ### CreateActiveUser -Create new user profile +Create new user profile. *Network usage*: no. @@ -1581,7 +1918,7 @@ ActiveUser: Active user profile. - type: "activeUser" - user: [User](./TYPES.md#user) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1594,7 +1931,7 @@ ChatCmdError: Command error. ### ListUsers -Get all user profiles +Get all user profiles. *Network usage*: no. @@ -1610,7 +1947,7 @@ UsersList: Users. - type: "usersList" - users: [[UserInfo](./TYPES.md#userinfo)] -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1619,7 +1956,7 @@ ChatCmdError: Command error. ### APISetActiveUser -Set active user profile +Set active user profile. *Network usage*: no. @@ -1647,7 +1984,7 @@ ActiveUser: Active user profile. - type: "activeUser" - user: [User](./TYPES.md#user) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1688,7 +2025,7 @@ CmdOk: Ok. - type: "cmdOk" - user_: [User](./TYPES.md#user)? -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1732,7 +2069,7 @@ UserProfileNoChange: User profile was not changed. - type: "userProfileNoChange" - user: [User](./TYPES.md#user) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1771,8 +2108,60 @@ ContactPrefsUpdated: Contact preferences updated. - fromContact: [Contact](./TYPES.md#contact) - toContact: [Contact](./TYPES.md#contact) -ChatCmdError: Command error. +ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) --- + + +## Chat management + +These commands should not be used with CLI-based bots + + +### StartChat + +Start chat controller. + +*Network usage*: no. + +**Parameters**: +- mainApp: bool +- enableSndFiles: bool + +**Syntax**: + +``` +/_start +``` + +**Responses**: + +ChatStarted: Chat started. +- type: "chatStarted" + +ChatRunning: Chat running. +- type: "chatRunning" + +--- + + +### APIStopChat + +Stop chat controller. + +*Network usage*: no. + +**Syntax**: + +``` +/_stop +``` + +**Response**: + +ChatStopped: Chat stopped. +- type: "chatStopped" + +--- diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index a77747482f..947c60586a 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -38,6 +38,8 @@ This file is generated automatically. - [MemberAcceptedByOther](#memberacceptedbyother) - [MemberBlockedForAll](#memberblockedforall) - [GroupMemberUpdated](#groupmemberupdated) + - [GroupLinkDataUpdated](#grouplinkdataupdated) + - [GroupRelayUpdated](#grouprelayupdated) [File events](#file-events) - Main events @@ -62,6 +64,11 @@ This file is generated automatically. - [SentGroupInvitation](#sentgroupinvitation) - [GroupLinkConnecting](#grouplinkconnecting) +[Network connection events](#network-connection-events) +- [HostConnected](#hostconnected) +- [HostDisconnected](#hostdisconnected) +- [SubscriptionStatus](#subscriptionstatus) + [Error events](#error-events) - [MessageError](#messageerror) - [ChatError](#chaterror) @@ -295,6 +302,7 @@ Group profile or preferences updated. - fromGroup: [GroupInfo](./TYPES.md#groupinfo) - toGroup: [GroupInfo](./TYPES.md#groupinfo) - member_: [GroupMember](./TYPES.md#groupmember)? +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -324,6 +332,7 @@ Member (or bot user's) group role changed. - member: [GroupMember](./TYPES.md#groupmember) - fromRole: [GroupMemberRole](./TYPES.md#groupmemberrole) - toRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -339,6 +348,7 @@ Another member is removed from the group. - byMember: [GroupMember](./TYPES.md#groupmember) - deletedMember: [GroupMember](./TYPES.md#groupmember) - withMessages: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -352,6 +362,7 @@ Another member left the group. - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -366,6 +377,7 @@ Bot user was removed from the group. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) - withMessages: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -379,6 +391,7 @@ Group was deleted by the owner (not bot user). - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -422,6 +435,7 @@ Another member blocked for all members. - byMember: [GroupMember](./TYPES.md#groupmember) - member: [GroupMember](./TYPES.md#groupmember) - blocked: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -440,6 +454,35 @@ Another group member profile updated. --- +### GroupLinkDataUpdated + +Group link data updated. + +**Record type**: +- type: "groupLinkDataUpdated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] +- relaysChanged: bool + +--- + + +### GroupRelayUpdated + +Group relay member updated. + +**Record type**: +- type: "groupRelayUpdated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- member: [GroupMember](./TYPES.md#groupmember) +- groupRelay: [GroupRelay](./TYPES.md#grouprelay) + +--- + + ## File events Bots that send or receive files may process these events to track delivery status and to process completion. @@ -685,6 +728,48 @@ Sent when bot joins group via another user link. --- +## Network connection events + + + + +### HostConnected + +Messaging or file server connected + +**Record type**: +- type: "hostConnected" +- protocol: string +- transportHost: string + +--- + + +### HostDisconnected + +Messaging or file server disconnected + +**Record type**: +- type: "hostDisconnected" +- protocol: string +- transportHost: string + +--- + + +### SubscriptionStatus + +Messaging subscription status changed + +**Record type**: +- type: "subscriptionStatus" +- server: string +- subscriptionStatus: [SubscriptionStatus](./TYPES.md#subscriptionstatus) +- connections: [string] + +--- + + ## Error events Bots may log these events for debugging. There will be many error events - this does NOT indicate a malfunction - e.g., they may happen because of bad network connectivity, or because messages may be delivered to deleted chats for a short period of time (they will be ignored). @@ -705,7 +790,7 @@ Message error. ### ChatError -Chat error. +Chat error (only used in WebSockets API). **Record type**: - type: "chatError" diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 8353359484..b4edb9bd22 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -5,6 +5,7 @@ This file is generated automatically. - [ACIReaction](#acireaction) - [AChat](#achat) - [AChatItem](#achatitem) +- [AddRelayResult](#addrelayresult) - [AddressSettings](#addresssettings) - [AgentCryptoError](#agentcryptoerror) - [AgentErrorType](#agenterrortype) @@ -40,6 +41,7 @@ This file is generated automatically. - [ChatInfo](#chatinfo) - [ChatItem](#chatitem) - [ChatItemDeletion](#chatitemdeletion) +- [ChatListQuery](#chatlistquery) - [ChatPeerType](#chatpeertype) - [ChatRef](#chatref) - [ChatSettings](#chatsettings) @@ -47,9 +49,11 @@ This file is generated automatically. - [ChatType](#chattype) - [ChatWallpaper](#chatwallpaper) - [ChatWallpaperScale](#chatwallpaperscale) +- [ClientNotice](#clientnotice) - [Color](#color) - [CommandError](#commanderror) - [CommandErrorType](#commanderrortype) +- [CommentsGroupPreference](#commentsgrouppreference) - [ComposedMessage](#composedmessage) - [ConnStatus](#connstatus) - [ConnType](#conntype) @@ -68,6 +72,7 @@ This file is generated automatically. - [CreatedConnLink](#createdconnlink) - [CryptoFile](#cryptofile) - [CryptoFileArgs](#cryptofileargs) +- [DroppedMsg](#droppedmsg) - [E2EInfo](#e2einfo) - [ErrorType](#errortype) - [FeatureAllowed](#featureallowed) @@ -89,8 +94,9 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) -- [GroupInfoSummary](#groupinfosummary) +- [GroupKeys](#groupkeys) - [GroupLink](#grouplink) +- [GroupLinkOwner](#grouplinkowner) - [GroupLinkPlan](#grouplinkplan) - [GroupMember](#groupmember) - [GroupMemberAdmission](#groupmemberadmission) @@ -102,14 +108,19 @@ This file is generated automatically. - [GroupPreference](#grouppreference) - [GroupPreferences](#grouppreferences) - [GroupProfile](#groupprofile) +- [GroupRelay](#grouprelay) +- [GroupRootKey](#grouprootkey) - [GroupShortLinkData](#groupshortlinkdata) +- [GroupShortLinkInfo](#groupshortlinkinfo) - [GroupSummary](#groupsummary) - [GroupSupportChat](#groupsupportchat) +- [GroupType](#grouptype) - [HandshakeError](#handshakeerror) - [InlineFileMode](#inlinefilemode) - [InvitationLinkPlan](#invitationlinkplan) - [InvitedBy](#invitedby) - [LinkContent](#linkcontent) +- [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) - [LocalProfile](#localprofile) - [MemberCriteria](#membercriteria) @@ -121,9 +132,12 @@ This file is generated automatically. - [MsgFilter](#msgfilter) - [MsgReaction](#msgreaction) - [MsgReceiptStatus](#msgreceiptstatus) +- [MsgSigStatus](#msgsigstatus) - [NetworkError](#networkerror) - [NewUser](#newuser) - [NoteFolder](#notefolder) +- [OwnerVerification](#ownerverification) +- [PaginationByTime](#paginationbytime) - [PendingContactConnection](#pendingcontactconnection) - [PrefEnabled](#prefenabled) - [Preferences](#preferences) @@ -132,15 +146,19 @@ This file is generated automatically. - [Profile](#profile) - [ProxyClientError](#proxyclienterror) - [ProxyError](#proxyerror) +- [PublicGroupData](#publicgroupdata) +- [PublicGroupProfile](#publicgroupprofile) - [RCErrorType](#rcerrortype) - [RatchetSyncState](#ratchetsyncstate) - [RcvConnEvent](#rcvconnevent) - [RcvDirectEvent](#rcvdirectevent) - [RcvFileDescr](#rcvfiledescr) -- [RcvFileInfo](#rcvfileinfo) - [RcvFileStatus](#rcvfilestatus) - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) +- [RcvMsgError](#rcvmsgerror) +- [RelayProfile](#relayprofile) +- [RelayStatus](#relaystatus) - [ReportReason](#reportreason) - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) @@ -154,6 +172,8 @@ This file is generated automatically. - [SndGroupEvent](#sndgroupevent) - [SrvError](#srverror) - [StoreError](#storeerror) +- [SubscriptionStatus](#subscriptionstatus) +- [SupportGroupPreference](#supportgrouppreference) - [SwitchPhase](#switchphase) - [TimedMessagesGroupPreference](#timedmessagesgrouppreference) - [TimedMessagesPreference](#timedmessagespreference) @@ -164,6 +184,7 @@ This file is generated automatically. - [UIThemeEntityOverrides](#uithemeentityoverrides) - [UpdatedMessage](#updatedmessage) - [User](#user) +- [UserChatRelay](#userchatrelay) - [UserContact](#usercontact) - [UserContactLink](#usercontactlink) - [UserContactRequest](#usercontactrequest) @@ -204,6 +225,15 @@ This file is generated automatically. - chatItem: [ChatItem](#chatitem) +--- + +## AddRelayResult + +**Record type**: +- relay: [UserChatRelay](#userchatrelay) +- relayError: [ChatError](#chaterror)? + + --- ## AddressSettings @@ -290,6 +320,12 @@ AGENT: - type: "AGENT" - agentErr: [SMPAgentError](#smpagenterror) +NOTICE: +- type: "NOTICE" +- server: string +- preset: bool +- expiresAt: UTCTime? + INTERNAL: - type: "INTERNAL" - internalErr: string @@ -317,6 +353,7 @@ INACTIVE: **Record type**: - reason: [BlockingReason](#blockingreason) +- notice: [ClientNotice](#clientnotice)? --- @@ -435,6 +472,10 @@ RcvDecryptionError: - msgDecryptError: [MsgDecryptError](#msgdecrypterror) - msgCount: word32 +RcvMsgError: +- type: "rcvMsgError" +- rcvMsgError: [RcvMsgError](#rcvmsgerror) + RcvGroupInvitation: - type: "rcvGroupInvitation" - groupInvitation: [CIGroupInvitation](#cigroupinvitation) @@ -548,6 +589,7 @@ ChatBanner: - "broadcast" - "internal" - "internalMark" +- "history" --- @@ -594,6 +636,9 @@ GroupRcv: - type: "groupRcv" - groupMember: [GroupMember](#groupmember) +ChannelRcv: +- type: "channelRcv" + LocalSnd: - type: "localSnd" @@ -759,10 +804,12 @@ Group: - itemTimed: [CITimed](#citimed)? - itemLive: bool? - userMention: bool +- hasLink: bool - deletable: bool - editable: bool - forwardedByMember: int64? - showGroupAsSender: bool +- msgSigned: [MsgSigStatus](#msgsigstatus)? - createdAt: UTCTime - updatedAt: UTCTime @@ -913,6 +960,7 @@ Error: ErrorAgent: - type: "errorAgent" - agentError: [AgentErrorType](#agenterrortype) +- agentConnId: string - connectionEntity_: [ConnectionEntity](#connectionentity)? ErrorStore: @@ -951,6 +999,9 @@ UserExists: - type: "userExists" - contactName: string +ChatRelayExists: +- type: "chatRelayExists" + DifferentActiveUser: - type: "differentActiveUser" - commandUserId: int64 @@ -1103,11 +1154,6 @@ FileAlreadyExists: - type: "fileAlreadyExists" - filePath: string -FileRead: -- type: "fileRead" -- filePath: string -- message: string - FileWrite: - type: "fileWrite" - filePath: string @@ -1203,6 +1249,10 @@ ConnectionUserChangeProhibited: PeerChatVRangeIncompatible: - type: "peerChatVRangeIncompatible" +RelayTestError: +- type: "relayTestError" +- message: string + InternalError: - type: "internalError" - message: string @@ -1280,6 +1330,22 @@ Message deletion result. - toChatItem: [AChatItem](#achatitem)? +--- + +## ChatListQuery + +**Discriminated union type**: + +Filters: +- type: "filters" +- favorite: bool +- unread: bool + +Search: +- type: "search" +- search: string + + --- ## ChatPeerType @@ -1307,11 +1373,11 @@ Used in API commands. Chat scope can only be passed with groups. ``` ```javascript -chatType.toString() + chatId + (chatScope ? chatScope.toString() : '') // JavaScript +ChatType.cmdString(chatType) + chatId + (chatScope ? GroupChatScope.cmdString(chatScope) : '') // JavaScript ``` ```python -str(chatType) + str(chatId) + ((str(chatScope)) if chatScope is not None else '') # Python +ChatType_cmd_string(chatType) + str(chatId) + ((GroupChatScope_cmd_string(chatScope)) if chatScope is not None else '') # Python ``` @@ -1384,6 +1450,14 @@ self == 'direct' ? '@' : self == 'group' ? '#' : self == 'local' ? '*' : '' // J - "repeat" +--- + +## ClientNotice + +**Record type**: +- ttl: int64? + + --- ## Color @@ -1446,6 +1520,15 @@ LARGE: - type: "LARGE" +--- + +## CommentsGroupPreference + +**Record type**: +- enable: [GroupFeatureEnabled](#groupfeatureenabled) +- duration: int? + + --- ## ComposedMessage @@ -1461,15 +1544,35 @@ LARGE: ## ConnStatus -**Enum type**: -- "new" -- "prepared" -- "joined" -- "requested" -- "accepted" -- "snd-ready" -- "ready" -- "deleted" +**Discriminated union type**: + +New: +- type: "new" + +Prepared: +- type: "prepared" + +Joined: +- type: "joined" + +Requested: +- type: "requested" + +Accepted: +- type: "accepted" + +SndReady: +- type: "sndReady" + +Ready: +- type: "ready" + +Deleted: +- type: "deleted" + +Failed: +- type: "failed" +- connError: string --- @@ -1530,16 +1633,6 @@ RcvGroupMsgConnection: - groupInfo: [GroupInfo](#groupinfo) - groupMember: [GroupMember](#groupmember) -SndFileConnection: -- type: "sndFileConnection" -- entityConnection: [Connection](#connection) -- sndFileTransfer: [SndFileTransfer](#sndfiletransfer) - -RcvFileConnection: -- type: "rcvFileConnection" -- entityConnection: [Connection](#connection) -- rcvFileTransfer: [RcvFileTransfer](#rcvfiletransfer) - UserContactConnection: - type: "userContactConnection" - entityConnection: [Connection](#connection) @@ -1609,7 +1702,6 @@ Error: - localDisplayName: string - profile: [LocalProfile](#localprofile) - activeConn: [Connection](#connection)? -- viaGroup: int64? - contactUsed: bool - contactStatus: [ContactStatus](#contactstatus) - chatSettings: [ChatSettings](#chatsettings) @@ -1639,6 +1731,7 @@ Error: Ok: - type: "ok" - contactSLinkData_: [ContactShortLinkData](#contactshortlinkdata)? +- ownerVerification: [OwnerVerification](#ownerverification)? OwnLink: - type: "ownLink" @@ -1760,11 +1853,21 @@ connFullLink + ((' ' + connShortLink) if connShortLink is not None else '') # Py - fileNonce: string +--- + +## DroppedMsg + +**Record type**: +- brokerTs: UTCTime +- attempts: int + + --- ## E2EInfo **Record type**: +- public: bool? - pqEnabled: bool? @@ -1966,6 +2069,9 @@ Snippet: Secret: - type: "secret" +Small: +- type: "small" + Colored: - type: "colored" - color: [Color](#color) @@ -2023,7 +2129,9 @@ Phone: - simplexLinks: [RoleGroupPreference](#rolegrouppreference) - reports: [GroupPreference](#grouppreference) - history: [GroupPreference](#grouppreference) +- support: [SupportGroupPreference](#supportgrouppreference) - sessions: [RoleGroupPreference](#rolegrouppreference) +- comments: [CommentsGroupPreference](#commentsgrouppreference) - commands: [[ChatBotCommand](#chatbotcommand)] @@ -2113,7 +2221,9 @@ MemberSupport: - "simplexLinks" - "reports" - "history" +- "support" - "sessions" +- "comments" --- @@ -2131,6 +2241,8 @@ MemberSupport: **Record type**: - groupId: int64 +- useRelays: bool +- relayOwnStatus: [RelayStatus](#relaystatus)? - localDisplayName: string - groupProfile: [GroupProfile](#groupprofile) - localAlias: string @@ -2147,17 +2259,20 @@ MemberSupport: - chatItemTTL: int64? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? - customData: JSONObject? +- groupSummary: [GroupSummary](#groupsummary) - membersRequireAttention: int - viaGroupLinkUri: string? +- groupKeys: [GroupKeys](#groupkeys)? --- -## GroupInfoSummary +## GroupKeys **Record type**: -- groupInfo: [GroupInfo](#groupinfo) -- groupSummary: [GroupSummary](#groupsummary) +- publicGroupId: string +- groupRootKey: [GroupRootKey](#grouprootkey) +- memberPrivKey: string --- @@ -2173,6 +2288,15 @@ MemberSupport: - acceptMemberRole: [GroupMemberRole](#groupmemberrole) +--- + +## GroupLinkOwner + +**Record type**: +- memberId: string +- memberKey: string + + --- ## GroupLinkPlan @@ -2181,7 +2305,9 @@ MemberSupport: Ok: - type: "ok" +- groupSLinkInfo_: [GroupShortLinkInfo](#groupshortlinkinfo)? - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? +- ownerVerification: [OwnerVerification](#ownerverification)? OwnLink: - type: "ownLink" @@ -2197,6 +2323,13 @@ ConnectingProhibit: Known: - type: "known" - groupInfo: [GroupInfo](#groupinfo) +- groupUpdated: bool +- ownerVerification: [OwnerVerification](#ownerverification)? +- linkOwners: [[GroupLinkOwner](#grouplinkowner)] + +NoRelays: +- type: "noRelays" +- groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? --- @@ -2206,6 +2339,7 @@ Known: **Record type**: - groupMemberId: int64 - groupId: int64 +- indexInGroup: int64 - memberId: string - memberRole: [GroupMemberRole](#groupmemberrole) - memberCategory: [GroupMemberCategory](#groupmembercategory) @@ -2223,6 +2357,8 @@ Known: - createdAt: UTCTime - updatedAt: UTCTime - supportChat: [GroupSupportChat](#groupsupportchat)? +- memberPubKey: string? +- relayLink: string? --- @@ -2259,6 +2395,7 @@ Known: ## GroupMemberRole **Enum type**: +- "relay" - "observer" - "author" - "member" @@ -2319,7 +2456,9 @@ Known: - simplexLinks: [RoleGroupPreference](#rolegrouppreference)? - reports: [GroupPreference](#grouppreference)? - history: [GroupPreference](#grouppreference)? +- support: [SupportGroupPreference](#supportgrouppreference)? - sessions: [RoleGroupPreference](#rolegrouppreference)? +- comments: [CommentsGroupPreference](#commentsgrouppreference)? - commands: [[ChatBotCommand](#chatbotcommand)]? @@ -2333,16 +2472,55 @@ Known: - shortDescr: string? - description: string? - image: string? +- publicGroup: [PublicGroupProfile](#publicgroupprofile)? - groupPreferences: [GroupPreferences](#grouppreferences)? - memberAdmission: [GroupMemberAdmission](#groupmemberadmission)? +--- + +## GroupRelay + +**Record type**: +- groupRelayId: int64 +- groupMemberId: int64 +- userChatRelay: [UserChatRelay](#userchatrelay) +- relayStatus: [RelayStatus](#relaystatus) +- relayLink: string? + + +--- + +## GroupRootKey + +**Discriminated union type**: + +Private: +- type: "private" +- rootPrivKey: string + +Public: +- type: "public" +- rootPubKey: string + + --- ## GroupShortLinkData **Record type**: - groupProfile: [GroupProfile](#groupprofile) +- publicGroupData: [PublicGroupData](#publicgroupdata)? + + +--- + +## GroupShortLinkInfo + +**Record type**: +- direct: bool +- groupRelays: [string] +- publicGroupId: string? --- @@ -2350,7 +2528,8 @@ Known: ## GroupSummary **Record type**: -- currentMembers: int +- currentMembers: int64 +- publicMemberCount: int64? --- @@ -2365,6 +2544,15 @@ Known: - lastMsgFromMemberTs: UTCTime? +--- + +## GroupType + +**Enum type**: +- "channel" +- "group" + + --- ## HandshakeError @@ -2394,6 +2582,7 @@ Known: Ok: - type: "ok" - contactSLinkData_: [ContactShortLinkData](#contactshortlinkdata)? +- ownerVerification: [OwnerVerification](#ownerverification)? OwnLink: - type: "ownLink" @@ -2446,6 +2635,16 @@ Unknown: - json: JSONObject +--- + +## LinkOwnerSig + +**Record type**: +- ownerId: string? +- chatBinding: string +- ownerSig: string + + --- ## LinkPreview @@ -2551,6 +2750,7 @@ Chat: - type: "chat" - text: string - chatLink: [MsgChatLink](#msgchatlink) +- ownerSig: [LinkOwnerSig](#linkownersig)? Unknown: - type: "unknown" @@ -2637,6 +2837,15 @@ Unknown: - "badMsgHash" +--- + +## MsgSigStatus + +**Enum type**: +- "verified" +- "signedNoKey" + + --- ## NetworkError @@ -2672,6 +2881,7 @@ SubscribeError: **Record type**: - profile: [Profile](#profile)? - pastTimestamp: bool +- userChatRelay: bool --- @@ -2688,6 +2898,45 @@ SubscribeError: - unread: bool +--- + +## OwnerVerification + +**Discriminated union type**: + +Verified: +- type: "verified" + +Failed: +- type: "failed" +- reason: string + + +--- + +## PaginationByTime + +**Discriminated union type**: + +Last: +- type: "last" +- count: int + +**Syntax**: + +``` +count= +``` + +```javascript +'count=' + count // JavaScript +``` + +```python +'count=' + str(count) # Python +``` + + --- ## PendingContactConnection @@ -2807,6 +3056,24 @@ NO_SESSION: - type: "NO_SESSION" +--- + +## PublicGroupData + +**Record type**: +- publicMemberCount: int64 + + +--- + +## PublicGroupProfile + +**Record type**: +- groupType: [GroupType](#grouptype) +- groupLink: string +- publicGroupId: string + + --- ## RCErrorType @@ -2930,16 +3197,6 @@ GroupInvLinkReceived: - fileDescrComplete: bool ---- - -## RcvFileInfo - -**Record type**: -- filePath: string -- connId: int64? -- agentConnId: string? - - --- ## RcvFileStatus @@ -2951,19 +3208,19 @@ New: Accepted: - type: "accepted" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Connected: - type: "connected" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Complete: - type: "complete" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Cancelled: - type: "cancelled" -- fileInfo_: [RcvFileInfo](#rcvfileinfo)? +- filePath_: string? --- @@ -3053,6 +3310,48 @@ MemberProfileUpdated: NewMemberPendingReview: - type: "newMemberPendingReview" +MsgBadSignature: +- type: "msgBadSignature" + + +--- + +## RcvMsgError + +**Discriminated union type**: + +Dropped: +- type: "dropped" +- attempts: int + +ParseError: +- type: "parseError" +- parseError: string + + +--- + +## RelayProfile + +**Record type**: +- displayName: string +- fullName: string +- shortDescr: string? +- image: string? + + +--- + +## RelayStatus + +**Enum type**: +- "new" +- "invited" +- "accepted" +- "active" +- "inactive" +- "rejected" + --- @@ -3101,6 +3400,7 @@ A_CRYPTO: A_DUPLICATE: - type: "A_DUPLICATE" +- droppedMsg_: [DroppedMsg](#droppedmsg)? A_QUEUE: - type: "A_QUEUE" @@ -3292,6 +3592,9 @@ UserNotFound: - type: "userNotFound" - userId: int64 +RelayUserNotFound: +- type: "relayUserNotFound" + UserNotFoundByName: - type: "userNotFoundByName" - contactName: string @@ -3366,6 +3669,14 @@ GroupMemberNotFound: - type: "groupMemberNotFound" - groupMemberId: int64 +GroupMemberNotFoundByIndex: +- type: "groupMemberNotFoundByIndex" +- groupMemberIndex: int64 + +MemberRelationsVectorNotFound: +- type: "memberRelationsVectorNotFound" +- groupMemberId: int64 + GroupHostMemberNotFound: - type: "groupHostMemberNotFound" - groupId: int64 @@ -3378,12 +3689,18 @@ MemberContactGroupMemberNotFound: - type: "memberContactGroupMemberNotFound" - contactId: int64 +InvalidMemberRelationUpdate: +- type: "invalidMemberRelationUpdate" + GroupWithoutUser: - type: "groupWithoutUser" DuplicateGroupMember: - type: "duplicateGroupMember" +DuplicateMemberId: +- type: "duplicateMemberId" + GroupAlreadyJoined: - type: "groupAlreadyJoined" @@ -3464,9 +3781,6 @@ PendingConnectionNotFound: - type: "pendingConnectionNotFound" - connId: int64 -IntroNotFound: -- type: "introNotFound" - UniqueID: - type: "uniqueID" @@ -3575,12 +3889,72 @@ OperatorNotFound: UsageConditionsNotFound: - type: "usageConditionsNotFound" +UserChatRelayNotFound: +- type: "userChatRelayNotFound" +- chatRelayId: int64 + +GroupRelayNotFound: +- type: "groupRelayNotFound" +- groupRelayId: int64 + +GroupRelayNotFoundByMemberId: +- type: "groupRelayNotFoundByMemberId" +- groupMemberId: int64 + InvalidQuote: - type: "invalidQuote" InvalidMention: - type: "invalidMention" +InvalidDeliveryTask: +- type: "invalidDeliveryTask" +- taskId: int64 + +DeliveryTaskNotFound: +- type: "deliveryTaskNotFound" +- taskId: int64 + +InvalidDeliveryJob: +- type: "invalidDeliveryJob" +- jobId: int64 + +DeliveryJobNotFound: +- type: "deliveryJobNotFound" +- jobId: int64 + +WorkItemError: +- type: "workItemError" +- errContext: string + + +--- + +## SubscriptionStatus + +**Discriminated union type**: + +Active: +- type: "active" + +Pending: +- type: "pending" + +Removed: +- type: "removed" +- subError: string + +NoSub: +- type: "noSub" + + +--- + +## SupportGroupPreference + +**Record type**: +- enable: [GroupFeatureEnabled](#groupfeatureenabled) + --- @@ -3713,6 +4087,22 @@ Handshake: - autoAcceptMemberContacts: bool - userMemberProfileUpdatedAt: UTCTime? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? +- userChatRelay: bool + + +--- + +## UserChatRelay + +**Record type**: +- chatRelayId: int64 +- address: string +- relayProfile: [RelayProfile](#relayprofile) +- domains: [string] +- preset: bool +- tested: bool? +- enabled: bool +- deleted: bool --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 279a74480b..8894609758 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -72,7 +72,7 @@ instance IsString ErrorTypeDoc where fromString s = TD s "" -- category name, category description, commands --- inner: constructor, description, responses, errors (ChatErrorType constructors), network usage, syntax +-- inner: constructor, hidden params, description, responses, errors (ChatErrorType constructors), network usage, syntax chatCommandsDocsData :: [(String, String, [(ConsName, [String], Text, [ConsName], [ErrorTypeDoc], Maybe UsesNetwork, Expr)])] chatCommandsDocsData = [ ( "Address commands", @@ -117,6 +117,10 @@ chatCommandsDocsData = ("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser", "CRChatCmdError"], [], Just UNBackground, "/_leave #" <> Param "groupId"), ("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"), ("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"), + ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRPublicGroupCreationFailed", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"), + ("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"), + ("APIAddGroupRelays", [], "Add relays to group.", ["CRGroupRelaysAdded", "CRGroupRelaysAddFailed", "CRChatCmdError"], [], Just UNInteractive, "/_add relays #" <> Param "groupId" <> " " <> Join ',' "relayIds"), + ("APIAllowRelayGroup", [], "Clear relay rejection for a channel (relay operator).", ["CRRelayGroupAllowed", "CRChatCmdError"], [], Just UNBackground, "/_relay allow #" <> Param "groupId"), ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] ), @@ -131,8 +135,9 @@ chatCommandsDocsData = ( "Connection commands", "These commands may be used to create connections. Most bots do not need to use them - bot users will connect via bot address with auto-accept enabled.", [ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)), + -- `Maybe` in `connectionLink :: Maybe AConnectionLink` is used to signal link parsing error to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers. ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectionLink"), - ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"), + ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"), ("Connect", [], "Connect via SimpleX link as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connLink_"), ("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest", "CRChatCmdError"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"), ("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected", "CRChatCmdError"], [], Nothing, "/_reject " <> Param "contactReqId") @@ -142,7 +147,11 @@ chatCommandsDocsData = "Commands to list and delete conversations.", [ ("APIListContacts", [], "Get contacts.", ["CRContactsList", "CRChatCmdError"], [], Nothing, "/_contacts " <> Param "userId"), ("APIListGroups", [], "Get groups.", ["CRGroupsList", "CRChatCmdError"], [], Nothing, "/_groups " <> Param "userId" <> Optional "" (" @" <> Param "$0") "contactId_" <> Optional "" (" " <> Param "$0") "search"), - ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode") + ("APIGetChats", [], "Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases).", ["CRApiChats", "CRChatCmdError"], [], Nothing, "/_get chats " <> Param "userId" <> OnOffParam "pcc" "pendingConnections" (Just False) <> " " <> Param "pagination" <> " " <> Json "query"), + ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"), + ("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"), + ("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"), + ("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff") -- ("APIChatItemsRead", [], "Mark items as read.", ["CRItemsReadForChat"], [], Nothing, ""), -- ("APIChatRead", [], "Mark chat as read.", ["CRCmdOk"], [], Nothing, ""), -- ("APIChatUnread", [], "Mark chat as unread.", ["CRCmdOk"], [], Nothing, ""), @@ -163,21 +172,27 @@ chatCommandsDocsData = ), ( "User profile commands", "Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents).", - [ ("ShowActiveUser", [], "Get active user profile", ["CRActiveUser", "CRChatCmdError"], [], Nothing, "/user"), + [ ("ShowActiveUser", [], "Get active user profile.", ["CRActiveUser", "CRChatCmdError"], [], Nothing, "/user"), ( "CreateActiveUser", [], - "Create new user profile", + "Create new user profile.", ["CRActiveUser", "CRChatCmdError"], [TD "CEUserExists" "User or contact with this name already exists", TD "CEInvalidDisplayName" "Invalid user display name"], Nothing, "/_create user " <> Json "newUser" ), - ("ListUsers", [], "Get all user profiles", ["CRUsersList", "CRChatCmdError"], [], Nothing, "/users"), - ("APISetActiveUser", [], "Set active user profile", ["CRActiveUser", "CRChatCmdError"], ["CEChatNotStarted"], Nothing, "/_user " <> Param "userId" <> Optional "" (" " <> Json "$0") "viewPwd"), + ("ListUsers", [], "Get all user profiles.", ["CRUsersList", "CRChatCmdError"], [], Nothing, "/users"), + ("APISetActiveUser", [], "Set active user profile.", ["CRActiveUser", "CRChatCmdError"], ["CEChatNotStarted"], Nothing, "/_user " <> Param "userId" <> Optional "" (" " <> Json "$0") "viewPwd"), ("APIDeleteUser", [], "Delete user profile.", ["CRCmdOk", "CRChatCmdError"], [], Just UNBackground, "/_delete user " <> Param "userId" <> OnOffParam "del_smp" "delSMPQueues" Nothing <> Optional "" (" " <> Json "$0") "viewPwd"), ("APIUpdateProfile", [], "Update user profile.", ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], [], Just UNBackground, "/_profile " <> Param "userId" <> " " <> Json "profile"), ("APISetContactPrefs", [], "Configure chat preference overrides for the contact.", ["CRContactPrefsUpdated", "CRChatCmdError"], [], Just UNBackground, "/_set prefs @" <> Param "contactId" <> " " <> Json "preferences") ] + ), + ( "Chat management", + "These commands should not be used with CLI-based bots", + [ ("StartChat", [], "Start chat controller.", ["CRChatStarted", "CRChatRunning"], [], Nothing, "/_start"), + ("APIStopChat", [], "Stop chat controller.", ["CRChatStopped"], [], Nothing, "/_stop") + ] ) ] @@ -189,6 +204,7 @@ cliCommands = "AcceptMember", "AddContact", "AddMember", + "AllowRelayGroup", "BlockForAll", "ChatHelp", "ClearContact", @@ -234,6 +250,7 @@ cliCommands = "MemberRole", "MuteUser", "NewGroup", + "NewPublicGroup", "QuitChat", "ReactToMessage", "RejectContact", @@ -270,6 +287,7 @@ cliCommands = "SetUserGroupReceipts", "SetUserAutoAcceptMemberContacts", "SetUserTimedMessages", + "SharePublicGroup", "ShowChatItem", "ShowChatItemInfo", "ShowGroupDescription", @@ -340,16 +358,15 @@ undocumentedCommands = "APIGetAppSettings", "APIGetCallInvitations", "APIGetChat", + "APIGetChatContentTypes", "APIGetChatItemInfo", "APIGetChatItems", "APIGetChatItemTTL", - "APIGetChats", "APIGetChatTags", "APIGetConnNtfMessages", "APIGetContactCode", "APIGetGroupMemberCode", "APIGetNetworkConfig", - "APIGetNetworkStatuses", "APIGetNtfConns", "APIGetNtfToken", "APIGetReactionMembers", @@ -357,6 +374,7 @@ undocumentedCommands = "APIGetUsageConditions", "APIGetUserServers", "APIGroupInfo", + "APIGetUpdatedGroupLinkData", "APIGroupMemberInfo", "APIGroupMemberQueueInfo", "APIHideUser", @@ -392,17 +410,17 @@ undocumentedCommands = "APISetServerOperators", "APISetUserContactReceipts", "APISetUserGroupReceipts", - "APISetUserAutoAcceptMemberContacts", "APISetUserServers", "APISetUserUIThemes", + "APIShareChatMsgContent", "APIStandaloneFileInfo", - "APIStopChat", "APIStorageEncryption", "APISuspendChat", "APISwitchContact", "APISwitchGroupMember", "APISyncContactRatchet", "APISyncGroupMemberRatchet", + "APITestChatRelay", "APITestProtoServer", "APIUnhideUser", "APIUnmuteUser", @@ -435,11 +453,13 @@ undocumentedCommands = "GetChatItemTTL", "GetRemoteFile", "GetUserProtoServers", + "GetUserChatRelays", "ListRemoteCtrls", "ListRemoteHosts", "ReconnectAllServers", "ReconnectServer", "ResetAgentServersStats", + "ShowConnectionsDiff", "ResubscribeAllConnections", "SetAllContactReceipts", "SetChatItemTTL", @@ -451,13 +471,14 @@ undocumentedCommands = "SetServerOperators", "SetTempFolder", "SetUserProtoServers", + "SetUserChatRelays", "SlowSQLQueries", - "StartChat", "StartRemoteHost", "StopRemoteCtrl", "StopRemoteHost", "StoreRemoteFile", "SwitchRemoteHost", + "TestChatRelay", "TestProtoServer", "TestStorageEncryption", "VerifyRemoteCtrlSession" diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index df50a09a8f..c8446e9e67 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -97,7 +97,9 @@ chatEventsDocsData = [ ("CEvtConnectedToGroupMember", "Connected to another group member."), ("CEvtMemberAcceptedByOther", "Another group owner, admin or moderator accepted member to the group after review (\"knocking\")."), ("CEvtMemberBlockedForAll", "Another member blocked for all members."), - ("CEvtGroupMemberUpdated", "Another group member profile updated.") + ("CEvtGroupMemberUpdated", "Another group member profile updated."), + ("CEvtGroupLinkDataUpdated", "Group link data updated."), + ("CEvtGroupRelayUpdated", "Group relay member updated.") ] ), ( "File events", @@ -136,6 +138,14 @@ chatEventsDocsData = ], [] ), + ( "Network connection events", + "", + [ ("CEvtHostConnected", "Messaging or file server connected"), + ("CEvtHostDisconnected", "Messaging or file server disconnected"), + ("CEvtSubscriptionStatus", "Messaging subscription status changed") + ], + [] + ), ( "Error events", "Bots may log these events for debugging. \ \There will be many error events - this does NOT indicate a malfunction - \ @@ -143,7 +153,7 @@ chatEventsDocsData = \or because messages may be delivered to deleted chats for a short period of time \ \(they will be ignored).", [ ("CEvtMessageError", ""), - ("CEvtChatError", ""), -- only used in WebSockets API, Haskell code uses Either, with error in Left + ("CEvtChatError", "Chat error (only used in WebSockets API)."), -- Haskell code uses Either, with error in Left ("CEvtChatErrors", "") ], [] @@ -174,19 +184,10 @@ undocumentedEvents = "CEvtContactPQEnabled", "CEvtContactRatchetSync", "CEvtContactRequestAlreadyAccepted", - "CEvtContactsDisconnected", - "CEvtContactsMerged", - "CEvtContactsSubscribed", - "CEvtContactSubError", - "CEvtContactSubSummary", "CEvtContactSwitch", "CEvtCustomChatEvent", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", - "CEvtHostConnected", - "CEvtHostDisconnected", - "CEvtNetworkStatus", - "CEvtNetworkStatuses", "CEvtNewRemoteHost", "CEvtNoMemberContactCreating", "CEvtNtfMessage", @@ -205,12 +206,12 @@ undocumentedEvents = "CEvtSndFileRedirectStartXFTP", "CEvtSndFileStart", -- legacy SMP files "CEvtSndStandaloneFileComplete", + "CEvtConnectionsDiff", "CEvtSubscriptionEnd", "CEvtTerminalEvent", "CEvtTimedAction", "CEvtUnknownMemberAnnounced", "CEvtUnknownMemberBlocked", "CEvtUnknownMemberCreated", - "CEvtUserAcceptedGroupSent", -- repeat group invitation after it was accepted by the user - "CEvtUserContactSubSummary" + "CEvtUserAcceptedGroupSent" -- repeat group invitation after it was accepted by the user ] diff --git a/bots/src/API/Docs/Generate.hs b/bots/src/API/Docs/Generate.hs index 334ad93bad..8bc4cbe6ee 100644 --- a/bots/src/API/Docs/Generate.hs +++ b/bots/src/API/Docs/Generate.hs @@ -73,8 +73,8 @@ syntaxText :: TypeAndFields -> Expr -> Text syntaxText r syntax = "\n**Syntax**:\n" <> "\n```\n" <> docSyntaxText r syntax <> "\n```\n" - <> (if isConst syntax then "" else "\n```javascript\n" <> jsSyntaxText False r syntax <> " // JavaScript\n```\n") - <> (if isConst syntax then "" else "\n```python\n" <> pySyntaxText r syntax <> " # Python\n```\n") + <> (if isConst syntax then "" else "\n```javascript\n" <> jsSyntaxText False "" r syntax <> " // JavaScript\n```\n") + <> (if isConst syntax then "" else "\n```python\n" <> pySyntaxText "" r syntax <> " # Python\n```\n") camelToSpace :: String -> String camelToSpace [] = [] diff --git a/bots/src/API/Docs/Generate/Python.hs b/bots/src/API/Docs/Generate/Python.hs new file mode 100644 index 0000000000..64aa1d1062 --- /dev/null +++ b/bots/src/API/Docs/Generate/Python.hs @@ -0,0 +1,358 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module API.Docs.Generate.Python where + +import API.Docs.Commands +import API.Docs.Events +import API.Docs.Generate +import API.Docs.Responses +import API.Docs.Syntax +import API.Docs.Syntax.Types +import API.Docs.Types +import API.TypeInfo +import Data.Char (isAlphaNum, toUpper) +import qualified Data.List.NonEmpty as L +import Data.Text (Text) +import qualified Data.Text as T + +commandsCodeFile :: FilePath +commandsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_commands.py" + +responsesCodeFile :: FilePath +responsesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_responses.py" + +eventsCodeFile :: FilePath +eventsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_events.py" + +typesCodeFile :: FilePath +typesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_types.py" + +-- | Replace dashes with underscores so Python identifiers stay valid. +pyIdent :: String -> Text +pyIdent = T.replace "-" "_" . T.pack + +-- | Python class name for a union member tag. +pyConstrName :: String -> Text +pyConstrName = pyIdent . fstToUpper + +commandsCodeText :: Text +commandsCodeText = + ("# API Commands\n# " <> autoGenerated <> "\n") + <> "from __future__ import annotations\n" + <> "import json\n" + <> "from typing import NotRequired, TypedDict\n" + <> "from . import _types as T\n" + <> "from . import _responses as CR\n" + <> foldMap commandCatCode chatCommandsDocs + where + commandCatCode CCCategory {categoryName, categoryDescr, commands} = + (T.pack $ "\n# " <> categoryName <> "\n# " <> categoryDescr <> "\n") + <> foldMap commandCode commands + where + commandCode CCDoc {commandType = ATUnionMember tag params, commandDescr, syntax, responses, network} = + ("\n# " <> commandDescr <> "\n") + <> ("# Network usage: " <> networkUsage network <> ".\n") + <> classDef + <> (if syntax == "" then "" else cmdStringFunc) + <> respAliasLine + where + constrName = T.pack $ fstToUpper tag + classDef = + ("class " <> constrName <> "(TypedDict):\n") + <> bodyOrPass (fieldsCodePy " " "T." params) + <> "\n" + cmdStringFunc = + ("\ndef " <> constrName <> "_cmd_string(self: " <> constrName <> ") -> str:\n") + <> " return " <> pySelfSyntaxText "T." (fstToUpper tag, params) syntax <> "\n" + respAliasLine = + "\n" <> constrName <> "_Response = " <> respUnion <> "\n" + respUnion = unionAliasRhs "" (responseRef . responseType) responses + responseRef (ATUnionMember rtag _) = "CR." <> pyConstrName rtag + +responsesCodeText :: Text +responsesCodeText = + ("# API Responses\n# " <> autoGenerated <> "\n") + <> pythonImports + <> unionTypeCodePy moduleMember "T." "ChatResponse" chatRespConstrs + where + chatRespConstrs = L.fromList $ map responseType chatResponsesDocs + +eventsCodeText :: Text +eventsCodeText = + ("# API Events\n# " <> autoGenerated <> "\n") + <> "from __future__ import annotations\n" + <> "from collections.abc import Awaitable, Callable\n" + <> "from typing import Literal, NotRequired, Protocol, TypedDict, overload\n" + <> "from . import _types as T\n" + <> unionTypeCodePy moduleMember "T." "ChatEvent" chatEventConstrs + <> onEventProtocolCode chatEventConstrs + where + chatEventConstrs = L.fromList $ concatMap catEvents chatEventsDocs + catEvents CECategory {mainEvents, otherEvents} = map eventType $ mainEvents ++ otherEvents + +-- | Render the `OnEventDecorator` Protocol — one `__call__` overload per +-- event tag, narrowing the handler's event parameter from the unnarrowed +-- `ChatEvent` union to the specific tagged TypedDict. Plus a fallback +-- overload for `event: str` that keeps the unnarrowed shape so non-literal +-- tags don't trigger a type error. +-- +-- `Client.on_event` is typed as a `OnEventDecorator` (via a property) so +-- callers get per-tag narrowing without per-tag handwritten overloads +-- in client.py. +onEventProtocolCode :: L.NonEmpty ATUnionMember -> Text +onEventProtocolCode members = + "\n\nclass OnEventDecorator(Protocol):\n" + <> " \"\"\"Per-tag narrowing protocol for ``Client.on_event``.\n" + <> "\n" + <> " ``@client.on_event(\"contactConnected\")`` types the handler's\n" + <> " ``evt`` parameter as :class:`ContactConnected` rather than the\n" + <> " unnarrowed :data:`ChatEvent` union.\n" + <> " \"\"\"\n" + <> foldMap overloadCode (L.toList members) + <> "\n @overload\n" + <> " def __call__(self, event: str, /) -> Callable[\n" + <> " [Callable[[\"ChatEvent\"], Awaitable[None]]],\n" + <> " Callable[[\"ChatEvent\"], Awaitable[None]],\n" + <> " ]: ...\n" + where + overloadCode (ATUnionMember tag _) = + "\n @overload\n" + <> " def __call__(self, event: Literal[\"" <> T.pack tag <> "\"], /) -> Callable[\n" + <> " [Callable[[\"" <> pyConstrName tag <> "\"], Awaitable[None]]],\n" + <> " Callable[[\"" <> pyConstrName tag <> "\"], Awaitable[None]],\n" + <> " ]: ...\n" + +typesCodeText :: Text +typesCodeText = + ("# API Types\n# " <> autoGenerated <> "\n") + <> "from __future__ import annotations\n" + <> "from typing import Literal, NotRequired, TypedDict\n" + <> foldMap typeCode chatTypesDocs + where + typeCode ctd@CTDoc {typeDef = APITypeDef {typeName' = name, typeDef}, typeDescr} = + (if T.null typeDescr then "" else "\n# " <> typeDescr <> "\n") + <> typeDefCode + <> typeCmdStringCode ctd + where + name' = T.pack name + enumValue m = case name of + "ConnectionMode" -> map toUpper m + "FileProtocol" -> map toUpper m + _ -> m + typeDefCode = case typeDef of + ATDRecord fields -> + ("\nclass " <> name' <> "(TypedDict):\n") + <> bodyOrPass (fieldsCodePy " " "" fields) + ATDEnum cs -> + "\n" <> name' <> " = Literal[" + <> T.intercalate ", " (map (\m -> "\"" <> T.pack (enumValue m) <> "\"") $ L.toList cs) + <> "]\n" + ATDUnion cs -> unionTypeCodePy typeMember "" name cs + +-- | For types with non-empty `typeSyntax`, emit a top-level +-- `_cmd_string(self: ) -> str` helper that mirrors the +-- Choice/Param expression. Records access fields via `self['']`; +-- enums and unions dispatch on `self` (a literal string) or `self['type']` +-- respectively. Required so generated `_commands.py` produces valid CLI +-- syntax for ChatRef/ChatType/ChatDeleteMode/GroupChatScope/PaginationByTime +-- params instead of stringifying the wire dict. +typeCmdStringCode :: CTDoc -> Text +typeCmdStringCode CTDoc {typeDef = td@APITypeDef {typeName' = name, typeDef}, typeSyntax} + | typeSyntax == "" = "" + | otherwise = + "\n\ndef " <> T.pack name <> "_cmd_string(self: " <> T.pack name <> ") -> str:\n" + <> " return " <> body <> ignore <> "\n" + where + body = pyTypeSyntaxText "" (name, fields) typeSyntax + -- Unions and enums use self/self['type'] to dispatch. Pyright cannot + -- narrow TypedDict access by string-literal key, so suppress per-branch + -- complaints with one ignore on the return. + ignore = case typeDef of + ATDUnion _ -> " # type: ignore[typeddict-item]" + _ -> "" + -- typeFields mirrors TS funcCode: include `self` so Choice "self" + -- resolves; for unions add `type` and flatten member fields. + self = APIRecordField "self" (ATDef td) + fields = case typeDef of + ATDRecord fs -> fs + ATDUnion ms -> + self : APIRecordField "type" tagType : concatMap (\(ATUnionMember _ fs) -> fs) (L.toList ms) + where + tagType = ATDef $ APITypeDef (name <> ".type") $ ATDEnum tags + tags = L.map (\(ATUnionMember tag _) -> tag) ms + ATDEnum _ -> [self] + +-- | Like `pySelfSyntaxText` but excludes `self` from the param-rewrite list +-- so `self == 'tag'` (enum dispatch) and `self['type']` (union dispatch) +-- survive verbatim. Used only for type-level cmd_string functions inside +-- @_types.py@, where peer type cmd_string calls don't need a namespace. +pyTypeSyntaxText :: String -> TypeAndFields -> Expr -> Text +pyTypeSyntaxText typeNamespace r expr = + rewriteParams accessors (pySyntaxText typeNamespace r expr) + where + accessors = filter ((/= "self") . fst) (paramAccessors r) + +-- | Member class name within the multi-type @_types.py@ module: prefix the +-- tag with the union type name so members from different unions don't +-- collide. +typeMember :: String -> String -> Text +typeMember typeName tag = T.pack typeName <> "_" <> pyIdent tag + +-- | Member class name within a single-union module (responses/events): just +-- the PascalCase tag, so commands can reference them as @CR.@. +moduleMember :: String -> String -> Text +moduleMember _ tag = pyConstrName tag + +-- | Common imports for the responses/events modules. +pythonImports :: Text +pythonImports = + "from __future__ import annotations\n" + <> "from typing import Literal, NotRequired, TypedDict\n" + <> "from . import _types as T\n" + +-- | Render a tagged-union type: one TypedDict per member, plus union alias +-- and `_Tag` Literal alias. The member class names are produced by +-- @memberName@ given the union type name and the member tag. +unionTypeCodePy :: + (String -> String -> Text) -> + Text -> + String -> + L.NonEmpty ATUnionMember -> + Text +unionTypeCodePy memberName typesNamespace name cs = + foldMap memberClass (L.toList cs) + <> "\n" <> name' <> " = " <> unionAliasRhs name' constrTypeRef (L.toList cs) + <> "\n" <> name' <> "_Tag = Literal[" <> tagLiterals <> "]\n" + where + name' = T.pack name + constrTypeRef (ATUnionMember tag _) = memberName name tag + tagLiterals = T.intercalate ", " $ map (\(ATUnionMember tag _) -> "\"" <> T.pack tag <> "\"") $ L.toList cs + memberClass (ATUnionMember tag fields) = + ("\nclass " <> memberName name tag <> "(TypedDict):\n") + <> (" type: Literal[\"" <> T.pack tag <> "\"]\n") + <> fieldsCodePy " " typesNamespace fields + +-- | Render the right-hand side of a union alias: either inline (one line) or +-- multi-line wrapped in parentheses with `|` separators between alternatives. +unionAliasRhs :: Text -> (a -> Text) -> [a] -> Text +unionAliasRhs lhs constr cs + | T.length (lhs <> " = " <> oneLine) <= 100 = oneLine <> "\n" + | otherwise = "(\n" <> T.intercalate "\n" (map (" " <>) lines') <> "\n)\n" + where + oneLine = T.intercalate " | " cs' + lines' = case cs' of + [] -> [] + (h : t) -> h : map ("| " <>) t + cs' = map constr cs + +-- | Emit a body of `pass` if there are no fields, otherwise the rendered +-- fields as-is. +bodyOrPass :: Text -> Text +bodyOrPass body + | T.null body = " pass\n" + | otherwise = body + +-- | Render record fields for a TypedDict body. Each field becomes +-- `: [ # ]`. Optional fields wrap the type in +-- `NotRequired[...]`. +fieldsCodePy :: Text -> Text -> [APIRecordField] -> Text +fieldsCodePy indent namespace = foldMap render + where + render (APIRecordField name t) = + indent <> T.pack name <> ": " <> wrapOptional t (typeText t) <> typeComment t <> "\n" + wrapOptional t inner = case t of + ATOptional _ -> "NotRequired[" <> inner <> "]" + _ -> inner + typeText = \case + ATPrim (PT t) -> primName t + ATDef (APITypeDef t _) -> quoted (namespace <> T.pack t) + ATRef t -> quoted (namespace <> T.pack t) + ATOptional t -> typeText t + ATArray {elemType} -> "list[" <> typeText elemType <> "]" + ATMap (PT k) v -> "dict[" <> primName k <> ", " <> typeText v <> "]" + primName = \case + TBool -> "bool" + TString -> "str" + TInt -> "int" + TInt64 -> "int" + TWord32 -> "int" + TDouble -> "float" + TJSONObject -> "dict[str, object]" + TUTCTime -> "str" + t -> T.pack t + quoted s = "\"" <> s <> "\"" + typeComment t = let c = typeComment' t in if T.null c then "" else " # " <> c + typeComment' = \case + ATPrim (PT t) -> typeComment_ t + ATOptional inner -> typeComment' inner + ATArray {elemType, nonEmpty} + | nonEmpty -> if T.null c then "non-empty" else c <> ", non-empty" + | otherwise -> c + where + c = typeComment' elemType + ATMap (PT k) v -> + let kc = typeComment_ k + vc = typeComment' v + tc t c = if T.null c then t else c + in if T.null kc && T.null vc then "" else tc (primName k) kc <> " : " <> tc (typeText v) vc + _ -> "" + typeComment_ = \case + TInt -> "int" + TInt64 -> "int64" + TWord32 -> "word32" + TDouble -> "double" + TUTCTime -> "ISO-8601 timestamp" + _ -> "" + +-- | Wrap `pySyntaxText` so each parameter access uses `self['']`. The +-- output of `pySyntaxText` references params as bare Python identifiers +-- (e.g. `str(userId)`); we rewrite those identifiers — but only outside +-- string literals — into TypedDict subscript accesses. The +-- @typeNamespace@ is prepended to any `_cmd_string(...)` calls +-- emitted for params whose type has its own syntax (e.g. @"T."@ from +-- @_commands.py@, or @""@ from within @_types.py@). +-- +-- Unlike the JS variant, we do NOT collapse adjacent string literals via +-- `T.replace "' + '" ""`: that pattern incorrectly matches `' ' + ','` +-- (the space-then-comma sequence between a literal and `','.join(...)`), +-- producing `' ,'.join(...)` which uses ` ,` as the join separator and +-- swallows the leading space. The `intercalate " + "` output is correct +-- without further string fixups. +pySelfSyntaxText :: String -> TypeAndFields -> Expr -> Text +pySelfSyntaxText typeNamespace r expr = + rewriteParams (paramAccessors r) (pySyntaxText typeNamespace r expr) + +-- | Map field name to the Python access expression: `self['']` for +-- required fields, `self.get('')` for optional ones (since +-- TypedDict's `NotRequired` allows the key to be absent and `[...]` would +-- raise `KeyError`). Used by the rewriter so the same name is substituted +-- consistently in Optional `is not None` checks and in the value position. +paramAccessors :: TypeAndFields -> [(String, String)] +paramAccessors (_, fields) = map mk fields + where + mk (APIRecordField n t) = (n, accessor n t) + accessor n = \case + ATOptional _ -> "self.get('" ++ n ++ "')" + _ -> "self['" ++ n ++ "']" + +-- | Replace bare identifiers (matching a key in @accessors@) with the +-- corresponding accessor expression, skipping characters inside +-- single-quoted string literals and respecting identifier word boundaries. +rewriteParams :: [(String, String)] -> Text -> Text +rewriteParams accessors = T.pack . go False . T.unpack + where + go _ [] = [] + -- Toggle in/out of single-quoted string on every unescaped quote. + go inStr ('\'' : rest) = '\'' : go (not inStr) rest + go True (c : rest) = c : go True rest + go False s@(c : rest) + | isIdentStart c = case takeIdent s of + (ident, after) -> case lookup ident accessors of + Just expr -> expr ++ go False after + Nothing -> ident ++ go False after + | otherwise = c : go False rest + isIdentStart c = isAlphaNum c || c == '_' + takeIdent = span (\c -> isAlphaNum c || c == '_') diff --git a/bots/src/API/Docs/Generate/TypeScript.hs b/bots/src/API/Docs/Generate/TypeScript.hs index b69635086e..c3049c100d 100644 --- a/bots/src/API/Docs/Generate/TypeScript.hs +++ b/bots/src/API/Docs/Generate/TypeScript.hs @@ -49,7 +49,7 @@ commandsCodeText = <> "}\n\n" <> ("export namespace " <> T.pack constrName <> " {\n") <> (" export type Response = " <> constrsCode " " "CR" (("CR." <> ) . T.pack . fstToUpper . memberTag) (map responseType responses)) - <> (if syntax == "" then "" else funcCode APITypeDef {typeName' = constrName, typeDef = ATDRecord params} syntax) + <> (if syntax == "" then "" else funcCode APITypeDef {typeName' = constrName, typeDef = ATDRecord params} "T." syntax) <> "}\n" where constrName = fstToUpper tag @@ -86,7 +86,7 @@ typesCodeText = ("// API Types\n// " <> autoGenerated <> "\n") <> foldMap typeCo "ConnectionMode" -> T.pack $ map toUpper tag "FileProtocol" -> T.pack $ map toUpper tag _ -> T.replace "-" "_" $ T.pack $ fstToUpper tag - namespaceFuncCode = "\nexport namespace " <> name' <> " {" <> funcCode td typeSyntax <> "}\n" + namespaceFuncCode = "\nexport namespace " <> name' <> " {" <> funcCode td "" typeSyntax <> "}\n" typeDefCode = case typeDef of ATDRecord fields -> ("\nexport interface " <> name' <> " {\n") @@ -107,7 +107,7 @@ unionTypeCode unionNamespace typesNamespace td@APITypeDef {typeName' = name} cs <> (" export type Tag = " <> constrsCode " " name' constrTag (L.toList cs) <> "\n") <> (" interface Interface {\n type: Tag\n }\n") <> foldMap constrType cs - <> (if cmdSyntax == "" then "" else funcCode td cmdSyntax) + <> (if cmdSyntax == "" then "" else funcCode td typesNamespace cmdSyntax) <> "}\n" where name' = T.pack name @@ -128,9 +128,9 @@ constrsCode indent name' constr cs line = T.intercalate " | " cs' cs' = map constr cs -funcCode :: APITypeDef -> Expr -> Text -funcCode td@APITypeDef {typeName' = name, typeDef} cmdSyntax = - "\n export function cmdString(" <> param <> ": " <> T.pack name <> "): string {\n return " <> jsSyntaxText True (name, self : typeFields) cmdSyntax <> "\n }\n" +funcCode :: APITypeDef -> String -> Expr -> Text +funcCode td@APITypeDef {typeName' = name, typeDef} typeNamespace cmdSyntax = + "\n export function cmdString(" <> param <> ": " <> T.pack name <> "): string {\n return " <> jsSyntaxText True typeNamespace (name, self : typeFields) cmdSyntax <> "\n }\n" where param = if hasParams cmdSyntax then "self" else "_self" self = APIRecordField "self" (ATDef td) diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 8d5cb9f348..ddd127241b 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -51,8 +51,11 @@ chatResponsesDocsData = ("CRChatItemReaction", "Message reaction"), ("CRChatItemUpdated", "Message updated"), ("CRChatItemsDeleted", "Messages deleted"), + ("CRChatRunning", ""), + ("CRChatStarted", ""), + ("CRChatStopped", ""), ("CRCmdOk", "Ok"), - ("CRChatCmdError", "Command error"), -- only used in WebSockets API, Haskell code uses Either, with error in Left + ("CRChatCmdError", "Command error (only used in WebSockets API)"), -- Haskell code uses Either, with error in Left ("CRConnectionPlan", "Connection link information"), ("CRContactAlreadyExists", ""), ("CRContactConnectionDeleted", "Connection deleted"), @@ -65,6 +68,12 @@ chatResponsesDocsData = ("CRGroupLinkCreated", ""), ("CRGroupLinkDeleted", ""), ("CRGroupCreated", ""), + ("CRPublicGroupCreated", ""), + ("CRPublicGroupCreationFailed", ""), + ("CRGroupRelays", ""), + ("CRGroupRelaysAdded", ""), + ("CRGroupRelaysAddFailed", ""), + ("CRRelayGroupAllowed", "Relay rejection cleared for a channel"), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), @@ -89,9 +98,9 @@ chatResponsesDocsData = ("CRUserDeletedMembers", "Members deleted"), ("CRUserProfileUpdated", "User profile updated"), ("CRUserProfileNoChange", "User profile was not changed"), - ("CRUsersList", "Users") + ("CRUsersList", "Users"), + ("CRApiChats", "Chat previews (paginated). Use this instead of CRContactsList / CRGroupsList when scanning at scale.") -- ("CRApiChat", "Chat and messages"), - -- ("CRApiChats", "Chats with the most recent messages"), -- ("CRChatCleared", ""), -- ("CRChatItemInfo", "Message information"), -- ("CRChatItems", "The most recent messages"), @@ -114,22 +123,22 @@ undocumentedResponses = "CRAgentWorkersDetails", "CRAgentWorkersSummary", "CRApiChat", - "CRApiChats", "CRAppSettings", "CRArchiveExported", "CRArchiveImported", "CRBroadcastSent", "CRCallInvitations", "CRChatCleared", + "CRChatContentTypes", "CRChatHelp", "CRChatItemId", "CRChatItemInfo", "CRChatItems", "CRChatItemTTL", - "CRChatRunning", + "CRChatMsgContent", + "CRChatRelayTestResult", "CRChats", - "CRChatStarted", - "CRChatStopped", + "CRConnectionsDiff", "CRChatTags", "CRConnectionAliasUpdated", "CRConnectionIncognitoUpdated", @@ -166,7 +175,6 @@ undocumentedResponses = "CRMemberSupportChatDeleted", "CRMemberSupportChats", "CRNetworkConfig", - "CRNetworkStatuses", "CRNewMemberContact", "CRNewMemberContactSentInv", "CRMemberContactAccepted", diff --git a/bots/src/API/Docs/Syntax.hs b/bots/src/API/Docs/Syntax.hs index 83fa2bf6a2..64c848f310 100644 --- a/bots/src/API/Docs/Syntax.hs +++ b/bots/src/API/Docs/Syntax.hs @@ -99,8 +99,8 @@ withOptBoolParam r param p f = (ATOptional (ATPrim (PT TBool))) -> f True _ -> paramError r param p "is not [optional] boolean" -jsSyntaxText :: Bool -> TypeAndFields -> Expr -> Text -jsSyntaxText useSelf r = T.replace "' + '" "" . T.pack . go Nothing True +jsSyntaxText :: Bool -> String -> TypeAndFields -> Expr -> Text +jsSyntaxText useSelf typeNamespace r = T.replace "' + '" "" . T.pack . go Nothing True where go param top = \case Concat exs -> intercalate " + " $ map (go param False) $ L.toList exs @@ -112,7 +112,7 @@ jsSyntaxText useSelf r = T.replace "' + '" "" . T.pack . go Nothing True _ -> paramName' useSelf param p where toStringSyntax (APITypeDef typeName _) - | typeHasSyntax typeName = paramName' useSelf param p <> ".toString()" + | typeHasSyntax typeName = typeNamespace <> typeName <> ".cmdString(" <> paramName' useSelf param p <> ")" | otherwise = paramName' useSelf param p Optional exN exJ p -> open <> n <> " ? " <> go (Just p) False exJ <> " : " <> nothing <> close where @@ -157,8 +157,8 @@ escapeChar c s | c `elem` s = concatMap (\c' -> if c' == c then ['\\', c] else [c]) s | otherwise = s -pySyntaxText :: TypeAndFields -> Expr -> Text -pySyntaxText r = T.pack . go Nothing True +pySyntaxText :: String -> TypeAndFields -> Expr -> Text +pySyntaxText typeNamespace r = T.pack . go Nothing True where go param top = \case Concat exs -> intercalate " + " $ map (go param False) $ L.toList exs @@ -167,7 +167,13 @@ pySyntaxText r = T.pack . go Nothing True withParamType r param p $ \case ATPrim (PT TString) -> paramName param p ATOptional (ATPrim (PT TString)) -> paramName param p + ATDef td -> toStringSyntax td + ATOptional (ATDef td) -> toStringSyntax td _ -> "str(" <> paramName param p <> ")" + where + toStringSyntax (APITypeDef typeName _) + | typeHasSyntax typeName = typeNamespace <> typeName <> "_cmd_string(" <> paramName param p <> ")" + | otherwise = "str(" <> paramName param p <> ")" Optional exN exJ p -> open <> "(" <> go (Just p) False exJ <> ") if " <> n <> " is not None else " <> nothing <> close where n = paramName param p diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 83675798af..be4a55835a 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} @@ -29,9 +30,10 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol -import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared +import Simplex.Chat.Operators +import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -43,6 +45,7 @@ import Simplex.Messaging.Client import Simplex.Messaging.Crypto.File import Simplex.Messaging.Parsers (dropPrefix, fstToLower) import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NetworkError (..), ProxyError (..)) +import Simplex.Messaging.Protocol.Types (ClientNotice (..)) import Simplex.Messaging.Transport import Simplex.RemoteControl.Types import System.Console.ANSI.Types (Color (..)) @@ -69,7 +72,7 @@ chatTypesDocs = sortOn docTypeName $! snd $! mapAccumL toCTDoc (S.empty, M.empty let (tds', td_) = toTypeDef tds sumTypeInfo in case td_ of Just typeDef -> (tds', CTDoc {typeDef, typeSyntax, typeDescr}) - Nothing -> error $ "Recursive type: " <> typeName + Nothing -> error $ "Recursive type: " <> typeName toTypeDef :: (S.Set String, M.Map String APITypeDef) -> (SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text) -> ((S.Set String, M.Map String APITypeDef), Maybe APITypeDef) toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, consPrefix, hideConstrs, _, _) = @@ -84,7 +87,7 @@ toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, cons let fields = fromMaybe (error $ "Record type without fields: " <> typeName) $ L.nonEmpty fieldInfos ((visited', typeDefs'), fields') = mapAccumL (toAPIField_ typeName) (S.insert typeName visited, typeDefs) fields td = APITypeDef typeName $ ATDRecord $ L.toList fields' - in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) + in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) _ -> error $ "Record type with " <> show (length constrs) <> " constructors: " <> typeName STUnion -> if length constrs > 1 then toUnionType constrs else unionError constrs STUnion1 -> if length constrs == 1 then toUnionType constrs else unionError constrs @@ -98,16 +101,16 @@ toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, cons toUnionType constrs = let ((visited', typeDefs'), members) = mapAccumL toUnionMember (S.insert typeName visited, typeDefs) $ fromMaybe (unionError constrs) $ L.nonEmpty constrs td = APITypeDef typeName $ ATDUnion members - in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) + in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) toUnionMember tds RecordTypeInfo {consName, fieldInfos} = let memberTag = normalizeConsName consPrefix consName - in second (ATUnionMember memberTag) $ mapAccumL (toAPIField_ typeName) tds fieldInfos + in second (ATUnionMember memberTag) $ mapAccumL (toAPIField_ typeName) tds fieldInfos unionError constrs = error $ "Union type with " <> show (length constrs) <> " constructor(s): " <> typeName toEnumType = toEnumType_ $ normalizeConsName consPrefix toEnumType_ f constrs = let members = L.map toEnumMember $ fromMaybe (enumError constrs) $ L.nonEmpty constrs td = APITypeDef typeName $ ATDEnum members - in ((S.insert typeName visited, M.insert typeName td typeDefs), Just td) + in ((S.insert typeName visited, M.insert typeName td typeDefs), Just td) where toEnumMember RecordTypeInfo {consName, fieldInfos} = case fieldInfos of [] -> f consName @@ -121,7 +124,7 @@ toAPIField_ typeName tds (FieldInfo fieldName typeInfo) = second (APIRecordField toAPIType = \case TIType (ST name _) -> apiTypeForName name TIOptional tInfo -> second ATOptional $ toAPIType tInfo - TIArray {elemType, nonEmpty} -> second (`ATArray`nonEmpty) $ toAPIType elemType + TIArray {elemType, nonEmpty} -> second (`ATArray` nonEmpty) $ toAPIType elemType TIMap {keyType = ST name _, valueType} | name `elem` primitiveTypes -> second (ATMap (PT name)) $ toAPIType valueType | otherwise -> error $ "Non-primitive key type in " <> typeName <> ", " <> fieldName @@ -133,7 +136,7 @@ toAPIField_ typeName tds (FieldInfo fieldName typeInfo) = second (APIRecordField Nothing -> case find (\(STI name' _, _, _, _, _, _) -> name == name') chatTypesDocsData of Just sumTypeInfo -> let (tds', td_) = toTypeDef tds sumTypeInfo -- recursion to outer function, loops are resolved via type defs map lookup - in case td_ of + in case td_ of Just td -> (tds', ATDef td) Nothing -> (tds', ATRef name) Nothing -> error $ "Undefined type: " <> name @@ -176,6 +179,7 @@ ciQuoteType = updateRecord (RecordTypeInfo name fields) = RecordTypeInfo name $ map optChatDir fields in st {recordTypes = map updateRecord records} -- need to map even though there is one constructor in this type +-- type info, JSON encoding, constructor prefix, removed constructors, string encoding for commands, description chatTypesDocsData :: [(SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text)] chatTypesDocsData = [ ((sti @(Chat 'CTDirect)) {typeName = "AChat"}, STRecord, "", [], "", ""), @@ -198,6 +202,7 @@ chatTypesDocsData = (sti @(ContactUserPref SimplePreference), STUnion, "CUP", [], "", ""), (sti @(ContactUserPreference SimplePreference), STRecord, "", [], "", ""), (sti @(CreatedConnLink 'CMContact), STRecord, "", [], Param "connFullLink" <> Optional "" (" " <> Param "$0") "connShortLink", ""), + (sti @AddRelayResult, STRecord, "", [], "", ""), (sti @AddressSettings, STRecord, "", [], "", ""), (sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types (sti @AgentErrorType, STUnion, "", [], "", ""), @@ -229,17 +234,19 @@ chatTypesDocsData = (sti @CIMentionMember, STRecord, "", [], "", ""), (sti @CIReactionCount, STRecord, "", [], "", ""), (sti @CITimed, STRecord, "", [], "", ""), + (sti @ClientNotice, STRecord, "", [], "", ""), (sti @Color, STEnum, "", [], "", ""), (sti @CommandError, STUnion, "", [], "", ""), (sti @CommandErrorType, STUnion, "", [], "", ""), + (sti @CommentsGroupPreference, STRecord, "", [], "", ""), (sti @ComposedMessage, STRecord, "", [], "", ""), (sti @Connection, STRecord, "", [], "", ""), (sti @ConnectionEntity, STUnion, "", [], "", ""), (sti @ConnectionErrorType, STUnion, "", [], "", ""), (sti @ConnectionMode, (STEnum' $ take 3 . consLower "CM"), "", [], "", ""), (sti @ConnectionPlan, STUnion, "CP", [], "", ""), - (sti @ConnStatus, (STEnum' $ consSep "Conn" '-'), "", [], "", ""), - (sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", ["ConnSndFile", "ConnRcvFile"], "", ""), + (sti @ConnStatus, STUnion, "Conn", [], "", ""), + (sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", [], "", ""), (sti @Contact, STRecord, "", [], "", ""), (sti @ContactAddressPlan, STUnion, "CAP", [], "", ""), (sti @ContactShortLinkData, STRecord, "", [], "", ""), @@ -247,6 +254,7 @@ chatTypesDocsData = (sti @ContactUserPreferences, STRecord, "", [], "", ""), (sti @CryptoFile, STRecord, "", [], "", ""), (sti @CryptoFileArgs, STRecord, "", [], "", ""), + (sti @DroppedMsg, STRecord, "", [], "", ""), (sti @E2EInfo, STRecord, "", [], "", ""), (sti @ErrorType, STUnion, "", [], "", ""), (sti @FeatureAllowed, STEnum, "FA", [], "", ""), @@ -254,7 +262,7 @@ chatTypesDocsData = (sti @FileError, STUnion, "FileErr", [], "", ""), (sti @FileErrorType, STUnion, "", [], "", ""), (sti @FileInvitation, STRecord, "", [], "", ""), - (sti @FileProtocol, (STEnum' $ consLower "FP"), "", [], "", ""), + (sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""), (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @Format, STUnion, "", ["Unknown"], "", ""), @@ -267,27 +275,33 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), - (sti @GroupInfoSummary, STRecord, "", [], "", ""), + (sti @GroupKeys, STRecord, "", [], "", ""), + (sti @GroupRootKey, STUnion, "GRK", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), + (sti @GroupLinkOwner, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), (sti @GroupMember, STRecord, "", [], "", ""), (sti @GroupMemberAdmission, STRecord, "", [], "", ""), - (sti @GroupMemberCategory, (STEnum' $ dropPfxSfx "GC" "Member"), "", [], "", ""), + (sti @GroupMemberCategory, STEnum' (dropPfxSfx "GC" "Member"), "", [], "", ""), (sti @GroupMemberRef, STRecord, "", [], "", ""), - (sti @GroupMemberRole, STEnum, "GR", [], "", ""), + (sti @GroupMemberRole, STEnum' (dropPfxSfx "GR" ""), "", ["GRUnknown"], "", ""), (sti @GroupMemberSettings, STRecord, "", [], "", ""), - (sti @GroupMemberStatus, (STEnum' $ (\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), + (sti @GroupMemberStatus, STEnum' ((\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), (sti @GroupPreference, STRecord, "", [], "", ""), (sti @GroupPreferences, STRecord, "", [], "", ""), (sti @GroupProfile, STRecord, "", [], "", ""), + (sti @GroupRelay, STRecord, "", [], "", ""), (sti @GroupShortLinkData, STRecord, "", [], "", ""), + (sti @GroupShortLinkInfo, STRecord, "", [], "", ""), (sti @GroupSummary, STRecord, "", [], "", ""), (sti @GroupSupportChat, STRecord, "", [], "", ""), + (sti @GroupType, STEnum, "GT", ["GTUnknown"], "", ""), (sti @HandshakeError, STEnum, "", [], "", ""), (sti @InlineFileMode, STEnum, "IFM", [], "", ""), (sti @InvitationLinkPlan, STUnion, "ILP", [], "", ""), (sti @InvitedBy, STUnion, "IB", [], "", ""), (sti @LinkContent, STUnion, "LC", [], "", ""), + (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), @@ -299,9 +313,11 @@ chatTypesDocsData = (sti @MsgFilter, STEnum, "MF", [], "", ""), (sti @MsgReaction, STUnion, "MR", [], "", ""), (sti @MsgReceiptStatus, STEnum, "MR", [], "", ""), + (sti @MsgSigStatus, STEnum, "MSS", [], "", ""), (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), (sti @NoteFolder, STRecord, "", [], "", ""), + (sti @OwnerVerification, STUnion, "OV", [], "", ""), (sti @PendingContactConnection, STRecord, "", [], "", ""), (sti @PrefEnabled, STRecord, "", [], "", ""), (sti @Preferences, STRecord, "", [], "", ""), @@ -311,16 +327,20 @@ chatTypesDocsData = (sti @Profile, STRecord, "", [], "", ""), (sti @ProxyClientError, STUnion, "Proxy", [], "", ""), (sti @ProxyError, STUnion, "", [], "", ""), + (sti @PublicGroupData, STRecord, "", [], "", ""), + (sti @PublicGroupProfile, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), (sti @RCErrorType, STUnion, "RCE", [], "", ""), (sti @RcvConnEvent, STUnion, "RCE", [], "", ""), (sti @RcvDirectEvent, STUnion, "RDE", [], "", ""), (sti @RcvFileDescr, STRecord, "", [], "", ""), - (sti @RcvFileInfo, STRecord, "", [], "", ""), (sti @RcvFileStatus, STUnion, "RFS", [], "", ""), (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), - (sti @ReportReason, (STEnum' $ dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), + (sti @RcvMsgError, STUnion, "RME", [], "", ""), + (sti @RelayProfile, STRecord, "", [], "", ""), + (sti @RelayStatus, STEnum, "RS", [], "", ""), + (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), @@ -333,6 +353,8 @@ chatTypesDocsData = (sti @SndGroupEvent, STUnion, "SGE", [], "", ""), (sti @SrvError, STUnion, "SrvErr", [], "", ""), (sti @StoreError, STUnion, "SE", [], "", ""), + (sti @SubscriptionStatus, STUnion, "SS", [], "", ""), + (sti @SupportGroupPreference, STRecord, "", [], "", ""), (sti @SwitchPhase, STEnum, "SP", [], "", ""), (sti @TimedMessagesGroupPreference, STRecord, "", [], "", ""), (sti @TimedMessagesPreference, STRecord, "", [], "", ""), @@ -343,6 +365,7 @@ chatTypesDocsData = (sti @UIThemeEntityOverrides, STRecord, "", [], "", ""), (sti @UpdatedMessage, STRecord, "", [], "", ""), (sti @User, STRecord, "", [], "", ""), + ((sti @UserChatRelay) {typeName = "UserChatRelay"}, STRecord, "", [], "", ""), (sti @UserContact, STRecord, "", [], "", ""), (sti @UserContactLink, STRecord, "", [], "", ""), (sti @UserContactRequest, STRecord, "", [], "", ""), @@ -351,12 +374,11 @@ chatTypesDocsData = (sti @UserPwdHash, STRecord, "", [], "", ""), (sti @XFTPErrorType, STUnion, "", [], "", ""), (sti @XFTPRcvFile, STRecord, "", [], "", ""), - (sti @XFTPSndFile, STRecord, "", [], "", "") - + (sti @XFTPSndFile, STRecord, "", [], "", ""), -- (sti @DatabaseError, STUnion, "DB", [], "", ""), -- (sti @ChatItemInfo, STRecord, "", [], "", ""), -- (sti @ChatItemVersion, STRecord, "", [], "", ""), - -- (sti @ChatListQuery, STUnion, "CLQ", [], "", ""), + (sti @ChatListQuery, STUnion, "CLQ", [], "", ""), -- (sti @ChatName, STRecord, "", [], "", ""), -- (sti @ChatPagination, STRecord, "CP", [], "", ""), -- (sti @ConnectionStats, STRecord, "", [], "", ""), @@ -365,13 +387,16 @@ chatTypesDocsData = -- (sti @MemberReaction, STRecord, "", [], "", ""), -- (sti @MsgContentTag, (STEnum' $ dropPfxSfx "MC" '_'), "", ["MCUnknown_"], "", ""), -- (sti @NavigationInfo, STRecord, "", [], "", ""), - -- (sti @PaginationByTime, STRecord, "", [], "", ""), + -- PTAfter / PTBefore are hidden — bots only need "tail last N chats". + -- The wire format is parsed by paginationByTimeP in + -- src/Simplex/Chat/Library/Commands.hs. + (sti @PaginationByTime, STUnion1, "PT", ["PTAfter", "PTBefore"], "count=" <> Param "count", "") -- (sti @RcvQueueInfo, STRecord, "", [], "", ""), -- (sti @RcvSwitchStatus, STEnum, "", [], "", ""), -- incorrect -- (sti @SendRef, STRecord, "", [], "", ""), -- (sti @SndQueueInfo, STRecord, "", [], "", ""), -- (sti @SndSwitchStatus, STEnum, "", [], "", ""), -- incorrect - ] + ] data SimplePreference = SimplePreference {allow :: FeatureAllowed} deriving (Generic) @@ -386,6 +411,7 @@ deriving instance Generic (CIReaction c d) deriving instance Generic (ContactUserPref p) deriving instance Generic (ContactUserPreference p) deriving instance Generic (CreatedConnLink m) +deriving instance Generic AddRelayResult deriving instance Generic AddressSettings deriving instance Generic AgentCryptoError deriving instance Generic AgentErrorType @@ -417,9 +443,11 @@ deriving instance Generic CIMention deriving instance Generic CIMentionMember deriving instance Generic CIReactionCount deriving instance Generic CITimed +deriving instance Generic ClientNotice deriving instance Generic Color deriving instance Generic CommandError deriving instance Generic CommandErrorType +deriving instance Generic CommentsGroupPreference deriving instance Generic ComposedMessage deriving instance Generic Connection deriving instance Generic ConnectionEntity @@ -435,6 +463,7 @@ deriving instance Generic ContactStatus deriving instance Generic ContactUserPreferences deriving instance Generic CryptoFile deriving instance Generic CryptoFileArgs +deriving instance Generic DroppedMsg deriving instance Generic E2EInfo deriving instance Generic ErrorType deriving instance Generic FeatureAllowed @@ -455,8 +484,10 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo -deriving instance Generic GroupInfoSummary +deriving instance Generic GroupKeys +deriving instance Generic GroupRootKey deriving instance Generic GroupLink +deriving instance Generic GroupLinkOwner deriving instance Generic GroupLinkPlan deriving instance Generic GroupMember deriving instance Generic GroupMemberAdmission @@ -468,7 +499,10 @@ deriving instance Generic GroupMemberStatus deriving instance Generic GroupPreference deriving instance Generic GroupPreferences deriving instance Generic GroupProfile +deriving instance Generic GroupRelay deriving instance Generic GroupShortLinkData +deriving instance Generic GroupShortLinkInfo +deriving instance Generic GroupType deriving instance Generic GroupSummary deriving instance Generic GroupSupportChat deriving instance Generic HandshakeError @@ -482,6 +516,7 @@ deriving instance Generic JSONCIDirection deriving instance Generic JSONCIFileStatus deriving instance Generic JSONCIStatus deriving instance Generic LinkContent +deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview deriving instance Generic LocalProfile deriving instance Generic MemberCriteria @@ -493,9 +528,11 @@ deriving instance Generic MsgErrorType deriving instance Generic MsgFilter deriving instance Generic MsgReaction deriving instance Generic MsgReceiptStatus +deriving instance Generic MsgSigStatus deriving instance Generic NetworkError deriving instance Generic NewUser deriving instance Generic NoteFolder +deriving instance Generic OwnerVerification deriving instance Generic PendingContactConnection deriving instance Generic PrefEnabled deriving instance Generic Preferences @@ -505,15 +542,19 @@ deriving instance Generic PreparedGroup deriving instance Generic Profile deriving instance Generic ProxyClientError deriving instance Generic ProxyError +deriving instance Generic PublicGroupData +deriving instance Generic PublicGroupProfile deriving instance Generic RatchetSyncState deriving instance Generic RCErrorType deriving instance Generic RcvConnEvent deriving instance Generic RcvDirectEvent deriving instance Generic RcvFileDescr -deriving instance Generic RcvFileInfo deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent +deriving instance Generic RcvMsgError +deriving instance Generic RelayProfile +deriving instance Generic RelayStatus deriving instance Generic ReportReason deriving instance Generic SecurityCode deriving instance Generic SimplexLinkType @@ -525,6 +566,8 @@ deriving instance Generic SndFileTransfer deriving instance Generic SndGroupEvent deriving instance Generic SrvError deriving instance Generic StoreError +deriving instance Generic SubscriptionStatus +deriving instance Generic SupportGroupPreference deriving instance Generic SwitchPhase deriving instance Generic TimedMessagesGroupPreference deriving instance Generic TimedMessagesPreference @@ -535,6 +578,7 @@ deriving instance Generic UIThemeEntityOverride deriving instance Generic UIThemeEntityOverrides deriving instance Generic UpdatedMessage deriving instance Generic User +deriving instance Generic (UserChatRelay' 'DBStored) deriving instance Generic UserContact deriving instance Generic UserContactLink deriving instance Generic UserContactRequest @@ -548,7 +592,7 @@ deriving instance Generic XFTPSndFile -- deriving instance Generic DatabaseError -- deriving instance Generic ChatItemInfo -- deriving instance Generic ChatItemVersion --- deriving instance Generic ChatListQuery +deriving instance Generic ChatListQuery -- deriving instance Generic ChatName -- deriving instance Generic ChatPagination -- deriving instance Generic ConnectionStats @@ -558,7 +602,7 @@ deriving instance Generic XFTPSndFile -- deriving instance Generic MemberReaction -- deriving instance Generic MsgContentTag -- deriving instance Generic NavigationInfo --- deriving instance Generic PaginationByTime +deriving instance Generic PaginationByTime -- deriving instance Generic RcvQueueInfo -- deriving instance Generic RcvSwitchStatus -- deriving instance Generic SendRef diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index df43374ffa..36e87db62d 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -1,5 +1,4 @@ {-# LANGUAGE AllowAmbiguousTypes #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -8,9 +7,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeOperators #-} -{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-orphans #-} @@ -170,12 +167,14 @@ toTypeInfo tr = _ -> TIType (simpleType tr) simpleType tr' = primitiveToLower $ case tyConName (typeRepTyCon tr') of "AgentUserId" -> ST TInt64 [] + "DBEntityId'" -> ST TInt64 [] "Integer" -> ST TInt64 [] "Version" -> ST TInt [] "BoolDef" -> ST TBool [] "PQEncryption" -> ST TBool [] "PQSupport" -> ST TBool [] "ACreatedConnLink" -> ST "CreatedConnLink" [] + "UserChatRelay'" -> ST "UserChatRelay" [] "CChatItem" -> ST "ChatItem" [] "FormatColor" -> ST "Color" [] "CustomData" -> ST "JSONObject" [] @@ -194,6 +193,7 @@ toTypeInfo tr = primitiveToLower st@(ST t ps) = let t' = fstToLower t in if t' `elem` primitiveTypes then ST t' ps else st stringTypes = [ "AConnectionLink", + "AProtocolType", "AgentConnId", "AgentInvId", "AgentRcvFileId", @@ -209,9 +209,13 @@ toTypeInfo tr = "MemberId", "Text", "MREmojiChar", + "PrivateKey", + "PublicKey", "ProtocolServer", "SbKey", "SharedMsgId", + "Signature", + "TransportHost", "UIColor", "UserPwd", "XContactId" diff --git a/cabal.project b/cabal.project index 3242d9ca49..7ee797e621 100644 --- a/cabal.project +++ b/cabal.project @@ -2,6 +2,15 @@ packages: . -- packages: . ../simplexmq -- packages: . ../simplexmq ../direct-sqlcipher ../sqlcipher-simple +-- uncomment two sections below to run tests with coverage +-- package * +-- coverage: True +-- library-coverage: True + +-- package attoparsec +-- coverage: False +-- library-coverage: False + index-state: 2023-12-12T00:00:00Z package cryptostore @@ -12,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9346b85c3f34f8b12fefef4631ba21087cf5f0e3 + tag: f03cec7a58ed13a39a52886888c74bcefdb64479 source-repository-package type: git diff --git a/docs/ABOUT.md b/docs/ABOUT.md new file mode 100644 index 0000000000..44809b9c0c --- /dev/null +++ b/docs/ABOUT.md @@ -0,0 +1,18 @@ +--- +layout: layouts/jobs.html +permalink: /about/index.html +--- + +# About us + +SimpleX Chat Ltd is a company founded to develop SimpleX network and software. + +Our mission is to create a fully decentralized network, based on the same principles as open web, but the one that gives users full control and ownership of their identity, contacts and communities. + +## Contact us + +SimpleX: "Ask SimpleX team" contact in the app or [this address](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). + +Email: [chat@simplex.chat](mailto:chat@simplex.chat). You can use PGP to encrypt email messages using our key from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and making your key available for a secure reply. + +You can follow our updates on social media: [X/Twitter](https://x.com/simplexchat), [Reddit](https://www.reddit.com/r/SimpleXChat/), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://primal.net/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828). diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 61f81d1a40..d0422e1abd 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -49,7 +49,7 @@ Please, note, that if you use a modern version of SimpleX, the databases will be In order to view database data you need to decrypt it first. Install `sqlcipher` using your favorite package manager and run the following commands in the directory with databases: ```bash sqlcipher files_chat.db -pragma key="youDecryptionPassphrase"; +pragma key="yourDecryptionPassphrase"; # Ensure it works fine select * from users; ``` diff --git a/docs/BUSINESS.md b/docs/BUSINESS.md index 8fd5df5c36..b72bf00257 100755 --- a/docs/BUSINESS.md +++ b/docs/BUSINESS.md @@ -9,7 +9,7 @@ SimpleX Chat (aka SimpleX) is a decentralized communication network that provide This document aims to help you make the best use of SimpleX Chat if you choose to engage with its users. -## Communcate with customers via business address +## Communicate with customers via business address In the same way you can connect to our "SimpleX Chat team" profile via the app, you can provide the address for your existing and prospective customers: - to buy your product and services via chat, @@ -85,7 +85,7 @@ To install SimpleX Chat CLI in the cloud, follow this: simplex-chat ``` -To deattach from running CLI simply press `Ctrl+B` and then `D`. +To detach from a running CLI, simply press `Ctrl+B` and then `D`. To reattach back to CLI, run: `tmux attach -t simplex-cli`. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f163335388..3ecfa17409 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -7,6 +7,31 @@ revision: 25.07.2025 # Contributing guide +## Focus on user problems + +We do not make code changes to improve code - any change must address a specific user problem or request. + +## Discuss the plans as early as possible + +Please discuss the problem you want to solve and your detailed implementation plan with the project team prior to contributing, to avoid wasted time and additional changes. Acceptance of your contribution depends on your willingness and ability to iterate the proposed contribution to achieve the required quality level, coding style, test coverage, and alignment with user requirements as they are understood by the project team. + +## Follow project structure, coding style and approaches + +./contributing/PROJECT.md has information about the structure of this `simplex-chat` repository. + +./contributing/CODE.md has details about general requirements common for `simplexmq` and `simplex-chat` repositories. + +These files can be used with LLM prompts, e.g. if you use Claude Code you can create CLAUDE.md file in project root importing content from these files: + +```markdown +@README.md +@docs/CONTRIBUTING.md +@docs/contributing/PROJECT.md +@docs/contributing/CODE.md +``` + +For Android/Desktop and iOS apps you can additionally import `apps/multiplatform/README.md` and `apps/ios/README.md`. + ## Compiling with SQLCipher encryption enabled Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode: @@ -46,7 +71,7 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t 1. Make PRs to `master` branch _only_ for both simplex-chat and simplexmq repos. -2. To build core libraries for Android, iOS and windows: +2. To build core libraries for Android, iOS and Windows: - merge `master` branch to `master-android` branch. - push to GitHub. diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 9ffeeef2d2..50e7771a1a 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -1,15 +1,17 @@ --- -title: SimpleX Directory Service +title: SimpleX Directory revision: 18.08.2023 --- -# SimpleX Directory Service +# SimpleX Directory -You can use an experimental directory service to discover the groups created and registered by other users. +You can use SimpleX Directory to discover the groups created and registered by other users. ## Searching for groups -Connect to the directory service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups. +SimpleX Directory is available at [simplex.chat/directory](https://simplex.chat/directory/) or via this [onion link](http://isdb4l77sjqoy2qq7ipum6x3at6hyn3jmxfx4zdhc72ufbmuq4ilwkqd.onion/directory/). + +You can also connect to SimpleX Directory via [this address](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups. Please note that your search queries can be kept by the bot as the conversation history, but you can use incognito mode when connecting to the bot, to avoid correlation with any other communications. See [Privacy policy](../PRIVACY.md) for more details. @@ -94,7 +96,7 @@ Group owners are expected to moderate the content in the groups, if members post We reserve the right to not accept the group listing in the directory or cancel its listing, and there may be cases when we can't provide an explanation. We will certainly try to avoid it by communicating with the group owners first. -The combination of display name and full name has to be unique for the listed groups. If a group uses the name or logo of SimpleX, SimpleX network or SimpleX Chat it must be consistent with [Permitted Uses or SimpleX trademark](./TRADEMARK.md). +The combination of display name and full name has to be unique for the listed groups. If a group uses the name or logo of SimpleX, SimpleX network or SimpleX Chat it must be consistent with [Permitted Uses of SimpleX trademark](./TRADEMARK.md). Once the group is listed in the directory, the bot will invite you to join the group of the group owners, where you can send any ideas or suggestions for how the groups functionality should evolve, and help steer both the product and the policies. diff --git a/docs/DONATIONS.md b/docs/DONATIONS.md new file mode 100644 index 0000000000..f7a6e61d7b --- /dev/null +++ b/docs/DONATIONS.md @@ -0,0 +1,32 @@ +--- +layout: layouts/privacy.html +permalink: /donate/index.html +--- + +# Please support us with donations + +Huge thank you to everybody who donated to SimpleX Chat! + +We are prioritizing users' privacy and security - it would be impossible without your support. + +Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure. + +To ensure network independence and neutrality, we are currently finalizing the launch of [SimpleX Network Consortium](https://simplexnetwork.org/) - an agreement between SimpleX Network Foundation that is being formed as 501.c3 non-profit and SimpleX Chat company. + +Your donations help us raise more funds - any amount, even the price of the cup of coffee, makes a big difference for us. + +Please donate via: + +- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission) +- BTC: [bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u](bitcoin:bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u) +- XMR: [8A3ZWAXrrQddvnT1fPrtbK86ZAoM4nai3Gjg1LEow3JWcryJtovMnHYZnxTJpCLmAbfWbnPMeTzPmMBjAhyd4xoM89hYq1c](monero:8A3ZWAXrrQddvnT1fPrtbK86ZAoM4nai3Gjg1LEow3JWcryJtovMnHYZnxTJpCLmAbfWbnPMeTzPmMBjAhyd4xoM89hYq1c) +- ETH/USDT (Ethereum, Arbitrum One): [0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a) ([donate.simplexchat.eth](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a)) +- [Other cryptocurrencies](https://github.com/simplex-chat/simplex-chat#please-support-us-with-your-donations) + +Thank you, + +Evgeny, SimpleX Chat founder + +## SimpleX Community Credits + +Please comment on our plan to make SimpleX network sustainable: https://simplex.chat/credits diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 86f87e069b..95cf972c0d 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -4,7 +4,6 @@ permalink: /downloads/index.html revision: 09.09.2024 --- -| Updated 09.09.2024 | Languages: EN | # Download SimpleX apps You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -26,6 +25,8 @@ You can link your mobile device with desktop to use the same profile remotely, b - Ubuntu 22.04 and Debian-based distros ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-x86_64.deb), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-aarch64.deb)). - Ubuntu 24.04 ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-24_04-x86_64.deb), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-24_04-aarch64.deb)). +You can [verify and reproduce](./REPRODUCE.md) Linux builds. + **Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-windows-x86_64.msi). @@ -34,7 +35,9 @@ You can link your mobile device with desktop to use the same profile remotely, b **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-armv7a.apk). + +You can [verify and reproduce](./REPRODUCE.md) Android APKs. ## Terminal (console) app diff --git a/docs/FAQ.md b/docs/FAQ.md index a24f9f9c56..8c14168811 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -51,7 +51,7 @@ revision: 13.08.2025 ### How do I connect to people? -Tap "pencil" button in the right corner, then "Create 1-time link". Share the link with the person you want to connect to. Your contact has to paste the link to the app's search bar. The link will can also be opened via the browser, once the app is installed. +Tap "pencil" button in the right corner, then "Create 1-time link". Share the link with the person you want to connect to. Your contact has to paste the link to the app's search bar. The link can also be opened via the browser, once the app is installed. Alternatively, you can show the QR code when meeting in person or in a video call. @@ -79,7 +79,7 @@ When "Incognito Mode” is turned on, your currently chosen profile name and ima ### How do invitations work? -It is quite a complex process, but fortunately all of this happens in the background, so it's simply to use. +It is quite a complex process, but fortunately all of this happens in the background, so it's simple to use. Whenever somebody connects to you via your address, they basically ask your client whether they want to establish connection. After that, you can either agree or disagree. If interested, please read more: [Addresses and invitations](./guide/making-connections.md). @@ -103,7 +103,7 @@ Also see: [I do not see the second tick on the messages I sent](#i-do-not-see-th ### I want to see when my contacts read my messages -To know when your contact read your messages, your contact's app has to send you a confirmation message. And vice versa, for your contact to know when you read the message, your app has to send a confirmation message. +To know when your contact reads your messages, your contact's app has to send you a confirmation message. And vice versa, for your contact to know when you read the message, your app has to send a confirmation message. The important questions for this feature: - do you always want that your contacts can see when you read all their messages? Probably, even with your close friends, sometimes you would prefer to have time before you answer their message, and also have a plausible deniability that you have not seen the message. And this should be ok - in the end, this is your device, and it should be for you to decide whether this confirmation message is sent or not, and when it is sent. @@ -111,7 +111,7 @@ The important questions for this feature: Overall, it seems that this feature is more damaging to your communications with your contacts than it is helpful. It keeps senders longer in the app, nervously waiting for read receipts, exploiting addictive patterns - having you spend more time in the app is the reason why it is usually present in most messaging apps. It also creates a pressure on the recipients to reply sooner, and if read receipts are opt-in, it creates a pressure to enable it, that can be particularly damaging in any relationships with power imbalance. -We think that delivery receipts are important and equally benefit both sides as the conversation, as they confirm that communication network functions properly. But we strongly believe that read receipts is an anti-feature that only benefits the app developers, and hurts the relations between the app users. So we are not planning to add it even as opt-in. In case you want your contact to know you've read the message put a reaction to it. And if you don't want them to know it - it is also ok, what your device sends should be under your control. +We think that delivery receipts are important and equally benefit both sides as the conversation, as they confirm that communication network functions properly. But we strongly believe that read receipts are an anti-feature that only benefits the app developers, and hurts the relations between the app users. So we are not planning to add it even as opt-in. In case you want your contact to know you've read the message put a reaction to it. And if you don't want them to know it - it is also ok, what your device sends should be under your control. ### Can I use the same profile on desktop? Do messages sync cross-platform? @@ -126,11 +126,11 @@ You can also revoke the files you send. If the recipients did not yet receive th This is different from most other messengers that allow deleting messages from the recipients' devices without any agreement with the recipients. We believe that allowing deleting information from your device to your contacts is a very wrong design decision for several reasons: -1) it violates your data sovereignty as the device owner - once your are in possession of any information, you have the rights to retain it, and any deletion should be agreed with you. And security and privacy is not possible if users don't have sovereignty over their devices. +1) it violates your data sovereignty as the device owner - once you are in possession of any information, you have the rights to retain it, and any deletion should be agreed with you. And security and privacy is not possible if users don't have sovereignty over their devices. 2) it may be a business communication, and either your organization policy or a compliance requirement is that every message you receive must be preserved for some time. 3) the message can contain a legally binding promise, effectively a contract between you and your contact, in which case you both need to keep it. 4) the messages may contain threat or abuse and you may want to keep them as a proof. -5) you may have paid for the the message (e.g., it can be a design project or consulting report), and you don't want it to suddenly disappear before you had a chance to store it outside of the conversation. +5) you may have paid for the message (e.g., it can be a design project or consulting report), and you don't want it to suddenly disappear before you had a chance to store it outside of the conversation. It is also important to remember, that even if your contact enabled "Delete for everyone", you cannot really see it as a strong guarantee that the message will be deleted. Your contact's app can have a very simple modification (a one-line code change), that would prevent this deletion from happening when you request it. So you cannot see it as something that guarantees your security from your contacts. @@ -232,7 +232,7 @@ You may not have the second tick on your sent messages for these reasons: ### I see image preview but cannot open the image It can be for these reasons: -- your contact did not finish uploading the image file, possibly closing the app too quickly. When the image file is fully uploaded there will be a tick in the _top right corner_ or the image +- your contact did not finish uploading the image file, possibly closing the app too quickly. When the image file is fully uploaded there will be a tick in the _top right corner_ of the image. - your device fails to receive it. Please check server connectivity and run server tests, and also try increasing network timeouts in Advanced network settings. File reception was substantially improved in v5.7 - please make sure you are using the latest version. - file expired and can no longer be received. Files can be received only for 2 days after they were sent, after that they won't be available and will show X in the top right corner. @@ -298,7 +298,7 @@ You can resolve it by deleting the app's database: (WARNING: this results in del ### My mobile app does not connect to desktop app 1. Check that both devices are connected to the same network (e.g., it won't work if mobile is connected to mobile Internet and desktop to WiFi). -2. If you use VPN on mobile, allow connections to local network in you VPN settings, or disable VPN. +2. If you use VPN on mobile, allow connections to local network in your VPN settings, or disable VPN. 3. Allow SimpleX Chat on desktop to accept network connections in system firewall settings. You may choose a specific port in desktop app to accept connections, by default it uses a random port every time. 4. Check that your WiFi router allows connections between devices (e.g., it may have an option for "device isolation", or similar). 5. If you see an error "certificate expired", please check that your device clocks are synchronized within a few seconds. @@ -312,7 +312,7 @@ If none of the suggestions work for you, you can create a separate profile on ea ### Does SimpleX support post quantum cryptography? -Yes! Please read more about quantum resistant encryption is added to SimpleX Chat and about various properties of end-to-end encryption in [this post](../blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md). +Yes! Please read more about how quantum-resistant encryption is added to SimpleX Chat and about various properties of end-to-end encryption in [this post](../blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md). ### Why can't I use the same profile on different devices? @@ -355,7 +355,7 @@ If the servers didn't upgrade, the messages would temporarily fail to deliver. Y With private routing enabled, instead of connecting to your contact's server directly, your client would "instruct" one of the known servers to forward the message, preventing the destination server from observing your IP address. -Your messages are additionally end-to-end encrypted between your client and the destination server, so that the forwarding server cannot observe the destination addresses and server responses – similarly to how onion routing work. Private message routing is, effectively, a two-hop onion packet routing. +Your messages are additionally end-to-end encrypted between your client and the destination server, so that the forwarding server cannot observe the destination addresses and server responses – similarly to how onion routing works. Private message routing is, effectively, a two-hop onion packet routing. Also, this connection is protected from man-in-the-middle attack by the forwarding server, as your client will validate destination server certificate using its fingerprint in the server address. @@ -375,7 +375,7 @@ Private message routing routes packets (each message is one 16kb packet), not so As each message uses its own random encryption key and random (non-sequential) identifier, the destination server cannot link multiple message queue addresses to the same client. At the same time, the forwarding server cannot observe which (and how many) addresses on the destination server your client sends messages to, thanks to e2e encryption between the client and destination server. In that regard, this design is similar to onion routing, but with per-packet anonymity, not per-circuit. -This design is similar to mixnets (e.g. [Nym network](https://nymtech.net)), and it is tailored to the needs of message routing, providing better transport anonymity that general purpose networks, like Tor or VPN. You still can use Tor or VPN to connect to known servers, to protect your IP address from them. +This design is similar to mixnets (e.g. [Nym network](https://nymtech.net)), and it is tailored to the needs of message routing, providing better transport anonymity than general-purpose networks, like Tor or VPN. You still can use Tor or VPN to connect to known servers, to protect your IP address from them. ### Why don't you embed Tor in SimpleX Chat app? diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 0cb855d729..c8fdd56bdd 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -4,6 +4,10 @@ Choosing a private messenger requires the understanding of many technical terms, While this glossary aims to be factual and objective, it is not completely unbiased. We designed SimpleX to be the most private, secure and resilient communication network, and some definitions reflect this view. +## 2-factor key exchange + +The ability of communication service to ensure the security of the [key agreement protocol](#key-agreement-protocol) against [man-in-the-middle](#man-in-the-middle-attack). + ## Address portability Similarly to [phone number portability](https://en.wikipedia.org/wiki/Local_number_portability) (the ability of the customer to transfer the service to another provider without changing the number), the address portability means the ability of a communication service customer to change the service provider without changing the service address. Many [federated networks](#federated-network) support SRV records to provide address portability, but allowing service users to set up their own domains for the addresses is not as commonly supported by the available server and client software as for email. @@ -89,7 +93,7 @@ Also known as perfect forward secrecy, it is a feature of a [key agreement proto ## Key agreement protocol -Also known as key exchange, it is a process of agreeing cryptographic keys between the sender and the recipient(s) of the message. It is required for [end-to-end encryption](#end-to-end-encryption) to work. +Also known as key exchange, it is a process of agreeing cryptographic keys between the sender and the recipient(s) of the message. It is required for [end-to-end encryption](#end-to-end-encryption) to work. Unless it is possible to secure the key exchange via [some second factor](#2-factor-key-exchange), e.g. security code verification, it can be vulnerable to [man-in-the-middle attack](#man-in-the-middle-attack). [Wikipedia](https://en.wikipedia.org/wiki/Key-agreement_protocol) @@ -143,7 +147,7 @@ SimpleX Clients also form a network using SMP relays and IP or some other overla [Wikipedia](https://en.wikipedia.org/wiki/Overlay_network) -# Non-repudiation +## Non-repudiation The property of the cryptographic or communication system that allows the recipient of the message to prove to any third party that the sender identified by some cryptographic key sent the message. It is the opposite to [repudiation](#repudiation). While in some context non-repudiation may be desirable (e.g., for contractually binding messages), in the context of private communications it may be undesirable. @@ -153,7 +157,7 @@ The property of the cryptographic or communication system that allows the recipi Generalizing [the definition](https://csrc.nist.gov/glossary/term/pairwise_pseudonymous_identifier) from NIST Digital Identity Guidelines, it is an opaque unguessable identifier generated by a service used to access a resource by only one party. -In the context of SimpleX network, these are the identifiers generated by SMP relays to access anonymous messaging queues, with a separate identifier (and access credential) for each accessing party: recipient, sender and and optional notifications subscriber. The same approach is used by XFTP relays to access file chunks, with separate identifiers (and access credentials) for sender and each recipient. +In the context of SimpleX network, these are the identifiers generated by SMP relays to access anonymous messaging queues, with a separate identifier (and access credential) for each accessing party: recipient, sender and an optional notifications subscriber. The same approach is used by XFTP relays to access file chunks, with separate identifiers (and access credentials) for sender and each recipient. ## Peer-to-peer @@ -169,11 +173,11 @@ The advantage is that the participants do not depend on any servers. There are [ ## Post-compromise security -Also known as break-in recovery, it is the quality of the end-to-end encryption scheme allowing to recover security against a passive attacker who observes encrypted messages after compromising one (or both) of the parties. Also known as recovery from compromise or break-in recovery. [Double-ratchet algorithm](#double-ratchet-algorithm) has this quality. +The quality of the end-to-end encryption scheme allowing to recover security against a passive attacker who observes encrypted messages after compromising one (or both) of the parties. Also known as recovery from compromise or break-in recovery. [Double-ratchet algorithm](#double-ratchet-algorithm) has this quality. ## Post-quantum cryptography -Any of the proposed cryptographic systems or algorithms that are thought to be secure against an attack by a quantum computer. It appears that as of 2023 there is no system or algorithm that is proven to be secure against such attacks, or even to be secure against attacks by massively parallel conventional computers, so a general recommendation is to use post-quantum cryptographic systems in combination with the traditional cryptographic systems. +Any of the proposed cryptographic systems or algorithms that are thought to be secure against an attack by a quantum computer. It appears that as of 2025 there is no system or algorithm that is *proven* to be secure against such attacks, or even to be secure against attacks by massively parallel conventional computers, so a general recommendation is to use post-quantum hybrid cryptography - combining post-quantum and traditional algorithms. [Wikipedia](https://en.wikipedia.org/wiki/Post-quantum_cryptography) diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index 2e428409df..edf3ba199b 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -6,31 +6,49 @@ layout: layouts/jobs.html # Join SimpleX Chat team -SimpleX Chat is a seed stage startup with a lot of user growth in 2022-2025, and a lot of exciting technical and product problems to solve to grow faster. - -We currently have 4 people in the team. - -We are looking for passionate and creative people to help us! +Join SimpleX Chat team to build the future of secure, private, and decentralized communications. ## Who we are looking for -### Mobile application developer +### iOS Engineer -You: -- created mobile applications for Android platforms as **your own full-time or side projects**, -- expertise with Android APIs, Kotlin and JetPack Compose framework, -- [a good taste](https://paulgraham.com/taste.html) for mobile apps design would be a bonus. +We are looking for an entrepreneurial iOS engineer. -It is not a full time job yet, we have some specific problems to solve in the Android app. If we are happy working together it is likely to evolve into a full-time job offer in 2026. +Please send your exceptional achievements related to iOS (created in your own free time, or via grants, or startups you co-founded, but not as part of employment or contract): +- popular iOS open-source libraries or frameworks. +- iOS apps you developed on your own or as part of 2-people team. +- technical publications about iOS engineering. -Please ONLY apply if you created and released your own apps (not as a job or contract for somebody else). +This is a full-time remote contract. You must be in UTC +/- 8 hours timezone. + +### Application Engineer + +We are looking for an entrepreneurial Android/Desktop applications engineer with an advanced expertise in: +- Kotlin Multiplatform. +- Jetpack Compose and JVM development. +- desktop applications for Linux, Mac and Windows. +- advanced knowledge of C/C++, Java, JavaScript and some other programming languages. + +Please send your exceptional engineering achievements (created in your own free time, or via grants, or startups you co-founded, but not as part of employment or contract): +- popular open-source libraries, frameworks or applications. +- apps you developed on your own or as part of 2-3 people team. +- technical publications about engineering. + +This is a full-time remote contract. You must be in UTC +/- 8 hours timezone. + +

Community Builder

+ +We are looking for an entrepreneurial Community Builder and Marketer, with successful crowdfunding experience. + +Please send your exceptional achievements related to community building, social media marketing, or crowdfunding (created in your free time or in startups you co-founded, but not as part of contract or employment): +- large, active online communities, social media accounts, podcasts, or YouTube channels. +- successful crowdfunding campaigns that raised significant funds. +- high-profile publications, interviews, or viral content pieces. + +This is a part-time remote contract. You must be in UTC +/- 8 hours timezone. ## How to join the team -1. [Install the app](https://github.com/simplex-chat/simplex-chat#install-the-app), try using it with the friends and [join some user groups](https://github.com/simplex-chat/simplex-chat#join-user-groups) – you will discover a lot of things that need improvements. +To apply, please [install SimpleX Chat app](./DOWNLOADS.md) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA). -2. Also look through [GitHub issues](https://github.com/simplex-chat/simplex-chat/issues) submitted by the users to see what would you want to contribute as a test. - -3. [Connect to us](https://smp4.simplex.im/a#IWCurmcnKDvfOzGrQdqlXjKinqkvO10a2q__nWBVG6c) via SimpleX Chat to chat about what you want to contribute and about joining the team. - -4. You can also email [jobs@simplex.chat](mailto:jobs@simplex.chat?subject=Join%20SimpleX%20Chat%20team) +**We do NOT review CVs at the initial stage, please only send the links to your achievements**. diff --git a/docs/LINKS.md b/docs/LINKS.md new file mode 100644 index 0000000000..46de314a75 --- /dev/null +++ b/docs/LINKS.md @@ -0,0 +1,4174 @@ +# Links to Community Publications + +## SimpleX Chat: Product Showcase - Removing User Identifiers From Messaging + +Help Net Security + +Review + +Help Net Security showcases SimpleX Chat as a free, private, open-source messenger that eliminates traditional user identifiers and stores data locally on devices. The article highlights end-to-end encrypted communications, contact addition through one-time links or QR codes, encrypted audio/video calls via WebRTC with hidden IP addresses, and security verification through comparable security codes between contacts. + +Image: help-net-security-product-showcase.jpg + +Language: English + +Date: Apr 29, 2026 + +https://www.helpnetsecurity.com/2026/04/29/product-showcase-simplex-chat-secure-messaging/ + +## Best Secure Messaging Apps: Signal vs Session vs SimpleX vs Briar + +State of Surveillance + +Comparison + +This secure messaging comparison guide evaluates Signal, Session, SimpleX, Briar, WhatsApp, and Telegram across criteria including phone number requirements, architecture, and metadata protection. SimpleX is highlighted as requiring no phone number, using a decentralized architecture with no metadata collection, and being best suited for maximum privacy, though the guide notes its small user base as a practical limitation. + +Image: state-of-surveillance-comparison.jpg + +Language: English + +Date: May 2026 + +https://stateofsurveillance.org/guides/basic/secure-messaging-comparison/ + +## Evgeny Poberezkin on SimpleX Private Chat + +Citadel Dispatch + +Podcast + +This Citadel Dispatch podcast episode features Evgeny Poberezkin discussing SimpleX Chat's radically different approach to user identity, where addresses are assigned to connections rather than endpoints. The conversation covers critiques of the MLS protocol, upcoming scalable channels to rival Telegram, and a sustainability model where large channels fund network operations. + +Image: citadel-dispatch-cd196.jpg + +Language: English + +Date: Mar 20, 2026 + +https://podcasts.apple.com/is/podcast/cd196-evgeny-poberezkin-simplex-private-chat/id1546393840?i=1000756411661 + +## The Messaging App With No User IDs + +(SimpleX Interview) + +Techlore + +Podcast + +Image: techlore-talks-simplex-interview.jpg + +Language: English + +Date: Jan 24, 2026 + +https://www.youtube.com/watch?v=hfzf0t8ZCK4 + +## Which Encrypted Messenger Is Best Secured? Signal, Session and SimpleX + +(Quelle messagerie chiffree est la mieux securisee? Signal, Session et SimpleX mais pas Whatsapp!) + +Nicolas Forcet + +Comparison + +This French-language article positions SimpleX between Signal and Session in a security hierarchy, noting its protocol design prevents servers from mapping social connections by using separate channels for incoming and outgoing messages. SimpleX is praised for stronger privacy protections including app-level locking, ephemeral messages, and Tor/VPN compatibility, though the author notes lower adoption rates may be a practical drawback. + +Image: nicolas-forcet-comparison.jpg + +Language: French + +Date: Jan 2, 2026 + +https://nicolasforcet.com/messagerie-chiffree-2026-signal-simplex-session/ + +## SimpleX Chat Review 2026: Open-Source Secure Messaging + +Darwin Dynamic + +Review + +This 2026 review describes SimpleX Chat as a strong privacy option that eliminates unique user IDs and features end-to-end encryption with decentralized routing. The reviewer notes a learning curve and ongoing development issues as drawbacks, but ultimately recommends it for privacy-conscious individuals willing to navigate a more complex interface than mainstream alternatives. + +Image: darwin-dynamic-review-2026.jpg + +Language: English + +Date: 2026 + +https://darwindynamic.com/simplex-chat-review-2026/ + +## The 5 Best Private Chat and Message Apps for 2026 + +Darwin Dynamic + +Comparison + +SimpleX Chat ranks third among five recommended private messaging apps for 2026. The article emphasizes that it requires no user identification, making it highly secure and anonymous, while noting that initial configuration can be challenging and occasional technical issues may occur. + +Image: darwin-dynamic-top5-2026.jpg + +Language: English + +Date: 2026 + +https://darwindynamic.com/5-best-private-chat-message-apps-for-2026/ + +## Best Decentralized Private Messengers in 2026 + +Factually + +Comparison + +This product comparison ranks SimpleX as the best choice for maximal anonymity and minimal metadata, describing its peer-to-peer design that avoids server-mediated trust and emphasizes anonymity. The review notes SimpleX's UI is minimal and focused on anonymity features, but acknowledges the app is younger, less battle-tested, and has a smaller user base compared to Signal and Matrix. + +Image: factually-decentralized-2026.jpg + +Language: English + +Date: 2026 + +https://factually.co/product-reviews/electronics-tech/best-decentralized-private-messengers-2026-signal-session-simplex-matrix-a6216a + +## 8 Best Secure Messaging Apps for Encrypted Chats in 2026 + +CloudSEK + +Comparison + +CloudSEK positions SimpleX as the best phone-number-free messaging app, highlighting its relay-based routing, zero metadata approach, and private invitation links instead of identity-based profiles. The article notes a trade-off between SimpleX's strong metadata protection and its smaller user community compared to more established platforms like Signal or Telegram. + +Image: cloudsek-best-secure-2026.jpg + +Language: English + +Date: 2026 + +https://www.cloudsek.com/knowledge-base/best-secure-messaging-apps + +## 10 Best Secure Messaging Apps You Should Check Out in 2026 + +Beebom + +Comparison + +Beebom lists SimpleX Chat third among the best secure messaging apps, calling it the "Best Minimal Secure Messaging App." The article highlights its lack of user IDs, incognito mode with random usernames, live message typing preview, screenshot blocking, and contact verification, noting it requires no email or phone number to use. + +Image: beebom-best-secure-2026.jpg + +Language: English + +Date: 2026 + +https://beebom.com/best-secure-messaging-apps/ + +## Decentralized Messengers: 8 WhatsApp and Telegram Alternatives 2026 + +(Децентрализованные мессенджеры: 8 альтернатив WhatsApp и Telegram в 2026 году) + +itforprof.com + +Comparison + +This Russian-language article describes SimpleX Chat as a decentralized platform that uses cryptographic keys instead of accounts for identification and employs Double Ratchet encryption with quantum-resistant extensions. The article notes that servers cannot access metadata about who communicates with whom, but warns that SimpleX is blocked by Roskomnadzor in Russia and requires a VPN to access. + +Image: itforprof-alternatives-2026.jpg + +Language: Russian + +Date: 2026 + +https://itforprof.com/blog/decentralizovannye-messendzhery/ + +## Release of SimpleX Chat 6.5 + +(Выпуск SimpleX Chat 6.5, ориентированный на консорциум и краудфандинг для независимости) + +OpenNet.ru + +News + +This Russian tech news site covers the SimpleX Chat 6.5 release, highlighting the introduction of channels with stateful messaging capabilities and the establishment of the SimpleX Network Consortium for network independence and governance. The update also brings improved web access features and SOCKS proxy support. + +Image: opennet-simplex-65.jpg + +Language: Russian + +Date: 2026 + +https://opennet.ru/65337/ + +## Vitalik Buterin Donates $765K in Ethereum to Privacy Messaging Apps + +Yahoo Finance + +News + +Yahoo Finance reports that Vitalik Buterin donated approximately $765,000 in Ethereum to privacy messaging apps Session and SimpleX. Buterin praised both apps for advancing permissionless account creation and metadata privacy, while acknowledging neither is perfect and both need improvements in user experience and security. + +Image: yahoo-finance-buterin.jpg + +Language: English + +Date: Nov 2025 + +https://finance.yahoo.com/news/vitalik-buterin-donates-765k-ethereum-190102367.html + +## Vitalik Buterin Supports Privacy-Focused Messaging Platforms With Significant Ethereum Donation + +Bitcoin.com News + +News + +Bitcoin.com reports that Vitalik Buterin donated 128 ETH (approximately $256,000) to SimpleX Chat and an equal amount to Session, totaling 256 ETH. Buterin emphasized the importance of permissionless account creation and metadata privacy, aiming to advance truly private messaging technologies that protect users from surveillance. + +Image: bitcoin-com-buterin.jpg + +Language: English + +Date: Nov 2025 + +https://news.bitcoin.com/vitalik-buterin-supports-privacy-focused-messaging-platforms-with-significant-ethereum-donation/ + +## Inside Vitalik's 256 ETH Grants: When Ethereum Falls, Privacy Rises + +CryptoSlate + +News + +CryptoSlate covers Vitalik Buterin's 256 ETH total grants to Session and SimpleX Chat, framing it as a signal that privacy infrastructure deserves funding when designed as a foundational architectural feature. The article highlights Buterin's support for metadata-resistant communication systems that operate entirely outside Ethereum's blockchain. + +Image: cryptoslate-buterin-analysis.jpg + +Language: English + +Date: Dec 2, 2025 + +https://cryptoslate.com/inside-vitaliks-256-eth-grants-when-eth-falls-privacy-rises/ + +## SimpleX Chat: The First Messaging App with No User Identifiers - Privacy by Design + +BrightCoding + +Review + +Bright Coding presents SimpleX Chat as a privacy-by-design platform that eliminates user identifiers entirely, using pairwise temporary identifiers for each conversation and end-to-end encryption with the double ratchet algorithm. The article highlights decentralized communication through user-hosted relay servers and spam prevention through opt-in contact invitations. + +Image: brightcoding-privacy-by-design.jpg + +Language: English + +Date: Sep 18, 2025 + +https://www.blog.brightcoding.dev/2025/09/18/simplex-chat-the-first-messaging-app-with-no-user-identifiers-privacy-by-design/ + +## SimpleX Chat 6.4.3: New Features and Jack Dorsey's Support + +(SimpleX Chat publie sa version 6.4.3 : une messagerie privee toujours plus aboutie) + +SysKB + +News + +This French tech site covers SimpleX Chat version 6.4.3, noting new features including bot support via commands, hypertext links in Markdown, and automatic removal of link tracking parameters. The article also mentions the project's $1.3 million funding round backed by Jack Dorsey in August 2024. + +Image: syskb-simplex-643.jpg + +Language: French + +Date: Aug 20, 2025 + +https://syskb.com/simplex-chat-6-4-3-nouveautes/ + +## Discover the Best Secure Messaging Apps in 2025 + +(Decouvrez les Meilleures Applications de Messagerie Securisee en 2025) + +Ca Marche Ca Fonctionne + +Comparison + +This French-language guide on secure messaging apps describes SimpleX Chat as minimalist while offering advanced features, particularly its incognito mode that generates random identifiers for each conversation. SimpleX receives less detailed coverage than Signal, which is positioned as the top security leader in the article. + +Image: camarchecafonctionne-secure-2025.jpg + +Language: French + +Date: Aug 26, 2025 + +https://www.camarchecafonctionne.com/decouvrez-les-meilleures-applications-de-messagerie-securisee-en-2025/ + +## SimpleX Chat Review - Secure and Private Messaging? + +HTR + +Review, Video + +Image: htr-simplex-review.jpg + +Language: English + +Date: Feb 10, 2025 + +https://www.youtube.com/watch?v=rGrF1M7x0Nk + +## Improving SimpleX With Evgeny From SimpleX and Daniel Keller From Flux + +Opt Out Podcast + +Podcast + +This Opt Out Podcast episode features Evgeny from SimpleX and Dan Keller from Flux discussing improvements to SimpleX Chat, including quantum-resistant encryption and privacy-preserving content moderation. The conversation also covers a new chat relay approach developed collaboratively between SimpleX and Flux, along with SimpleX's network operator monetization plans. + +Image: optout-improving-simplex.jpg + +Language: English + +Date: Jan 24, 2025 + +https://optoutpod.com/episodes/improving-simplex/ + +## Best WhatsApp Alternatives 2025 + +Tuta Blog + +Comparison + +Tuta designates SimpleX as the "best decentralized" WhatsApp alternative, highlighting that it requires no phone number or ID for registration and uses a decentralized network where each chat creates unique fingerprints to prevent connection mapping. The article calls it a must-try for those whose personal well-being and safety demand a greater amount of privacy, while noting its smaller user base compared to competitors. + +Image: tuta-whatsapp-alternatives.jpg + +Language: English + +Date: 2025 + +https://tuta.com/blog/best-whatsapp-alternatives-privacy + +## Best WhatsApp Alternatives for Privacy + +Proton Blog + +Comparison + +Proton's blog highlights SimpleX Chat's radical privacy approach of requiring no phone number, email, or username to create an account, using one-time invitation links instead of a central user directory. The article notes that SimpleX encrypts both messages and metadata and has undergone independent security audits, but faces limitations including a small user base and reports of misuse by extremist groups. + +Image: proton-whatsapp-alternatives.jpg + +Language: English + +Date: 2025 + +https://proton.me/blog/whatsapp-alternatives + +## What Is SimpleX Chat? + +(Qu'est-ce que SimpleX Chat) + +No Trust Verify + +Article + +This French-language article from NoTrustVerify describes SimpleX Chat as the first messaging platform with no identifiers that respects privacy by default. It explains the decentralized architecture using message queues instead of user accounts, and highlights features like Incognito Mode, Live Messages, and separate Chat Profiles for different conversations. + +Image: notrustverify-simplex-explainer.jpg + +Language: French + +Date: May 5, 2025 + +https://blog.notrustverify.ch/quest-ce-que-simplex-chat + +## SimpleX Chat: Privacy-Friendly Messenger for Maximum Privacy + +(SimpleX Chat - Datenschutzfreundlicher Messenger fur maximale Privatsphare) + +Digital Unplug Schweiz + +Guide + +This German-language privacy site describes SimpleX Chat as an open-source messenger that completely eliminates user IDs and stores data locally on devices. It details the Double-Ratchet Protocol encryption, temporary connection links, decentralized proxy server routing with optional Tor integration, and cross-platform availability across iOS, Android, Windows, macOS, and Linux. + +Image: digitalunplug-simplex-guide.jpg + +Language: German + +Date: 2025 (estimated) + +https://digitalunplug.ch/simplex.php + +## SimpleX Chat - Next Level Private Messaging + +Mental Outlaw + +Review, Video + +Image: mental-outlaw-simplex-review.jpg + +Language: English + +Date: Oct 1, 2024 + +https://www.youtube.com/watch?v=0cRu98XSap0 + +## Why We Recommend SimpleX Now + +Techlore + +Review, Video + +Image: techlore-recommend-simplex.jpg + +Language: English + +Date: Oct 7, 2024 + +https://www.youtube.com/watch?v=DVKe8U-n8fU + +## Open-Source SimpleX Chat Succeeds Where Telegram Failed + +Notebookcheck + +News + +Notebookcheck argues that SimpleX Chat addresses privacy failures inherent in Telegram, highlighting that SimpleX requires no phone number, uses end-to-end encryption with onion routing, and allows users to select their own servers. The article notes that SimpleX operates in incognito mode by default and that even SimpleX itself cannot determine where messages originate. + +Image: notebookcheck-simplex-succeeds.jpg + +Language: English + +Date: Oct 2, 2024 + +https://www.notebookcheck.net/Open-source-SimpleX-Chat-succeeds-where-Telegram-failed.896988.0.html + +## SimpleX Chat Group Chat Tested in Practice + +(SimpleX: Gruppenchat-Funktion im Praxistest) + +Kuketz IT-Security Blog + +Review + +German privacy blogger Mike Kuketz documents significant performance issues with SimpleX's group chat functionality, including substantial message delays where one message sent at 19:39 arrived the next day at 11:50. The founder acknowledged that groups were never designed for more than 50 users, and the article concludes SimpleX is unsuitable for group chats exceeding that limit. + +Image: kuketz-group-chat-test.jpg + +Language: German + +Date: Oct 14, 2024 + +https://www.kuketz-blog.de/simplex-gruppenchat-funktion-im-praxistest/ + +## My Experience With SimpleX Chat: Is It the Ultimate Open Source Private Messaging App? + +It's FOSS + +Review + +It's FOSS presents a highly positive experience with SimpleX Chat, praising its no-ID signup, quantum-resistant encryption, and intuitive messaging features. The main drawback noted was buggy video calls with audio and quality issues, but overall the reviewer concludes SimpleX sets a new standard for secure communication. + +Image: itsfoss-simplex-review.jpg + +Language: English + +Date: Dec 2024 + +https://itsfoss.com/news/simplex-chat/ + +## SimpleX Chat: A Decentralized Messaging App + +(SimpleX Chat, un'app di messaggistica decentralizzata) + +Le Alternative + +Review + +This Italian article reviews SimpleX Chat as a completely decentralized messaging app emphasizing anonymity and privacy, with no user IDs and end-to-end encrypted messages that remain on servers only until delivery. It notes support for audio/video calls, message editing, and self-hosted servers, while flagging drawbacks like high battery consumption and account recovery difficulties without backups. + +Image: lealternative-simplex-review.jpg + +Language: Italian + +Date: Sep 18, 2024 + +https://blog.lealternative.net/2024/09/18/simplex-chat-unapp-di-messaggistica-decentralizzata/ + +## SimpleX: The Revolution of Private Messaging + +(SimpleX: La Rivoluzione della Messaggistica Privata) + +aiutocomputerhelp.it + +Review + +This Italian article describes SimpleX as a radical messaging platform that eliminates permanent user identities entirely. Communication occurs through encrypted invitation links creating isolated, non-traceable channels, with no central server knowing anything about users and the ability to self-host relay servers. + +Image: aiutocomputerhelp-simplex-revolution.jpg + +Language: Italian + +Date: Jul 16, 2025 + +https://www.aiutocomputerhelp.it/simplex-la-rivoluzione-della-messaggistica-privata/ + +## SimpleX: Messaging With Total Privacy + +(SimpleX: mensajeria con total privacidad) + +VeraSoul + +Review + +This Spanish-language article presents SimpleX Chat as a highly secure messaging app that requires no user IDs, unlike Telegram or Signal. It highlights the app's use of its own SMP protocol, end-to-end encryption, decentralized architecture, and the option for users to choose between SimpleX servers or self-host alternatives. + +Image: verasoul-simplex-review.jpg + +Language: Spanish + +Date: Nov 19, 2024 + +https://verasoul.com/simplex-mensajeria-con-total-privacidad.html + +## SimpleX Chat: The Messaging App Every Privacy Enthusiast Should Use + +(SimpleX Chat la aplicacion de mensajeria que todo entusiasta de la privacidad deberia utilizar) + +GatoOscuro + +Review + +This Spanish-language article recommends SimpleX Chat as an exceptional privacy platform that eliminates metadata surveillance risks present in alternatives like Signal. The author highlights out-of-band key exchange making man-in-the-middle attacks practically impossible, anonymous peer identifiers, self-destructing messages, and a decentralized client-centric architecture. + +Image: gatooscuro-simplex-review.jpg + +Language: Spanish + +Date: 2022 (estimated) + +https://gatooscuro.xyz/simplex-chat-la-aplicacion-de-mensajeria-que-todo-entusiasta-de-la-privacidad-deberia-utilizar/ + +## Interview With the Author of SimpleX Chat: The Most Secure Messaging by Design + +(Entrevista con el autor de SimpleX Chat: la mensajeria mas segura por diseno) + +GatoOscuro + +Interview + +This Spanish-language interview with SimpleX founder Evgeny Poberezkin covers the project's approach to eliminating user identifiers, addresses concerns about government surveillance and backdoors, and discusses funding from Village Global without compromising independence. Poberezkin emphasizes that adoption, word-of-mouth promotion, and user donations are essential for the project's survival. + +Image: gatooscuro-simplex-interview.jpg + +Language: Spanish + +Date: 2024 + +https://gatooscuro.xyz/entrevista-con-el-autor-de-simplex-chat-la-mensajeria-mas-segura-por-diseno/ + +## SimpleX Chat Messaging App + +(App de mensajeria SimpleX Chat) + +Alt43.es + +Review + +This Spanish-language Medium article reviews SimpleX Chat's security features including end-to-end encryption, open-source code, and the ability to register without a phone number or email. It explains that users exchange temporary anonymous identifiers via QR codes or one-time links, with all data stored only on client devices using an encrypted, portable database format. + +Image: burp-simplex-review.jpg + +Language: Spanish + +Date: Aug 1, 2023 + +https://medium.com/@burp.es/app-de-mensajer%C3%ADa-simplex-chat-c0ad46b50f1f + +## SimpleX Is a Revolutionary Messaging Platform That Redefines Privacy + +(O SimpleXchat e uma plataforma de mensagens revolucionarias que redefinem a privacidade) + +Alex Emidio + +Review + +This Portuguese-language Medium article describes SimpleX Chat as a revolutionary privacy platform that eliminates user IDs entirely, requiring no phone numbers or email addresses. It highlights end-to-end encrypted messages, group chats, file sharing, disappearing messages, and encrypted audio/video calls, all operating on decentralized servers using unidirectional message queues. + +Image: alex-emidio-simplex-review.jpg + +Language: Portuguese + +Date: Oct 10, 2023 + +https://medium.com/@alexemidio/o-simplexchat-%C3%A9-uma-plataforma-de-mensagens-revolucion%C3%A1rias-que-redefinem-a-privacidade-sendo-o-4690f2a1b2d4 + +## SimpleX: The Chat Network That Preserves Metadata Privacy + +(SimpleX, a rede de bate-papo que preserva a privacidade de metadados) + +Edivaldo Brito + +Review + +This Portuguese article describes SimpleX as a decentralized, open-source chat network that preserves metadata privacy by using disposable relay nodes and assigning no user identifiers for message routing. It explains that SimpleX employs two layers of end-to-end encryption - double ratchet for forward secrecy and a second layer to protect metadata - along with unidirectional (simplex) message queues that combine the advantages of P2P and server-based architectures. + +Image: edivaldo-brito-simplex-review.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.edivaldobrito.com.br/simplex-a-rede-de-bate-papo-que-preserva-a-privacidade-de-metadados/ + +## SimpleX Is an Alternative to Telegram Focused on Privacy + +(SimpleX e uma alternativa ao Telegram com foco na privacidade) + +Midia Segura + +Review + +This Portuguese-language article presents SimpleX as a privacy-focused alternative to Telegram, noting increased user migration following Pavel Durov's arrest and Telegram's policy changes. It highlights SimpleX as the first messaging app without a user ID, email, or phone number, with backing from Jack Dorsey. + +Image: midia-segura-simplex-review.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://midiasegura.com/simplex-e-uma-alternativa-ao-telegram-com-foco-na-privacidade/ + +## SimpleX Chat: The First Messenger Without User ID + +(Simplex Chat - O primeiro mensageiro sem ID de usuario) + +Rafael Mesquita / TabNews + +Article + +This Portuguese-language article highlights SimpleX Chat as a privacy-focused messenger that uses temporary anonymous pairwise message queue identifiers instead of user IDs, phone numbers, or usernames. It explains that this design prevents metadata correlation attacks and that user profiles are stored only on devices, with dual-layer end-to-end encryption and optional Tor connections. + +Image: tabnews-simplex-first-messenger.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.tabnews.com.br/RafaelMesquita/simplex-chat-o-primeiro-mensageiro-sem-id-de-usuario + +## SimpleX Chat: A Revolutionary Tool for Private and Even Anonymous Communications + +(SimpleX Chat: A Ferramenta Revolucionaria para Comunicacoes Privadas e ate Anonimas) + +Coach De Osasco + +Review, Video + +Image: portuguese-simplex-revolutionary.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=fUwPGhSYlLY + +## Exploring SimpleX Chat: A Scriptable, Decentralized Messaging App + +Rick Carlino + +Article + +The author explores SimpleX Chat's bot-building capabilities using its CLI application with WebSocket support, finding the process remarkably straightforward and reminiscent of writing IRC scripts with a better UX. The article includes a functional TypeScript example demonstrating how to build chatbots through JSON commands, requiring minimal setup of just downloading an executable and connecting via WebSocket. + +Image: rickcarlino-simplex-bots.jpg + +Language: English + +Date: 2024 + +https://rickcarlino.com/2024/simplex-chat-bots.html + +## Session and SimpleX: Encrypted Messenger Comparison + +FreedomNode + +Comparison + +FreedomNode compares SimpleX and Session, noting that SimpleX takes privacy further by eliminating user identifiers entirely and using temporary anonymous message queue identifiers for each conversation. The article notes SimpleX represents a more experimental privacy frontier while Session provides greater stability and cross-device compatibility, with SimpleX having limitations around desktop support and requiring both parties to be online to establish connections. + +Image: freedomnode-session-simplex.jpg + +Language: English + +Date: Oct 29, 2023 + +https://freedomnode.com/blog/session-and-simplex-encrypted-messenger-comparison/ + +## SimpleX: The First Messenger Without User Identifiers + +(SimpleX - первый мессенджер без идентификаторов пользователей) + +Habr / Privacy Accelerator + +Article + +This Russian-language Habr article introduces SimpleX Chat as a privacy-focused messenger that operates without user identifiers, using separate message queue identifiers for each contact. It highlights the SimpleX Messaging Protocol with end-to-end encryption, Tor routing support, open-source code, and a professional security audit by Trail of Bits. + +Image: habr-simplex-first-messenger.jpg + +Language: Russian + +Date: Dec 2022 + +https://habr.com/ru/companies/privacyaccelerator/articles/705778/ + +## An Anonymous Messenger: A Mandatory Standard for Every Person + +(Анонимный мессенджер - обязательный стандарт для каждого человека) + +Habr + +Article + +This Russian-language article argues that anonymous messaging with strong encryption should be a standard necessity for everyone, as governments increasingly surveil communications. SimpleX Chat is highlighted as innovative for being the first messenger without user identifiers, using temporary anonymous paired identifiers for each connection that make it impossible to correlate anonymous profiles with real identities. + +Image: habr-anonymous-standard.jpg + +Language: Russian + +Date: Nov 2024 + +https://habr.com/ru/articles/851866/ + +## Open-Source SimpleX Chat Succeeds Where Telegram Failed + +(Чат SimpleX с открытым исходным кодом преуспел там, где Telegram потерпел неудачу) + +Notebookcheck Russia + +News + +This Russian-language Notebookcheck article highlights how SimpleX Chat addresses privacy concerns that Telegram failed to handle, including not requiring phone numbers, using one-way onion routing, allowing users to select their own servers, and providing end-to-end encryption with local device encryption. Unlike Telegram, which made changes after government pressure, SimpleX maintains stronger anonymity protections. + +Image: notebookcheck-ru-simplex.jpg + +Language: Russian + +Date: Oct 3, 2024 + +https://www.notebookcheck-ru.com/CHat-SimpleX-s-otkrytym-iskhodnym-kodom-preuspel-tam-gde-Telegram-poterpel-neudachu.897103.0.html + +## Messenger for Paranoids: Configuring SimpleX Chat + +(Мессенджер для параноиков. Настраиваем SimpleX Chat) + +Первый отдел + +Guide, Video + +Image: russian-paranoid-messenger-tutorial.jpg + +Language: Russian + +Date: Nov 3, 2024 + +https://www.youtube.com/watch?v=3UcejFJ3TY0 + +## SimpleX Chat: A Damn Private Messaging Service + +(SimpleX Chat une messagerie sacrement privee) + +Siksik + +Review + +Image: siksik-simplex-review.jpg + +Language: French + +Date: 2024 (estimated) + +https://siksik.org/simplex-chat-une-messagerie-sacrement-privee/ + +## SimpleX Chat: A New Instant Messaging Application + +(SimpleX Chat: Une Nouvelle Application de Messagerie Instantanee) + +AEKONE + +Review + +Image: aekone-simplex-review.jpg + +Language: French + +Date: 2024 (estimated) + +https://aek.one/simplex-chat-une-nouvelle-application-de-messagerie-instantannee/ + +## SimpleX Chat Review + +Freedom.Tech + +Review + +Freedom.tech's review praises SimpleX Chat's beautiful UI, metadata protection through its peer-to-relay architecture, double ratchet encryption, and incognito mode for pseudonymous communication. The review identifies key weaknesses including intermittent notifications on both iOS and Android and flaky audio/video calls in beta, but concludes it is a fantastic tool ideal for activists, journalists, and privacy-focused users. + +Image: freedom-tech-simplex-review.jpg + +Language: English + +Date: Sep 26, 2023 + +https://freedom.tech/simplex-chat-review/ + +## SimpleX Raises $1.3M From Jack Dorsey and Asymmetric VC, Releases Chat v6.0 + +NoBs Bitcoin + +News + +No BS Bitcoin reports that SimpleX Chat secured $1.3 million in pre-seed investment led by Jack Dorsey and Asymmetric Capital Partners. The v6.0 release includes protocol improvements reducing the messages needed for users to connect by half, a redesigned mobile interface, image blur options, and improved moderation tools supporting up to 20 message selections. + +Image: nobsbitcoin-funding-v60.jpg + +Language: English + +Date: Aug 2024 + +https://www.nobsbitcoin.com/simplex-chat-v6-0/ + +## SimpleX Chat v6.1: Better Calls, iOS Notifications, UX Improvements + +NoBs Bitcoin + +News + +No BS Bitcoin covers SimpleX Chat v6.1, which enhances call functionality with camera activation and screen sharing from the desktop app during voice calls. The release also resolves iOS notification delivery problems, improves connectivity, and features a redesigned conversation layout with faster message operations. + +Image: nobsbitcoin-v61.jpg + +Language: English + +Date: Oct 2024 + +https://www.nobsbitcoin.com/simplex-chat-v6-1-0/ + +## SimpleX Chat v5.7: Quantum Resistant End-to-End Encryption + +NoBs Bitcoin + +News + +No BS Bitcoin reports that SimpleX Chat v5.7 introduced quantum-resistant end-to-end encryption for all contacts and message forwarding without revealing the source. The article notes planned third-party security audits and the team's request for community donations to help cover audit costs exceeding $50,000. + +Image: nobsbitcoin-v57-quantum.jpg + +Language: English + +Date: Apr 2024 + +https://www.nobsbitcoin.com/simplex-chat-v5-7-0/ + +## SimpleX Chat v5.8: Private Message Routing + +NoBs Bitcoin + +News + +No BS Bitcoin covers SimpleX Chat v5.8's introduction of private message routing, described as a 2-hop onion routing protocol inspired by Tor that protects both users' IP addresses and transport sessions. The update also adds file reception protections from unknown servers, chat themes with wallpapers, and improved group permissions for admins. + +Image: nobsbitcoin-v58-routing.jpg + +Language: English + +Date: Jun 2024 + +https://www.nobsbitcoin.com/simplex-chat-v5-8/ + +## SimpleX Launches Quantum-Resistant Encryption in Beta + +Reclaim the Net + +News + +Reclaim The Net reports that SimpleX Chat launched a beta version of quantum-resistant encryption, implementing post-quantum cryptography with an improved quantum-resistant double ratchet algorithm. The article notes the use of fixed 16kb block sizes and lossless compression to prevent traffic analysis, with the feature currently optional for users. + +Image: reclaimthenet-quantum-beta.jpg + +Language: English + +Date: Apr 2, 2024 + +https://reclaimthenet.org/simplex-chat-launches-quantum-resistant-encryption-in-beta + +## SimpleX Introduces Enhanced IP Privacy Measures + +Reclaim the Net + +News + +Reclaim The Net covers SimpleX Chat v5.8's private message routing protocol where the forwarding relay is chosen by the sender and the second by the recipient, ensuring neither party can observe the other's IP address. The developers rejected embedding Tor due to latency and metadata correlation issues, instead building their own solution that provides IP protection by default. + +Image: reclaimthenet-ip-privacy.jpg + +Language: English + +Date: Jun 12, 2024 + +https://reclaimthenet.org/simplex-introduces-enhanced-ip-privacy-measures + +## We Found the Most Incognito iPhone Messaging App + +(On a trouve l'app de messagerie iPhone la plus incognito) + +iPhon.fr + +Article + +This French iPhone blog highlights SimpleX Chat as the most confidential messaging application on iOS, featuring no user identification requirements, encrypted metadata and profile hiding, and decentralized infrastructure allowing users to run it on personal servers. Contacts are established via unique QR codes rather than phone numbers or usernames. + +Image: iphon-fr-most-incognito.jpg + +Language: French + +Date: Jul 15, 2023 + +https://www.iphon.fr/post/on-a-trouve-lapp-de-messagerie-iphone-la-plus-incognito + +## SimpleX: Secure Messaging With Self-Hosted Server + +(Simplex - Messagerie securisee avec serveur auto-heberge) + +Paf LeGeek + +Guide, Video + +Image: paflegeek-simplex-selfhost.jpg + +Language: French + +Date: Jun 19, 2023 + +https://www.youtube.com/watch?v=66URjJ1RkeM + +## SimpleX Chat and How Privacy Aligns With the Future of Computing + +Opt Out Podcast + +Podcast + +This Opt Out Podcast episode features SimpleX Chat founder Evgeny discussing how privacy aligns with the future of computing. The conversation covers data sovereignty, SimpleX's decentralized architecture, and the project's emphasis on user control, with resources provided on running independent servers. + +Image: optout-simplex-s3e02.jpg + +Language: English + +Date: Feb 27, 2023 + +https://optoutpod.com/episodes/s3e02-simplexchat/ + +## SimpleX Chat: Messenger Without UserID With Maximum Protection + +(SimpleX Chat: мессенджер без UserID с максимальной защитой переписки) + +Теплица социальных технологий + +Review, Video + +Image: russian-simplex-max-protection.jpg + +Language: Russian + +Date: Aug 8, 2023 + +https://www.youtube.com/watch?v=g9HGLNCkEyk + +## SimpleX Chat: Overview of Main Functions + +(SimpleX Chat #2: обзор основных функций) + +Теплица социальных технологий + +Review, Video + +Image: russian-simplex-overview-functions.jpg + +Language: Russian + +Date: Aug 10, 2023 + +https://www.youtube.com/watch?v=fp1QUPNkxKI + +## SimpleX Chat: Anonymous Messenger Without ID + +(SimpleX CHAT - анонимный мессенджер без ID) + +Чёрный Треугольник + +Review, Video + +Image: russian-simplex-anonymous-no-id.jpg + +Language: Russian + +Date: Aug 25, 2023 + +https://www.youtube.com/watch?v=Ecx5jGUn-hQ + +## SimpleX Chat: The Ultra-Private Messaging App Almost Nobody Knows + +(SimpleX Chat la app de mensajeria ultra privada que casi nadie conoce) + +RD CIPHER + +Review, Video + +Image: spanish-simplex-ultra-private.jpg + +Language: Spanish + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=5vroKXgVZ3Q + +## SimpleX: Messaging WITHOUT Identifiers + +(SimpleX: mensajeria SIN identificadores) + +Elurk Informatica + +Review, Video + +Image: spanish-simplex-sin-identificadores.jpg + +Language: Spanish + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=Uit79EFxTAs + +## An Overview of Privacy-Focused, Decentralized Instant Messengers + +Marius + +Article + +This privacy-focused blog describes SimpleX as an open-source, decentralized instant messenger that lacks fixed user identifiers, requiring neither a phone number nor a username. However, the author withdrew their recommendation after SimpleX received venture capital funding from Jack Dorsey in August 2024, citing concerns about the investment's influence. + +Image: marius-privacy-messengers-overview.jpg + +Language: English + +Date: 2024 (estimated) + +https://xn--gckvb8fzb.com/an-overview-of-privacy-focused-decentralized-instant-messengers/ + +## What Is SimpleX Chat? + +No Trust Verify / Medium + +Article + +NoTrustVerify describes SimpleX Chat as the first messaging platform that requires no login and respects privacy by default, operating through a decentralized architecture using one-way message queues rather than centralized servers. The article discusses incognito mode, live message typing indicators, and separate chat profiles, while noting challenges around multi-device synchronization and large group management. + +Image: notrustverify-what-is-simplex.jpg + +Language: English + +Date: Jun 2023 + +https://medium.com/notrustverify/what-is-simplex-chat-11124d39a318 + +## SimpleX Chat v5.4: Link Mobile and Desktop Apps via Quantum-Resistant Protocol + +NoBs Bitcoin + +News + +No BS Bitcoin covers SimpleX Chat v5.4, which introduced the ability to link mobile and desktop apps via a secure quantum-resistant protocol on local networks. The update also improved group functionality with faster joining, incognito profile support for groups, and added screen sharing for video calls on desktop. + +Image: nobsbitcoin-v54-desktop.jpg + +Language: English + +Date: Nov 2023 + +https://www.nobsbitcoin.com/simplex-chat-v5-4/ + +## SimpleX Chat v5.3: Desktop App, Local File Encryption and Improved Groups + +NoBs Bitcoin + +News + +No BS Bitcoin reports on SimpleX Chat v5.3, which introduced a new desktop app, a directory service with group improvements, and encrypted local files and media with forward secrecy. The release also achieved a 40% reduction in memory usage and added new privacy controls for message visibility. + +Image: nobsbitcoin-v53-desktop.jpg + +Language: English + +Date: Sep 2023 + +https://www.nobsbitcoin.com/simplex-chat-v5-3/ + +## Haskell in Production: SimpleX + +Serokell + +Interview + +Serokell interviews SimpleX Chat creator Evgeny Poberezkin about building the platform in Haskell, chosen for its strength in highly concurrent communication applications with features like green threads and transactional memory. The article discusses challenges of compiling Haskell to mobile devices and the project's exploration of monetization through optional subscriptions while remaining fully open-source. + +Image: serokell-haskell-simplex.jpg + +Language: English + +Date: May 17, 2022 + +https://serokell.io/blog/haskell-in-production-simplex + +## SimpleX: Impressions From the Messenger Without Identifiers + +(SimpleX: Eindrucke vom Messenger ohne Identifier) + +Kuketz IT-Security Blog + +Review + +German privacy blogger Mike Kuketz rates SimpleX as "conditionally recommended," praising its innovative no-identifier design and Tor support. He identifies weaknesses including the absence of contact verification, high battery consumption, and client instability, though notes the developer indicated these issues are being addressed. + +Image: kuketz-simplex-review.jpg + +Language: German + +Date: Dec 2, 2022 + +https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/ + +## Decentralized, Anonymous, Encrypted: SimpleX Messenger Now Also for Smartphones + +(Dezentral, anonym, verschlusselt: SimpleX-Messenger jetzt auch furs Smartphone) + +Heise Online + +News + +Heise reports on SimpleX Chat releasing smartphone apps for iPhone and Android after initially being available only as a command-line application. The article highlights the decentralized architecture where servers cannot know who communicated with whom, with users sharing QR codes to establish encrypted connections. + +Image: heise-simplex-smartphone.jpg + +Language: German + +Date: Mar 9, 2022 + +https://www.heise.de/news/Dezentral-anonym-verschluesselt-SimpleX-Messenger-jetzt-auch-fuers-Smartphone-6544488.html + +## So Apple Won't Read Along: SimpleX Chat Updated to Version 3 + +(Damit Apple nichts mitliest: SimpleX-Chat aktualisiert auf Version 3) + +Heise Online + +News + +Heise covers SimpleX Chat version 3's privacy-focused iOS push notifications that contain no information about contacts or chat content, preventing any data from reaching Apple's servers. The update also introduced database export/import functionality and faster message transmission while maintaining backward compatibility. + +Image: heise-simplex-v3-apple.jpg + +Language: German + +Date: Jul 12, 2022 + +https://www.heise.de/news/Damit-Apple-nichts-mitliest-SimpleX-Chat-aktualisiert-auf-Version-3-7170288.html + +## Open-Source Messenger SimpleX: Anonymous and Now Also Private Chatting + +(Open-Source-Messenger SimpleX: Anonym und jetzt auch privat chatten) + +Heise Online + +News + +Heise reports on SimpleX Chat version 4.0 introducing private server authentication through password protection, allowing server operators to control who can receive messages while maintaining the platform's anonymous design. Previously all chat servers were public, but administrators can now restrict access by sharing passwords with intended users. + +Image: heise-simplex-v4-private.jpg + +Language: German + +Date: Nov 29, 2022 + +https://www.heise.de/news/Open-Source-Messenger-SimpleX-anonym-und-jetzt-auch-privat-chatten-7359889.html + +## SimpleX 1.0.0: Decentralized, Privacy-Respecting and Encrypted Chat + +(SimpleX 1.0.0: Dezentraler, Privatsphare achtender und verschlusselter Chat) + +Heise Online + +News + +Heise covers SimpleX Chat reaching version 1.0.0, marking its protocol as stable with guaranteed compatibility for future releases. The article explains the dual-layer end-to-end encryption using a double-ratchet mechanism that changes keys for each message, with decentralized servers routing messages without storing user data or metadata. + +Image: heise-simplex-100-release.jpg + +Language: German + +Date: Jan 13, 2022 + +https://www.heise.de/news/SimpleX-1-0-0-Dezentraler-Privatsphaere-achtender-und-verschluesselter-Chat-6325990.html + +## SimpleX Chat Wants to Offer Complete Privacy + +(SimpleX Chat will vollstandige Privatsphare bieten) + +iphone-ticker.de + +News + +German iPhone blog iphone-ticker covers SimpleX Chat's privacy approach of establishing direct connections through shared QR codes or links for end-to-end encrypted messaging. The article highlights version 3.0's push notifications that reveal nothing about chat content or contacts, and the addition of encrypted audio and video calling. + +Image: iphone-ticker-simplex-privacy.jpg + +Language: German + +Date: Jul 13, 2022 + +https://www.iphone-ticker.de/simplex-chat-will-vollstaendige-privatsphaere-bieten-194389/ + +## SimpleX Chat Now Also for Smartphones + +(SimpleX-Chat jetzt auch fur Smartphones) + +GNU/Linux.ch + +News + +This Swiss GNU/Linux community site reports on SimpleX Chat's release of smartphone applications for iPhone and Android, expanding beyond its command-line interface. The article emphasizes end-to-end encryption, decentralized architecture, and QR code-based peer-to-peer connections without routing data through SimpleX servers. + +Image: gnulinux-ch-simplex-smartphones.jpg + +Language: German + +Date: Mar 10, 2022 + +https://gnulinux.ch/simplex-chat-smartphones + +## SimpleX Chat: A Star on the Open-Source Messenger Horizon + +(SimpleX Chat - Frohlockender Stern am Open-Source Messenger Himmel) + +hackspoiler.de + +Review + +This German security blog highlights SimpleX Chat as a privacy-focused open-source messenger requiring no user ID, with end-to-end encryption for all communications including voice messages, audio/video calls, and self-destructing messages. The article positions it as a secure alternative to proprietary platforms, comparing it to Signal and Session under the AGPL V3 license. + +Image: hackspoiler-simplex-star.jpg + +Language: German + +Date: 2023 (estimated) + +https://hackspoiler.de/simplex-chat-verschluesselter-opensource-messenger/ + +## Good Messengers Instead of WhatsApp + +(Gute Messenger statt WhatsApp) + +Digitalcourage + +Guide + +German digital rights organization Digitalcourage describes SimpleX as a relatively new open-source messenger launched in 2022 that uses distributed servers with temporary connection identifiers rather than phone numbers or usernames. While it functions reliably and supports voice messages, file transfers, and video calls, the organization only conditionally recommends it due to its youth as a project and limited track record. + +Image: digitalcourage-simplex-recommendation.jpg + +Language: German + +Date: 2023 (estimated) + +https://digitalcourage.de/digitale-selbstverteidigung/messenger + +## Messenger SimpleX Protects Privacy and Is Open Source + +(Messenger SimpleX schutzt Privatsphare und ist Open Source) + +Linux-Magazin + +News + +German Linux Magazin reports on SimpleX as an anonymous, encrypted open-source messenger (AGPLv3) that uses no central server to track connections - servers only relay messages, and anyone can self-host a message broker using SimpleXMQ. The article highlights SimpleX's double ratchet end-to-end encryption with forward secrecy and metadata concealment, noting that at the time of writing only a command-line client was available with mobile apps in development. + +Image: linux-magazin-simplex-privacy.jpg + +Language: German + +Date: 2022 (estimated) + +https://www.linux-magazin.de/news/messenger-simplex-schuetzt-privatsphaere-und-ist-open-source/ + +## SimpleX + +freie-messenger.de + +Article + +This German free messenger comparison site provides a comprehensive overview of SimpleX Chat, noting its distinctive lack of user identifiers and end-to-end encryption with data stored only on client devices. The assessment concludes that while SimpleX is an innovative privacy-focused messenger suitable for individuals and activists, it has limitations for business use and lacks interoperability with standard protocols like XMPP. + +Image: freie-messenger-simplex.jpg + +Language: German + +Date: updated regularly + +https://www.freie-messenger.de/simplex/ + +## The Best Private Instant Messengers + +Privacy Guides + +Review + +Privacy Guides recommends SimpleX Chat as an instant messenger that does not depend on any unique identifiers such as phone numbers or usernames. The page notes its double ratchet encryption with quantum resistance, metadata protection through unidirectional message delivery queues, and independent security audits conducted in July 2024 and October 2022. + +Image: privacy-guides-recommendation.jpg + +Language: English + +Date: updated regularly + +https://www.privacyguides.org/en/real-time-communication/ + +## SimpleX + +Whonix Wiki + +Review + +The Whonix wiki describes SimpleX as a general-purpose instant messaging client with both client and server released as Freedom Software under GNU AGPLv3. It highlights mandatory end-to-end encryption, quantum-resistant encryption by default for one-on-one chats, and incognito mode, while providing detailed Flatpak installation instructions and warning that users must export their chat database before shutdown to avoid losing all data. + +Image: whonix-simplex-recommendation.jpg + +Language: English + +Date: updated regularly + +https://www.whonix.org/wiki/SimpleX + +## Private Messaging: Wikilibriste Recommendations + +(Les messageries privees) + +Wikilibriste + +Review + +This French privacy guide recommends SimpleX for users seeking a strict, intentionally confidential messaging model. It highlights SimpleX's unique reception addresses per contact, Double Ratchet encryption, incognito mode, and the absence of user identifiers, while noting that user IP remains visible to relay servers unless Tor is used. + +Image: wikilibriste-simplex-recommendation.jpg + +Language: French + +Date: updated regularly + +https://www.wikilibriste.fr/messagerie-vie-privee/ + +## Messenger Matrix + +Kuketz IT-Security Blog + +Comparison + +This comprehensive German messenger comparison table rates SimpleX on dozens of technical criteria. It notes SimpleX's decentralized architecture, post-quantum encryption, NaCl/Signal Protocol cryptography, and Trail of Bits security audit, but gives it a "very limited" recommendation and categorizes it as targeting advanced users rather than beginners. + +Image: kuketz-messenger-matrix.jpg + +Language: German + +Date: updated regularly + +https://www.messenger-matrix.de/messenger-matrix.html + +## Communicate Online in Complete Anonymity + +(SimpleX Chat: comunicare online in totale anonimato) + +Web Apps Magazine + +Article + +This Italian article presents SimpleX as a paradigm shift in private messaging, highlighting its lack of phone number requirements, decentralized relay system, and metadata protection. It positions the app for journalists, activists, and privacy-conscious users, while honestly noting the tradeoff of losing all contacts if the phone is lost without backups. + +Image: webappsmagazine-simplex-anonymity.jpg + +Language: Italian + +Date: Feb 15, 2026 + +https://webappsmagazine.blogspot.com/2026/02/simplex-chat-comunicare-online-in.html + + +## SimpleX Chat: A Privacy-Optimized Chat App - Why Telegram Users Are Leaving + +(SimpleX Chat, 개인 정보 보호에 최적화된 채팅 앱, 텔레그램 사용자들이 떠나는 이유) + +Billionnapkin + +Review + +This article frames SimpleX as a privacy-optimized alternative to Telegram, arguing users are migrating due to Telegram's changed data access policies following CEO Pavel Durov's legal issues. The tone is promotional but balanced, acknowledging concerns about potential misuse while endorsing SimpleX for journalists, activists, and privacy-conscious professionals. + +Image: billionnapkin-simplex-review.jpg + +Language: Korean + +Date: Oct 8, 2024 + +https://billionnapkin.com/simplex-chat/ + +## Telegram Alternative Apps: Stronger Anonymous Messenger Recommendations + +(텔레그램 대체 앱: 더 강력한 익명 메신저 추천) + +NetXHack + +Guide + +This Korean-language comparison of Telegram alternatives describes SimpleX as an identifier-free distributed framework that requires no phone numbers or account IDs. The article notes SimpleX's relay servers remain ignorant of senders and receivers, but acknowledges it prioritizes anonymity over user experience and is better suited for small groups needing secure communication. + +Image: netxhack-telegram-alternatives.jpg + +Language: Korean + +Date: Dec 17, 2025 (updated) + +https://netxhack.com/apps/foss/alternatives-to-telegram/ + +## Users Flocking to Telegram Alternative Apps + +(텔레그램 대안 앱으로 몰려가는 이용자들) + +eKoreaNews + +News + +This Korean news article reports on users flocking to Telegram alternatives, positioning SimpleX as the first messenger without user IDs. It notes SimpleX's decentralized architecture and mentions its early funding support from Jack Dorsey, while contextualizing the migration trend against Telegram's policy changes allowing authorities access to user data. + +Image: ekoreanews-telegram-alternatives.jpg + +Language: Korean + +Date: Oct 11, 2024 + +https://www.ekoreanews.co.kr/news/articleView.html?idxno=75746 + +## Vitalik Donates 128 ETH Each to Privacy Messaging Apps Session and SimpleX Chat + +(ヴィタリック、プライバシー重視のメッセージアプリ「Session」「SimpleX Chat」に各128ETHを寄付) + +New Economy / Gentosha + +News + +This Japanese article reports on Vitalik Buterin's donation of 128 ETH each to Session and SimpleX Chat on November 27, 2025. It quotes Buterin saying encrypted messaging is critical for digital privacy, and notes he identified permissionless account creation and metadata privacy as key development priorities, while acknowledging both apps have not yet achieved ideal user experience. + +Image: neweconomy-buterin-simplex.jpg + +Language: Japanese + +Date: Nov 2025 + +https://www.neweconomy.jp/posts/521981 + +## Ethereum Founder Makes Huge Donation to Messaging App + +(イーサリアム創設者、メッセージングアプリに巨額寄付) + +CRYPTO TIMES + +News + +This Japanese crypto news outlet reports on Vitalik Buterin's combined 256 ETH donation to Session and SimpleX Chat. It highlights SimpleX's approach of eliminating permanent user identifiers entirely, using QR codes and invitation links instead, with servers functioning as data conduits that maintain no information about who communicates with whom. + +Image: cryptotimes-buterin-simplex.jpg + +Language: Japanese + +Date: Dec 3, 2025 + +https://crypto-times.jp/news-ethereum-founder-makes-huge-donation-to-messaging-app/ + +## Anonymous Messaging App SimpleX Chat: Setup and Usage Guide + +(ID不要の匿名メッセージアプリ「SimpleX Chat」の使い方) + +VPN Taizen + +Guide + +This Japanese setup guide describes SimpleX as having the highest privacy protection among messaging apps, explaining its encrypted queue system where servers cannot know who communicates with whom. The reviewer provides detailed instructions for profiles, contact management, and Tor integration, but criticizes the interface as "terrible" with confusing design and iOS stability issues. + +Image: vpn-taizen-simplex-guide.jpg + +Language: Japanese + +Date: 2025 (updated) + +https://vpn-taizen.com/how_to_use_anonymous_messaging_app_simplex_chat_that_doesnt_require_id/ + +## Privacy-Focused Chat Tool SimpleX: First Impressions + +(プライバシー特化のチャットツール「SimpleX」を利用してみる) + +Kusaimara Blog + +Review + +This Japanese blog post from September 2022 shares first impressions of SimpleX as a privacy-focused chat tool available on mobile. The author finds it usable despite being in development and expresses cautious optimism, noting SimpleX later gained Japanese language support by June 2023. + +Image: kusaimara-simplex-first-impressions.jpg + +Language: Japanese + +Date: Sep 2022 + +https://kusaimara.net/2022/09/20 + +## Anonymous Chat Showdown: Session vs SimpleX + +(匿名チャット対決!Session対SimpleX) + +Kusaimara Blog + +Comparison + +This Japanese blog post compares Session and SimpleX across recognition, usability, and anonymity. It notes SimpleX avoids user IDs entirely using unique URLs for connections but requires manual Tor activation. The author concludes both tools suffer from limited adoption, arguing that messaging apps require network effects to be practical. + +Image: kusaimara-session-vs-simplex.jpg + +Language: Japanese + +Date: Jul 2024 + +https://kusaimara.net/2024/07/758 + +## Why I Recommend SimpleX Over Signal and Session + +(巷で話題のSignalやSessionではなくSimpleXをおすすめする理由) + +vpn53049 / Ameblo + +Review + +This Japanese blog post advocates for SimpleX over Signal and Session, citing its per-connection one-time IDs, forward secrecy, and post-quantum encryption. The author criticizes Session for lacking forward secrecy and using fixed permanent IDs, and questions Signal's leadership integrity, concluding SimpleX addresses privacy gaps present in both alternatives. + +Image: ameblo-vpn53049-simplex-recommend.jpg + +Language: Japanese + +Date: May 19, 2024 + +https://ameblo.jp/vpn53049/entry-12852836589.html + +## SimpleX's Revolutionary Idea + +(SimpleXの革命的なアイデアに心を打たれた) + +vpn53049 / Ameblo + +Article + +This Japanese blog post explains SimpleX's key innovation: generating unique IDs per conversation rather than using persistent anonymous IDs. The author argues this eliminates the social graph exposure risk inherent in competing apps and solves the practical frustration of managing multiple devices or accounts to maintain anonymity across different social contexts. + +Image: ameblo-vpn53049-simplex-revolutionary.jpg + +Language: Japanese + +Date: Sep 5, 2022 + +https://ameblo.jp/vpn53049/entry-12786598980.html + +## The Next Anonymous Messengers After Signal: Session and SimpleX Chat + +(Signalの次に代わる匿名メッセージアプリ・SessionとSimpleX Chat) + +renaro / note.com + +Comparison + +This Japanese article positions Session and SimpleX as the next anonymous messengers after Signal, whose mandatory phone number requirement is its main weakness. SimpleX is presented as the most anonymous option due to having no user ID whatsoever, though it requires the additional Orbot app for maximum privacy, making Session more convenient for baseline anonymity. + +Image: renaro-signal-session-simplex.jpg + +Language: Japanese + +Date: Nov 21, 2025 + +https://note.com/renaro/n/n8b3c9af1899f + +## SimpleX Chat: Next-Generation Secure Communication Tool Without Identity Verification + +(SimpleX Chat:无需身份识别的下一代安全通讯工具) + +Huluohu Blog + +Review + +This Chinese article introduces SimpleX Chat as a uniquely private messaging platform that requires no phone number, email, or username of any kind, using shared links instead to establish connections. It highlights SimpleX's core advantages: true anonymity where even the servers cannot know who is talking to whom, end-to-end encryption, decentralized server architecture, open-source transparency, and innovative use of unidirectional message queues with rotating random receive addresses to resist network analysis. + +Image: huluohu-simplex-review.jpg + +Language: Chinese + +Date: 2024 (estimated) + +https://www.huluohu.com/posts/1221/ + +## SimpleX Chat: A Private and Encrypted Open-Source Communication Tool Without Any User IDs + +(SimpleX Chat:一个私人且加密的开源通讯工具,没有任何用户 ID) + +Ababtools + +Review + +This article describes SimpleX Chat as an open-source messaging platform that does not rely on any user identifier, not even random numbers. It highlights Double Ratchet encryption, multiple chat profiles including hidden ones, encrypted voice messages and audio/video calls, secret group chats, and portable encrypted databases, with availability across Android, iOS, and desktop. + +Image: ababtools-simplex-review.jpg + +Language: Chinese + +Date: Jan 7, 2024 + +https://ababtools.com/?post=955 + +## Open-Source SimpleX Chat Succeeds Where Telegram Failed + +(开源 SimpleX Chat 成功弥补了 Telegram 的不足) + +Notebookcheck China + +News + +This Chinese Notebookcheck article argues SimpleX succeeds where Telegram failed on privacy. It highlights that SimpleX requires no phone numbers, uses incognito mode with auto-generated usernames, employs one-way onion routing, and allows users to set up their own servers. The article notes unlimited group sizes but acknowledges SimpleX currently lacks widespread adoption. + +Image: notebookcheck-cn-simplex.jpg + +Language: Chinese + +Date: Oct 3, 2024 + +https://www.notebookcheck-cn.com/SimpleX-Chat-Telegram.897088.0.html + +## Open-Source SimpleX Chat Succeeds Where Telegram Failed + +(O SimpleX Chat de codigo aberto tem sucesso onde o Telegram falhou) + +Notebookcheck Portugal + +News + +This Portuguese Notebookcheck article contrasts SimpleX with Telegram, emphasizing that SimpleX does not require phone numbers for registration, uses incognito mode and one-way onion routing, and offers end-to-end plus local device encryption. It positions SimpleX's open-source, decentralized approach against Telegram's conflicts with government agencies. + +Image: notebookcheck-pt-simplex.jpg + +Language: Portuguese + +Date: Oct 2024 + +https://www.notebookcheck.info/O-SimpleX-Chat-de-codigo-aberto-tem-sucesso-onde-o-Telegram-falhou.897026.0.html + +## Anonymous Messaging Apps + +(App di messaggistica anonime) + +Le Alternative + +Guide + +This Italian blog about alternative apps describes SimpleX Chat as an open-source, end-to-end encrypted messenger that has received an independent security audit. It emphasizes that the only way to add a contact is through QR code or link, requiring neither phone number nor email address, and notes availability on F-Droid, Play Store, and iOS. + +Image: lealternative-anonymous-apps.jpg + +Language: Italian + +Date: Dec 14, 2022 + +https://blog.lealternative.net/2022/12/14/app-di-messaggistica-anonime/ + +## SimpleX Chat + +Freeonline.org + +Review + +This Italian review awards SimpleX Chat "Site of the Day" status, describing it as a messaging platform that eliminates user identifiers entirely. It highlights advanced end-to-end encryption, local-only data storage, Tor network access, and availability across all major platforms, while noting the experience is more technical than competing services. + +Image: freeonline-simplex-review.jpg + +Language: Italian + +Date: Feb 17, 2026 (updated) + +https://www.freeonline.org/simplex-chat/ + +## 10 Most Secure Messaging Apps of 2024 + +(Le 10 app di messaggistica piu sicure del 2024) + +Moyens I/O Italy + +Comparison + +Image: moyens-io-secure-apps-2024.jpg + +Language: Italian + +Date: 2024 + +https://it.moyens.net/app/app-messaggistica-piu-sicure-del-signal-session-simplex/ + +## SimpleX Chat: The Hidden Portal of Privacy + +(SimpleX Chat - O Portal Oculto da Privacidade) + +Paranoia + +Review, Video + +Image: portuguese-simplex-hidden-portal.jpg + +Language: Portuguese + +Date: Oct 2024 + +https://www.youtube.com/watch?v=CCB9m0T7RIM + +## Anonymous Chat Without Phone and Email: Online Privacy With Ultra Metadata Annihilation + +(SIMPLEX: CHAT ANONIMO SEM TELEFONE E EMAIL / PRIVACIDADE ONLINE COM ULTRA ANIQUILACAO DE METADADOS) + +Leandroibov + +Review, Video + +Image: portuguese-simplex-ultra-annihilation.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=gvVfpPB1srI + +## How to Use SimpleX Private Chat Without Identification + +(Como usar o SimpleX chat privado sem identificacao) + +Prometheus HODL + +Guide, Video + +Image: portuguese-simplex-tutorial.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=JMPxptujnaQ + +## SimpleX Chat: Messaging Meets Perfect Privacy + +The Digital Prepper + +Review, Video + +Image: simplex-messaging-perfect-privacy.jpg + +Language: English + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=aKRfDch_WBQ + +## SimpleX Chat: Simple Messaging With Unusually Good Privacy + +Remember Lads Subscribe to Big Bear + +Review, Video + +Image: simplex-unusually-good-privacy.jpg + +Language: English + +Date: Dec 19, 2023 + +https://www.youtube.com/watch?v=zkEY9m2E-Y4 + +## Self-Hosted SimpleX Chat + +Simple Messaging With Unusually Good Privacy + +Guide, Video + +Image: selfhosted-simplex-tutorial.jpg + +Language: English + +Date: Feb 8, 2024 + +https://www.youtube.com/watch?v=1zMAGzYBgJY + +## Setting Up SimpleX As Your Private Messenger + +Daniel's Blog + +Guide + +This blog post walks through self-hosting a SimpleX SMP server with Traefik and Docker after the author abandoned WhatsApp over privacy concerns. It highlights SimpleX's invite-link-based connections and decentralized relays that do not reveal participant information, while honestly noting the desktop app requires an active smartphone connection and lacks a web interface. + +Image: xfuture-blog-simplex-setup.jpg + +Language: English + +Date: 2025 + +https://xfuture-blog.com/posts/setting-up-simplex-as-your-private-messenger/ + +## Showdown: Signal, Session, SimpleX, Matrix, XMPP, vs Briar + +Simplified Privacy + +Comparison + +This messenger showdown compares Signal, Session, SimpleX, Matrix, XMPP, and Briar, positioning SimpleX as "most likely to grow" due to its corporate APIs. It recommends SimpleX specifically for users who want to hide their communications, while noting it is rated as most vulnerable to psychological phishing among the messengers compared. + +Image: simplified-privacy-showdown.jpg + +Language: English + +Date: 2024 (estimated) + +https://simplifiedprivacy.com/messengers/ + +## SimpleX Network: Power to the People + +SimpleX Chat + +Livestream, Video + +Image: simplex-power-to-people-livestream.jpg + +Language: English + +Date: Feb 11, 2025 + +https://www.youtube.com/watch?v=Uez2mfVGU7s + +## Every Thing You Need to Know About SimpleX Chat + +Libre Self Hosted + +Guide + +This self-hosting directory describes SimpleX as the most private and secure chat platform, using pairwise per-queue identifiers instead of persistent user IDs. It notes the Trail of Bits security audit, AGPL-3.0 licensing, Haskell implementation, and Tor support, emphasizing that SimpleX protocols are and will remain open and in the public domain. + +Image: libreselfhosted-simplex-overview.jpg + +Language: English + +Date: 2024 (estimated) + +https://www.libreselfhosted.com/project/simplex-chat/ + +## Privacy First Steps + +Seth For Privacy + +Guide + +In this privacy guide by Seth for Privacy, SimpleX is recommended as a key step in the privacy journey for its protection of both message contents and metadata. The author notes SimpleX has become his preferred messenger over Signal, primarily because it eliminates the phone number requirement, and he references a detailed podcast interview with SimpleX founder Evgeny. + +Image: sethforprivacy-privacy-steps.jpg + +Language: English + +Date: 2024 (estimated) + +https://github.com/sethforprivacy/sethforprivacy.com/blob/master/content/posts/privacy-first-steps.md + +## Degoogle Your Private Life: Real-Time Messaging + +iode Blog + +Guide + +This iode tech blog article on degoogling covers SimpleX as a decentralized messaging alternative using unidirectional simplex queues. It praises SimpleX's open-source end-to-end encryption and censorship resistance, but identifies significant limitations: a very small user base, no remote contact discovery, slower message delivery, no multi-device support, and total data loss if the device is lost without backups. + +Image: iode-degoogle-messaging.jpg + +Language: English + +Date: 2024 (estimated) + +https://blog.iode.tech/degoogle-your-private-life-4-instant-messaging/ + +## SimpleX Chat v5.2: Message Delivery Receipts + +NoBs Bitcoin + +News + +This release announcement covers SimpleX Chat v5.2.0, highlighting new message delivery receipts with per-contact opt-out, conversation filtering by favorites and unread status, and group improvements including full context for replied messages. The developers emphasized their commitment to keeping SimpleX protocols open and in the public domain. + +Image: nobsbitcoin-v52-receipts.jpg + +Language: English + +Date: 2023 + +https://www.nobsbitcoin.com/simplex-chat-v5-2-0/ + +## SimpleX Server Now Available for StartOS + +NoBs Bitcoin + +News + +This article announces SimpleX Server availability on StartOS through the Start9 Registry. It explains SimpleX's server architecture where servers act as simple relays that do not store profiles, contacts, or groups, and each conversation typically uses two different servers - one chosen by each participant - with Tor server support built in. + +Image: nobsbitcoin-startos.jpg + +Language: English + +Date: 2023 + +https://www.nobsbitcoin.com/simplex-server-now-available-for-startos/ + +## SimpleX Chat + +Blog de Joselito + +Comparison + +This Spanish blog post acknowledges SimpleX offers superior privacy features over XMPP, including metadata protection and automatic encryption, but argues XMPP is not obsolete. The author identifies a vulnerability in SimpleX's group key distribution relying on administrators, and views SimpleX as promising but unproven long-term, using both platforms with XMPP as his primary messenger. + +Image: joselito-simplex-vs-xmpp.jpg + +Language: Spanish + +Date: Dec 5, 2023 + +https://joselito.mataroa.blog/blog/simplex-chat/ + +## SimpleX Chat Part 2 + +Blog de Joselito + +Review + +In this follow-up Spanish post, the author details SimpleX's technical strengths including metadata protection through unidirectional message queues, double-layer encryption, and automatic message deletion from servers after receipt. While primarily an XMPP user, the author calls SimpleX "an excellent messaging platform" and appreciates that robust security works automatically without user effort. + +Image: joselito-simplex-part2.jpg + +Language: Spanish + +Date: Dec 7, 2023 + +https://joselito.mataroa.blog/blog/simplex-chat-parte-2/ + +## 14 Best Secure Messengers of 2026 + +(14 лучших безопасных мессенджеров 2026) + +pro32.com + +Comparison + +This Russian article listing 14 secure messengers highlights SimpleX's unique architecture without user identifiers, where contacts are added via QR codes or invitation links. It identifies SimpleX as one of the most promising options for bypassing blockades, suitable for users prioritizing anonymity and censorship resistance. + +Image: pro32-best-messengers-2026.jpg + +Language: Russian + +Date: 2026 + +https://pro32.com/ru/article/14-samykh-bezopasnykh-messendzherov-kakoy-vybrat-dlya-lichnoy-i-rabochey-perepiski/ + +## Comparative Review of Protected Messengers + +(Сравнительный обзор защищенных мессенджеров) + +SecurityLab.ru + +Comparison + +This Russian comparative review of protected messengers describes SimpleX as a decentralized platform targeting privacy-conscious users who prioritize anonymity. It notes SimpleX's strengths in avoiding phone numbers and tracking, but identifies fewer features compared to Telegram or Signal, a potentially confusing interface, and a smaller user community as drawbacks. + +Image: securitylab-messenger-comparison.jpg + +Language: Russian + +Date: 2024 (estimated) + +https://www.securitylab.ru/blog/personal/SimlpeHacker/354153.php + +## SimpleX Chat Overview: What Is It? + +(Обзор SimpleX Chat: Что это такое?) + +DDPA.ru + +Review + +This Russian overview describes SimpleX as a privacy-focused messenger that assigns users no identifiers of any kind, ensuring complete anonymity. It highlights the hybrid P2P and federated architecture, end-to-end encryption resistant to relay server compromise, and positions SimpleX as ideal for journalists, activists, and cybersecurity enthusiasts. + +Image: ddpa-simplex-overview.jpg + +Language: Russian + +Date: 2024 (estimated) + +https://ddpa.ru/p/simplex-chat + +## Group P2P Chats and the First Messenger Without ID + +(Групповые P2P-чаты и первый мессенджер без ID) + +Habr / GlobalSign + +Article + +This Habr article examines group P2P messaging and SimpleX as the first messenger without user IDs, using temporary anonymous paired message queue identifiers unique to each connection. It details incognito mode, decentralized storage, dual-layer encryption, and Tor compatibility, contrasting SimpleX's approach with all existing messengers that rely on some form of user identification. + +Image: habr-globalsign-p2p-chats.jpg + +Language: Russian + +Date: Feb 2024 + +https://habr.com/ru/companies/globalsign/articles/792986/ + +## Safer Than Signal or Telegram? SimpleX Offers Absolute Privacy Without Creating a Personal ID + +(Bezpecnejsi nez Signal nebo Telegram? SimpleX nabidne absolutni soukromi bez vytvareni osobniho ID) + +Cnews.cz + +News + +This Czech tech news article presents SimpleX as safer than Signal or Telegram, highlighting its elimination of permanent user IDs in favor of temporary anonymous message identifiers deleted immediately after sending. It notes the project originated in 2020 and gained attention when Jack Dorsey endorsed it as potentially more secure than Signal or Telegram. + +Image: cnews-cz-simplex-privacy.jpg + +Language: Czech + +Date: May 27, 2023 + +https://www.cnews.cz/bezpecnejsi-nez-signal-nebo-telegram-simplex-nabidne-absolutni-soukromi-bez-nutnosti-vytvareni-osobniho-id/ + +## SimpleX Chat Is a Revolution in Encrypted Communication + +(SimpleX Chat je revoluci v sifrovane komunikaci) + +Kryptoanarchista.cz + +Review + +This Czech crypto-anarchist publication calls SimpleX a revolution in encrypted communication that surpasses Signal and Threema. It praises the temporary anonymous pairwise identifiers that prevent correlation attacks, and the phone-number-free setup via QR codes, while noting limitations in desktop availability and the need for an alternative channel to initiate contact. + +Image: kryptoanarchista-simplex-revolution.jpg + +Language: Czech + +Date: Aug 17, 2023 + +https://kryptoanarchista.cz/simplex-chat-je-revoluci-v-sifrovane-komunikaci/ + +## Overview and Comparison of Encrypted Communication Tools + +(Prehlad a porovnanie sifrovanych komunikacnych nastrojov - messengerov) + +Juraj Bednar + +Comparison + +In this Slovak overview of encrypted communication tools, Juraj Bednar describes SimpleX as the youngest application in the comparison, using asymmetric connections via QR codes with no user identifiers. He notes basic functionality including audio and video calls works, but advises waiting before using it for family or business communication due to ongoing development and some technical issues. + +Image: bednar-encrypted-messengers.jpg + +Language: Slovak + +Date: Apr 5, 2022 + +https://juraj.bednar.io/blog/2022/04/05/sifrovane-komunikacne-nastroje-prehlad/ + +## Encrypted Communication Between Programs and Mobile Devices + +Juraj Bednar + +Guide + +Juraj Bednar chose SimpleX as his preferred solution for sending encrypted notifications between programs and mobile devices, valuing its lack of account creation requirements and absence of spam. He uses helper functions to send messages and files from scripts, while noting SimpleX still has bugs and does not guarantee delivery if the program finishes before transmission completes. + +Image: bednar-encrypted-notifications.jpg + +Language: English + +Date: Nov 24, 2024 + +https://juraj.bednar.io/en/blog-en/2024/11/24/encrypted-communication-between-programs-and-mobile-devices/ + +## Session and SimpleX: Encrypted Messenger Comparison + +Michal Kodnar + +Comparison + +This comparison characterizes SimpleX as the first identifier-free messenger, using temporary anonymous paired message queues instead of user IDs. The author finds both Session and SimpleX are interesting projects but notes SimpleX remains buggy and incomplete, requiring QR codes for new connections and lacking disappearing messages at the time of writing. + +Image: kodnar-session-simplex.jpg + +Language: English + +Date: Oct 2023 + +https://michalkodnar.xyz/blog-en/culture-en/session-and-simlex-encrypted-messenger-comparison/ + +## Secure and Decentralized Chat Without Phone Number or Accounts + +(Chat securizat si descentralizat, fara numar de telefon sau conturi, cu aplicatia de mesagerie SimpleX Chat) + +Romica Prihor + +Guide + +This Romanian blog post promotes SimpleX as a revolution in secure and decentralized messaging without phone numbers or accounts. It provides installation instructions for Android, iOS, and desktop, and covers group management through invitation links with moderation features, positioning SimpleX as ideal for those prioritizing communication privacy without compromises. + +Image: prihor-simplex-guide.jpg + +Language: Romanian + +Date: Feb 2025 + +https://romicaprihor.blogspot.com/2025/02/simplex-chat-revolutia-mesageriei.html + +## New Super-Secure Messenger SimpleX Chat: Advantages and Disadvantages + +(Novyi super-zakhyshchenyi mesendzhder SimpleX Chat: perevahy y nedoliky) + +Kostiantyn Korsun / Censor.net + +Review + +This Ukrainian blog post reviews SimpleX Chat's advantages and disadvantages, noting it requires no phone number, email, or any user identifier - a feature rare among messengers. The author highlights that SimpleX stores all data locally with no cloud storage, supports one-time profile links, an incognito mode with random display names per contact, and invitation-only group chats, while also being fundamentally different from Signal, WhatsApp, Threema, Wire, and Session which all rely on some form of user ID. + +Image: korsun-simplex-expert-review.jpg + +Language: Ukrainian + +Date: 2023 + +https://censor.net/ua/blogs/3525466/novyyi-super-zahyschenyyi-mesendjer-simplex-chat-perevagy-yi-nedoliky + +## In Search of a Secure Messenger + +(U poshukakh bezpechnoho mesendzhera) + +KR. Labs Research + +Guide + +This Ukrainian guide to secure messaging describes SimpleX as a decentralized P2P messenger where users own their own servers, requiring no phone numbers or usernames for registration. It highlights end-to-end encryption by default with no metadata collection, positioning SimpleX as an advanced privacy solution appealing to activists and journalists. + +Image: kr-labs-secure-messenger.jpg + +Language: Ukrainian + +Date: Oct 24, 2023 + +https://research.kr-labs.com.ua/secure-and-privacy-messaging-apps-guide/ + +## SimpleX.Chat Is a Chat Network That Preserves Metadata Privacy + +(SimpleX.Chat e chat mrezha, koyato zapazva poveritelnostta na metadannite) + +Hristo Hristov / Medium + +Article + +This Bulgarian Medium article explains SimpleX as a decentralized client-server network that routes messages through disposable nodes while maintaining sender and receiver anonymity. It details the dual end-to-end encryption layers, DNS independence, and the absence of any global user identities, noting that network connections can only be discovered through observation of IP packet timing. + +Image: hristov-simplex-metadata.jpg + +Language: Bulgarian + +Date: Jun 5, 2022 + +https://hristo-hristov.medium.com/simplex-chat-%D0%B5-%D1%87%D0%B0%D1%82-%D0%BC%D1%80%D0%B5%D0%B6%D0%B0-%D0%BA%D0%BE%D1%8F%D1%82%D0%BE-%D0%B7%D0%B0%D0%BF%D0%B0%D0%B7%D0%B2%D0%B0-%D0%BF%D0%BE%D0%B2%D0%B5%D1%80%D0%B8%D1%82%D0%B5%D0%BB%D0%BD%D0%BE%D1%81%D1%82%D1%82%D0%B0-%D0%BD%D0%B0-%D0%BC%D0%B5%D1%82%D0%B0%D0%B4%D0%B0%D0%BD%D0%BD%D0%B8%D1%82%D0%B5-eb31243435a6 + +## Best Encrypted Chat Apps for 2025 + +(Nay-dobrite prilozheniya za kriptiran chat na 2025) + +Questona.com + +Comparison + +This Bulgarian article on encrypted chat apps describes SimpleX as suitable for additional privacy, claiming greater anonymity than Briar because it uses no ID numbers. It notes user names change constantly in group chats and supports disappearing messages, but flags an extremely small user base and server reliability issues as significant practical drawbacks. + +Image: questona-encrypted-chat.jpg + +Language: Bulgarian + +Date: Jan 29, 2025 + +https://questona.com/kriptiran-chat/ + +## SimpleX: Communication Client for Truly Secure and Anonymous Communication + +(SimpleX - komunikacijski klijent za stvarno sigurnu i anonimnu komunikaciju) + +Bug.hr + +Review + +This Croatian tech publication describes SimpleX as a highly secure, decentralized messaging app using Double Ratchet encryption with no user identifiers and local-only data storage. It highlights anonymous profiles, self-destructing messages, voice and video calls, and self-hosting capability, with one commenter noting it is "really top for privacy." + +Image: bug-hr-app-of-day.jpg + +Language: Croatian + +Date: Feb 2, 2024 + +https://www.bug.hr/appdana/simplex-komunikacijski-klijent-za-stvarno-sigurnu-i-anonimnu-komunikaciju-38082 + +## SimpleX Chat: An Open and Secure Chat + +(SimpleX Chat, en oppen och saker chatt) + +Oppet Moln + +Article + +This Swedish article introduces SimpleX as an open and secure chat that requires no global identity such as phone number, username, or IP address. It claims superior security compared to Signal, XMPP/Matrix, and other P2P protocols, while acknowledging that users must evaluate the security claims themselves and that usability tradeoffs exist. + +Image: oppet-moln-simplex.jpg + +Language: Swedish + +Date: May 12, 2022 + +https://oppetmoln.se/20220512/simplex-chat-en-oppen-och-saker-chatt/ + +## Top 10 Secure Messaging Platforms to Replace Telegram in Vietnam 2025 + +(Top 10 Nen Tang Nhan Tin Bao Mat Thay The Telegram Tai Viet Nam 2025) + +TuDongChat + +Article + +This Vietnamese article lists SimpleX as the first decentralized, open-source messaging platform that eliminates user identification entirely, requiring no phone number, email, or personal identifier. It positions SimpleX as the highest-security option among Telegram alternatives for Vietnamese users, ideal for journalists, activists, and anyone requiring no digital footprint. + +Image: tudongchat-simplex-vietnam.jpg + +Language: Vietnamese + +Date: May 26, 2025 + +https://tudongchat.com/blog/nen-tang-nhan-tin-bao-mat/ + +## SimpleX: The Safest Instant Messaging App? + +Free.com.tw + +Review + +This Taiwanese article introduces SimpleX as a privacy-focused messaging app launched in 2020 that requires no phone number or email, using decentralized networking instead of a single server. It notes the spam prevention benefit of link-based contact sharing and mentions the desktop version requires smartphone pairing, while flagging the small user base and lack of Traditional Chinese localization. + +Image: free-com-tw-simplex.jpg + +Language: Chinese (Tr.) + +Date: Mar 26, 2025 + +https://free.com.tw/simplex/ + +## SimpleX: Comparison of Secure Messaging Platforms + +FutaGuard + +Review + +This Chinese comparison of secure messaging platforms gives SimpleX the most favorable assessment, noting it supports message deletion, disappearing messages, channels, and bots. The author calls it "currently the only one with some promise" among privacy-focused alternatives to Telegram, praising its customizable relay servers while noting ongoing cross-device synchronization challenges. + +Image: futa-gg-simplex-comparison.jpg + +Language: Chinese (Tr.) + +Date: 2024 + +https://blog.futa.gg/1/simple-x/ + +## SimpleX Chat: Privacy Without Compromise + +(SimpleX Chat - prywatnosc bez kompromisow) + +opentech.guru + +Review + +This Polish article describes SimpleX as a fully open-source, decentralized messenger that eliminates user identifiers entirely, using separate one-way message queues per contact. It highlights double ratchet and post-quantum key exchange encryption, incognito mode, Tor connectivity, and availability across all major platforms including CLI, calling the servers "dumb pipes" with no knowledge of who connects with whom. + +Image: opentech-guru-simplex.jpg + +Language: Polish + +Date: Feb 2026 + +https://opentech.guru/simplex-chat-prywatnosc-bez-kompromisow/ + +## SimpleX and Matrix Are the Best Messengers. Period. + +(SimpleX i Matrix to najlepsze komunikatory. Kropka.) + +Programista Dla Pasji + +Comparison + +This Polish article evaluates multiple messengers, praising SimpleX for its decentralization where servers function merely as message relays, requiring no personal data. However, the author ultimately chooses Matrix over SimpleX for daily use due to Matrix's superior multi-device support, while acknowledging SimpleX's elegant simplicity and decentralized design. + +Image: programista-pasji-simplex.jpg + +Language: Polish + +Date: Sep 5, 2024 + +https://programistadlapasji.pl/simplex-i-matrix-to-najlepsze-komunikatory-kropka/ + +## Privacy Redefined: First Messenger Without User IDs + +(Prywatnosc zdefiniowana na nowo - Pierwszy komunikator bez identyfikatorow uzytkownikow) + +CONEA + +Article + +Image: conea-simplex-privacy.jpg + +Language: Polish + +Date: 2024 (estimated) + +http://conea.pl/aktualnosci/Prywatnosc-zdefiniowana-na-nowo---Pierwszy-komunikator-bez-identyfikator%C3%B3w-uzytkownik%C3%B3w-(ID)_158 + +## The Only TRULY Anonymous Chat: SimpleX + +(L'unica chat VERAMENTE anonima | SimpleX) + +rdwei + +Review, Video + +Image: italian-youtube-anonymous-chat.jpg + +Language: Italian + +Date: Apr 2026 + +https://www.youtube.com/watch?v=Eamu0Ys63l4 + +## SimpleX Chat Video Review + +PeerTube Uno Italia + +Review, Video + +Video review of SimpleX Chat v5.4 on Italian federated PeerTube instance. Covers connecting mobile and desktop apps via quantum-resistant protocol and improved group features. + +Image: peertube-uno-simplex.jpg + +Language: Italian + +Date: Mar 8, 2026 + +https://peertube.uno/w/ecD1N1HjNC4SBvmWusnTC2 + +## Security in a Box: Communication Tools + +(Ilgili Araclar - Communication Tools) + +Security in a Box / Front Line Defenders + +Guide + +This Turkish-language page from Security in a Box, a digital security guide, lists SimpleX Chat as a free, open-source secure messaging application. It notes SimpleX's decentralized network, lack of phone number requirements, absence of fixed user identifiers, end-to-end encryption by default, disappearing messages, and the Trail of Bits security assessment. + +Image: securityinabox-simplex-turkish.jpg + +Language: Turkish + +Date: 2024 + +https://securityinabox.org/tr/communication/tools/ + +## SimpleX for Iran + +Paskoocheh / ASL19 + +Review + +Paskoocheh, a platform providing circumvention tools for Iranian users, offers SimpleX Chat for Android download. The listing describes SimpleX as a privacy-focused messenger with end-to-end encryption, decentralized architecture, and anonymous communication capabilities, noting it is particularly useful for civil activists, journalists, and anyone requiring confidentiality. + +Image: paskoocheh-simplex-iran.jpg + +Language: Farsi + +Date: May 20, 2025 + +https://paskoocheh.com/tools/839/android.html + +## Most Secure Messaging Apps in the World + +Plaza.ir + +Comparison + +Image: plaza-ir-secure-messaging.jpg + +Language: Farsi + +Date: 2024 + +https://www.plaza.ir/241420/best-encrypted-messaging-apps + +## Vitalik Donates 128 ETH Each to Session and SimpleX, Supporting Privacy Communication Development + +(Vitalik jin chen xuan bu xiang Session he SimpleX ge juan zeng 128 ETH) + +TechFlow + +News + +This Chinese crypto newsletter reports on Vitalik Buterin donating 128 ETH each to Session and SimpleX Chat, emphasizing that encrypted communication is crucial for protecting digital privacy. It notes Vitalik identified permissionless account creation and metadata privacy as key priorities, while acknowledging that decentralization, multi-device support, and Sybil/DoS resistance remain significant technical challenges. + +Image: techflow-vitalik-simplex.jpg + +Language: Chinese + +Date: Nov 27, 2025 + +https://www.techflowpost.com/newsletter/detail_106673.html + +## Donating 256 ETH: Vitalik's Bet on Privacy Communication - Why Session and SimpleX? + +(Juan zeng 256 ETH, Vitalik ya zhu yin si tong xun: wei shen me shi Session he SimpleX?) + +BlockBeats + +News + +This Chinese crypto outlet analyzes Vitalik's 256 ETH donation to Session and SimpleX, explaining SimpleX's radical approach of using one-directional message queues with no global user IDs. It contrasts Session's Web3 token model with SimpleX's rejection of tokenization, and notes the donation was timed one day after the EU's Chat Control proposal threatening end-to-end encryption. + +Image: blockbeats-vitalik-simplex.jpg + +Language: Chinese + +Date: Nov 2025 + +https://www.theblockbeats.info/news/60368 + +## I Used Anonymous Chat Tools for a Week: These 3 Are Truly Safe + +(Wo yong le yi zhou ni ming liao tian gong ju, fa xian zhe 3 ge cai shi zhen zheng de an quan) + +Zhihu + +Review + +This Chinese article reviews three anonymous messaging tools - Session, SimpleX Chat, and TWT - from a week-long hands-on test. SimpleX is praised for its zero-metadata design where even the server cannot know who is communicating, with support for text, voice, and groups, but criticized for requiring manual connection-code sharing and a developer-oriented interface that makes onboarding friends difficult. + +Image: zhihu-anonymous-chat-tools.jpg + +Language: Chinese + +Date: 2025 + +https://zhuanlan.zhihu.com/p/1916814606019584862 + +## Truly Secure and Anonymous Social Communication Tools + +极客小白 + +Guide, Video + +Image: chinese-youtube-secure-tools.jpg + +Language: Chinese + +Date: 2024 + +https://www.youtube.com/watch?v=UXH6wUOqnfk + +## SimpleX Chat: Decentralized Privacy Messaging App Without User Identifiers + +(SimpleX Chat - wu xu yong hu biao shi fu de qu zhong xin hua yin si xiao xi ying yong) + +Kaiyuanapp.cn + +Review + +This Chinese open-source app directory describes SimpleX as a decentralized privacy messaging app that operates without any form of user identifiers. It highlights the SimpleX Messaging Protocol with relay servers that only store encrypted messages temporarily, self-hosting capability, disappearing messages, and anonymous group chats, while noting limitations in voice/video calling, occasional messaging delays, and high Android battery consumption. + +Image: kaiyuanapp-simplex.jpg + +Language: Chinese + +Date: Apr 22, 2025 + +https://kaiyuanapp.cn/simplex-chat-%E6%97%A0%E9%9C%80%E7%94%A8%E6%88%B7%E6%A0%87%E8%AF%86%E7%AC%A6%E7%9A%84%E5%8E%BB%E4%B8%AD%E5%BF%83%E5%8C%96%E9%9A%90%E7%A7%81%E6%B6%88%E6%81%AF%E5%BA%94%E7%94%A8/ + +## Pavol Luptak on Censorship, Security, and Nomadism + +(SP21 Pavol Luptak o cenzure, bezpecnosti, nomadstvi) + +Stackuj.cz Podcast + +Podcast + +Image: stackuj-luptak-podcast.jpg + +Language: Czech + +Date: May 22, 2022 + +https://www.youtube.com/watch?v=N0prtSOyeUU + +## Kostiantyn Korsun: Zaluzhnyi and Messengers + +(Kostyantyn Korsun: Zaluzhnyy i mesendzhery) + +Tverezo.info + +Article + +This Ukrainian article, written by Kostyantyn Korsun, discusses General Zaluzhny's essay on technology in modern warfare and the Ukrainian military's widespread reliance on Signal for encrypted communications despite formal prohibitions. While focused on Signal's role in military contexts and the US Defense Secretary's controversy over using Signal for classified data, the article addresses the broader topic of encrypted messengers in sensitive operational environments. + +Image: tverezo-korsun-zaluzhnyi.jpg + +Language: Ukrainian + +Date: 2025 + +https://tverezo.info/post/205151 + +## Top 10 Most Secure Messaging Apps in 2024 + +(Top 10 mest sikre besked-apps i 2024) + +Moyens I/O Denmark + +Comparison + +Image: moyens-dk-secure-apps.jpg + +Language: Danish + +Date: 2024 + +https://dk.moyens.net/apps/top-mest-sikre-besked-apps-signal-session-simplex/ + +## SimpleX Chat Tutorial Part 2: Fun Features + +(SimpleX Chat jian yi jiao cheng di er dan) + +BHB Community + +Guide + +This Chinese tutorial covers SimpleX Chat setup and advanced features including post-quantum encryption, database password creation, server provider selection, security code verification, and group management. It notes SimpleX has no message recall function except for group admins, voice/video calls require VPN access, and file sizes are limited to 1GB. + +Image: bhb-simplex-tutorial-2.jpg + +Language: Chinese + +Date: Mar 20, 2025 + +https://boyshelpboys.com/thread-6844.htm + +## Vitalik Donated 256 ETH to 2 Chat Apps You've Never Heard Of - What's He Betting On? + +(Vitalik juan le 256 ge ETH gei 2 ge ni mei ting guo de liao tian ruan jian, dao di zai ya zhu shen me?) + +BlockWeeks + +News + +This Chinese crypto article analyzes Vitalik's donation of 128 ETH each to Session and SimpleX, timed strategically one day after the EU's Chat Control proposal. It contrasts SimpleX's rejection of tokenization with Session's Web3 SESH token model, and notes Session's token surged over 450% following the announcement while SimpleX views speculation as counterproductive to privacy goals. + +Image: blockweeks-vitalik-simplex.jpg + +Language: Chinese + +Date: Nov 28, 2025 + +https://blockweeks.com/article/189510 + +## Looking Back at 2025: Top 10 Influential Figures in the Crypto Industry + +(Hui wang 2025: ying xiang jia mi hang ye de shi da nian du feng yun ren wu) + +Tencent News + +News + +This Chinese article reviewing the top 10 influential figures in the crypto industry for 2025 mentions SimpleX briefly in the context of Vitalik Buterin donating 128 ETH each to Session and SimpleX Chat. It quotes Vitalik emphasizing that digital privacy protection through encrypted messaging is crucial, citing permissionless account creation and metadata privacy as key development directions. + +Image: tencent-news-crypto-2025.jpg + +Language: Chinese + +Date: Dec 22, 2025 + +https://news.qq.com/rain/a/20251222A01LTC00 + +## SimpleX Chat: Next-Generation Secure Communication Tool + +Zhousa.com + +Review + +This Chinese article presents SimpleX as a next-generation secure communication tool with an identity-free design requiring no phone number, email, or username. It explains the single-direction message queue architecture with rotating receiver addresses, and positions the platform for ordinary users, journalists, activists, and anyone valuing privacy. + +Image: zhousa-simplex-review.jpg + +Language: Chinese + +Date: 2024 + +https://www.zhousa.com/archives/60915.html + +## From Privacy to Social: Web3 Needs an All-in-One Encrypted Social Application + +(Cong yin si dao she jiao: Web3 xu yao yi zhan shi jia mi she jiao ying yong) + +Golden Finance / KasTop + +News + +This Chinese article about Web3 encrypted social applications positions SimpleX as an important advancement in decentralized private communication, noting Vitalik's 128 ETH donation. While praising SimpleX's end-to-end encryption and lack of user identifiers, the article argues that comprehensive platforms combining privacy with full Web3 social features represent the next evolutionary stage beyond SimpleX. + +Image: golden-finance-web3-privacy.jpg + +Language: Chinese + +Date: Dec 17, 2025 + +https://www.kastop.com/Item/1995.aspx + +## SimpleX in Test + +(SimpleX im Test) + +Bitcoinlighthouse.de + +Review + +This German review tests SimpleX and explains how it fundamentally differs from other messengers by using no user IDs at all - not even random numbers - to protect metadata privacy. The article describes how SimpleX uses per-contact message queue identifiers instead of user identifiers, and highlights the incognito mode which assigns a different display name for each contact, preventing even contacts from proving they communicate with the same person. + +Image: bitcoinlighthouse-simplex-test.jpg + +Language: German + +Date: 2024 + +https://bitcoinlighthouse.de/privacy/simplex-im-test/ + +## German Language Pack for Mobile Privacy Messaging Service SimpleX + +(Jetzt auch auf Deutsch anonym chatten: Update fuer Messenger SimpleX) + +Heise Online + +News + +Heise, a major German tech publication, reports on SimpleX version 4.0 adding a German language pack. The update also introduced encryption of received and stored messages using SQLCipher, a TypeScript SDK for chatbot integration, and self-hosted WebRTC ICE server support. The article notes the founder was seeking donations for a $20,000 independent security audit. + +Image: heise-german-language-simplex.jpg + +Language: German + +Date: 2022 + +https://www.heise.de/news/Deutsches-Sprachpaket-fuer-den-mobilen-Privatsphaere-Nachrichtendienst-SimpleX-7278902.html + +## SimpleX 1.0.0: Decentralized, Privacy-Respecting and Encrypted Chat + +(SimpleX 1.0.0: Dezentraler, Privatsphaere achtender und verschluesselter Chat) + +Tarnkappe.info + +Community + +This German security forum post announces SimpleX's stable 1.0.0 release, describing its two-layer end-to-end encryption with double-ratchet algorithm and unidirectional simplex queues with unique encryption keys per queue. It emphasizes that establishing secure channels requires exchanging encryption keys through QR codes or invitations, and notes the terminal client's availability plus a DigitalOcean one-click server deployment option. + +Image: tarnkappe-simplex-1-0.jpg + +Language: German + +Date: Jan 13, 2022 + +https://tarnkappe.info/forum/t/simplex-1-0-0-dezentraler-privatsphaere-achtender-und-verschluesselter-chat/9812 + +## SimpleX Chat Overview + +GNU/Linux.ch + +Article + +This GNU/Linux-focused Swiss German article explains SimpleX's architecture as a decentralized network using one-way nodes for asynchronous message forwarding, avoiding any form of identity for message routing. It details two layers of end-to-end encryption with forward secrecy, and notes that servers do not retain user records, do not communicate with each other, and have no way to obtain a complete list of participating servers. + +Image: gnulinux-ch-simplex-overview.jpg + +Language: German + +Date: 2022 + +https://gnulinux.ch/simplex-chat + +## New Service: SimpleX Chat Server + +AdminForge + +Service + +AdminForge, a German community infrastructure provider, announces hosting a new SimpleX Chat server at simplex.adminforge.de. It describes SimpleX's Double-Ratchet encryption, multiple profile support, anonymous group participation, and explains that SMP servers function only as relays holding messages until recipients reconnect while XFTP file transfer servers retain uploads temporarily. + +Image: adminforge-simplex-server.jpg + +Language: German + +Date: Oct 4, 2023 + +https://adminforge.de/tools/neuer-service-simplex-chat-server/ + +## Privacy Handbook: SimpleX + +(Datenschutz-Handbuch) + +Privacy-Handbuch.de + +Guide + +This German privacy handbook describes SimpleX as using an innovative metadata-avoidance approach with no account IDs, where encrypted sessions are established directly between clients and servers merely route data packets. It notes SimpleX is particularly suited for concealing contact with specific individuals, though adoption remains limited for everyday use. + +Image: privacy-handbuch-simplex.jpg + +Language: German + +Date: 2024 + +https://www.privacy-handbuch.de/handbuch_89.htm + +## Signal's Brothers: Choosing From Five Most Private and Protected Messengers + +(Bratya Signal. Vybiraem iz pyati naibolee privatnykh i zashchishchyonnykh messendzherov) + +Xakep.ru + +Review + +This Russian security magazine calls SimpleX "the most interesting messenger in this selection, and also the most mysterious." The article highlights its anonymous registration requiring no phone number, federated architecture allowing user-hosted relay servers, and modern functionality including audio/video calls and disappearing messages. + +Image: xakep-simplex-signal-brothers.jpg + +Language: Russian + +Date: Aug 27, 2024 + +https://xakep.ru/2024/08/27/5-private-messengers/ + +## Decentralized Messengers: Choosing the Most Secure Way to Communicate + +(Detsentralizovannyye messendzhery: vybiraem samyj bezopasnyj sposob obshcheniya) + +SecurityLab.ru + +Guide + +This Russian security analysis categorizes SimpleX Chat as a decentralized and anonymous messenger prioritizing minimalism and maximum security. It notes SimpleX provides a high level of anonymity and eliminates metadata exposure, but acknowledges its restricted functionality and relatively low user awareness. + +Image: securitylab-decentralized-messengers.jpg + +Language: Russian + +Date: Sep 1, 2024 + +https://www.securitylab.ru/analytics/551634.php + +## WhatsApp and Telegram Alternative: Full Guide to Decentralized Chats + +(Alternativa WhatsApp i Telegram: polnyj gid po detsentralizovannym chatam) + +SecurityLab.ru + +Guide + +This Russian guide to decentralized chat alternatives describes SimpleX as using temporary anonymous message queue identifiers instead of traditional accounts, with no phone numbers or emails required. It notes the unfamiliar interaction model may confuse newcomers and potential delivery delays exist, but ranks SimpleX highly for anonymity and metadata protection. + +Image: securitylab-whatsapp-alternative.jpg + +Language: Russian + +Date: Aug 14, 2025 + +https://www.securitylab.ru/analytics/562397.php + +## Classification of Secure Messengers: New Projects + +(Klassifikatsiya zashchishchyonnykh messendzherov. Novyye proyekty) + +Habr / GlobalSign + +News + +This Habr article classifies SimpleX Chat as an experimental, "extremely privacy-focused" messenger for enthusiasts, describing it as the first messenger without user IDs of any kind. It identifies SimpleX as one of only two messengers (alongside Briar) that meets all security criteria on the author's evaluation scale. + +Image: habr-globalsign-classification.jpg + +Language: Russian + +Date: Feb 27, 2023 + +https://habr.com/ru/companies/globalsign/articles/719330/ + +## Alternatives to Signal and Telegram: Which Secure Messenger to Use Now? + +(Alternativy Signal i Telegram: kakoj bezopasnyj messendzher ispol'zovat' teper'?) + +hi-tech.mail.ru + +Review + +This Russian tech review highlights SimpleX's complete anonymity through having no telephone numbers or identifiers, and describes its support for audio/video calls, file transfer, disappearing messages, and personal relay servers. It notes all information is stored exclusively on user devices, with messages only temporarily held on relay servers during delivery. + +Image: hi-tech-mail-simplex.jpg + +Language: Russian + +Date: Sep 9, 2024 + +https://hi-tech.mail.ru/review/114266-alternativy-signal-i-telegram-kakoj-bezopasnyj-messendzher-ispolzovat/ + +## SimpleX Chat Review + +te-st.org + +Review + +This review by Russian digital rights organization Te-st characterizes SimpleX Chat as one of the first messengers where the list of missing security features is notably short. It emphasizes that the absence of UserID means users cannot be identified afterward, and unlike most secure messaging apps, no phone number is required. + +Image: te-st-simplex-review.jpg + +Language: Russian + +Date: Aug 21, 2023 + +https://te-st.org/2023/08/21/simplex-chat-review/ + +## VC.ru Article on SimpleX + +VC.ru + +Article + +This Russian user review describes SimpleX as having clear, high-quality voice and video calls, but identifies significant practical limitations including slow media file delivery, compressed photos, and sluggish video uploads. The author expresses skepticism about Microsoft's involvement with the project and concludes that the messenger functions more like a calling app than a complete communication platform. + +Image: vc-ru-simplex.jpg + +Language: Russian + +Date: 2024 + +https://vc.ru/id2160811/779184 + +## RuTube: SimpleX Chat Video + +RuTube + +Review, Video + +This is the second video in a Russian-language series about SimpleX Chat, demonstrating its primary functionality including messaging, encrypted voice and video calls, automatic message deletion timers, incognito mode, and database backup capabilities. + +Image: rutube-simplex-video.jpg + +Language: Russian + +Date: 2024 + +https://rutube.ru/video/b3dcb8869291d7de55596392c05aa24c/ + +## SimpleX: Messaging Proof Against the Curious + +(SimpleX: La mensajeria a prueba de curiosos) + +Francisco Barral + +Article + +This Spanish article explains that SimpleX Chat uses no user identifiers and instead generates unique, temporary addresses for each connection. It describes the app's use of end-to-end encryption via the Signal Protocol, peer-to-peer connections where possible, and privacy features including disappearing messages, multiple chat profiles, and incognito mode. + +Image: franciscobarral-simplex.jpg + +Language: Spanish + +Date: 2024 + +https://franciscobarral.es/simplex-la-mensajeria-a-prueba-de-curiosos/ + +## SimpleX Chat: Secure Messaging and Decentralized Communities + +(SimpleX Chat: Mensajeria segura y comunidades descentralizadas) + +El Ecosistema Startup + +Article + +This Spanish startup-focused article presents SimpleX as a fully decentralized messaging platform with independent security audits completed in 2022 and 2024. It highlights practical applications for startup founders and community managers who need confidential discussions without depending on centralized platforms, with availability across iOS, Android, macOS, Linux, and Windows. + +Image: ecosistemastartup-simplex.jpg + +Language: Spanish + +Date: 2024 + +https://ecosistemastartup.com/simplex-chat-mensajeria-segura-y-comunidades-descentralizadas/ + +## Secure Messaging Apps: Complete Technical Analysis + +(Apps de Mensajeria Seguras: Analisis Tecnico Completo) + +EsGeeks + +Comparison + +This Spanish technical analysis describes SimpleX's approach to radical metadata reduction through ephemeral addresses and message queues specific to each contact, preventing reconstruction of social graphs. It rates SimpleX as suitable for users with strict privacy models and advanced technical knowledge, noting that while IP addresses remain visible to relay servers, using Tor resolves this. + +Image: esgeeks-most-secure-app.jpg + +Language: Spanish + +Date: 2024 + +https://esgeeks.com/app-mensajeria-mas-segura/ + +## Most Secure Decentralized Messengers + +(Mensajeros descentralizados mas seguros) + +EsGeeks + +Comparison + +This Spanish article on secure decentralized messengers categorizes SimpleX Chat as focusing on minimalism and maximum security, with a high level of anonymity and absence of metadata. It notes SimpleX suffers from limited functionality and low popularity compared to other messaging platforms. + +Image: esgeeks-decentralized-messengers.jpg + +Language: Spanish + +Date: 2024 + +https://esgeeks.com/mensajeros-descentralizados-mas-seguros/ + +## SimpleX Chat: The Messaging App + +(SimpleX Chat: la app de mensajeria segura sin identificadores de usuario) + +Computekni + +Review + +This Spanish article describes SimpleX Chat as the first messaging platform without user identifiers of any kind, using end-to-end encryption and QR codes for private connections. It highlights the app's availability on iOS, Android, and F-Droid, and notes its open-source codebase allows public inspection and quick resolution of security issues. + +Image: computekni-simplex-chat.jpg + +Language: Spanish + +Date: May 2023 + +https://www.computekni.com/2023/05/simplex-chat-la-app-de-mensajeria.html + +## This Is How This Secure Messaging App Works Without User Identifiers + +(Asi funciona esta app de mensajeria segura que carece de identificadores para los usuarios) + +WWWhatsnew + +News + +This Spanish tech news site explains that SimpleX Chat delivers messages without using sender or recipient identifiers, relying on the SimpleX Messaging Protocol (SMP) with persistent queues. It describes how users generate unique invitation codes for each contact, with messages stored directly on devices rather than centralized servers. + +Image: wwwhatsnew-simplex.jpg + +Language: Spanish + +Date: Aug 13, 2022 + +https://wwwhatsnew.com/2022/08/13/asi-funciona-esta-app-de-mensajeria-segura-que-carece-de-identificadores-para-los-usuarios/ + +## Telegram Privacy Alternatives + +(Conoce las alternativas a Telegram de mensajeria encriptada y segura) + +CriptoNoticias + +Article + +This Spanish crypto news article presents SimpleX Chat as a privacy-focused alternative to Telegram, noting that developers believe persistent alphanumeric identifiers compromise privacy. It highlights SimpleX's use of temporary anonymous message queue identifiers and single-use QR codes that reduce traceability. + +Image: criptonoticias-telegram-alternatives.jpg + +Language: Spanish + +Date: 2024 + +https://www.criptonoticias.com/tecnologia/alternativas-telegram-privacidad-mensajeria/ + +## SimpleX Chat Tutorial + +Bitcoin.ar + +Guide + +This tutorial from ONG Bitcoin Argentina presents SimpleX as "the first mailbox without user identification," launched in 2021. It notes that while SimpleX includes standard messaging features, its ergonomics remain less fluid than WhatsApp or Signal and can be more restrictive when adding contacts, positioning it as suitable for privacy-conscious users willing to sacrifice daily convenience. + +Image: bitcoin-ar-simplex-tutorial.jpg + +Language: Spanish + +Date: 2024 + +https://bitcoin.ar/tutoriales/simplex-chat/ + +## These Are the Most Secure Messaging Apps According to a Criminologist + +(Estas son las aplicaciones de mensajeria mas seguras segun una criminologa) + +Noticias de Navarra + +News + +This Spanish news article reports that criminologist Maria Aperador highlights SimpleX as the most secure messaging application, citing its lack of user identifiers (not even random ones), end-to-end encryption, and impossibility of data tracking. She emphasizes that SimpleX contains no metadata and the app doesn't know who you are or where messages originate. + +Image: noticiasnavarra-simplex-criminologist.jpg + +Language: Spanish + +Date: Nov 25, 2024 + +https://www.noticiasdenavarra.com/ciencia-y-tecnologia/2024/11/25/estas-son-aplicaciones-mensajeria-mas-seguras-segun-una-criminologa-8972947.html + +## Step-by-Step SimpleX Chat Guide + +(Passo a passo do aplicativo SimpleX Chat) + +Alex Emidio / Substack + +Guide + +This Portuguese step-by-step guide covers SimpleX Chat's backup and data recovery features, including database encryption with user-created passwords and export of encrypted backups. It emphasizes the local-first approach where contacts and messages are stored on the user's device, and warns that the database password cannot be changed if lost. + +Image: alexemidio-substack-simplex.jpg + +Language: Portuguese + +Date: May 26, 2024 + +https://alexemidio.substack.com/p/passo-a-passo-do-aplicativo-simplexchat + +## Ethereum Founder Donates R$4.2 Million to Privacy-Focused Messengers + +(Fundador do Ethereum doa R$ 4,2 milhoes para mensageiros focados em privacidade) + +LiveCoins + +News + +This Brazilian article reports that Vitalik Buterin donated 128 ETH (approximately R$2.1 million) to SimpleX Chat as part of a larger donation to privacy-focused messengers. It notes Buterin acknowledged SimpleX still needs to address challenges including multi-device support and defense against Sybil/DoS attacks without requiring phone numbers. + +Image: livecoins-vitalik-simplex.jpg + +Language: Portuguese + +Date: Nov 2025 + +https://livecoins.com.br/fundador-do-ethereum-doa-r-42-milhoes-para-mensageiros-focados-em-privacidade-baixe-e-use/ + +## Top Most Secure Messaging Apps: Signal, Session, SimpleX + +(Top aplicativos de mensagens mais seguros) + +Moyens I/O Portugal + +Comparison + +Image: moyens-pt-secure-apps.jpg + +Language: Portuguese + +Date: 2024 + +https://pt.moyens.net/aplicativos/top-aplicativos-mensagens-mais-seguros-signal-session-simplex/ + +## SimpleX: How to Use the World's Most Private Messaging App + +(Simplex: como usar o app de mensagens mais privado do mundo) + +Soberano News + +Guide + +This Portuguese guide describes SimpleX as designed to not know who you are, with whom you speak, or when you speak, using blind relay servers that operate as message forwarders without identifying senders or recipients. It acknowledges the app may be slower and have a less polished design than competitors, making it ideal for journalists, activists, and those concerned with digital surveillance. + +Image: soberano-simplex-guide.jpg + +Language: Portuguese + +Date: 2024 + +https://soberano.news/guias-e-ferramentas/simplex-como-usar-o-app-de-mensagens-mais-privado-do-mundo/ + +## How to Install SimpleX Chat Messenger on Linux via Flatpak + +(Como instalar o mensageiro Simplex Chat no Linux via Flatpak) + +Edivaldo Brito + +Guide + +This Portuguese tutorial explains how to install SimpleX Chat on Linux via Flatpak, describing it as a private, open-source encrypted messenger with no user IDs. It lists SimpleX's features including end-to-end encrypted messages, files, images, audio/video calls, secret group chats, instant private notifications, and portable chat profiles, while emphasizing that SimpleX uses no phone numbers or other user identifiers and stores all data on client devices. + +Image: edivaldobrito-simplex-flatpak.jpg + +Language: Portuguese + +Date: 2024 + +https://www.edivaldobrito.com.br/como-instalar-o-mensageiro-simplex-chat-no-linux-via-flatpak/ + +## Top 10 Best Messaging Apps for Secure, Anonymous and Privacy-Respecting Chat + +(Le 10 migliori app di messaggistica per chat sicure, anonime e rispettose della privacy) + +Jacopo Coccia + +Comparison + +This Italian article lists the 10 best messaging apps for secure and anonymous chats, mentioning apps like Threema, Signal, and Wire that offer end-to-end encryption and zero-log policies. SimpleX Chat is not specifically named in the available excerpt, which focuses on the general landscape of privacy-focused messaging and the growing need for data protection. + +Image: jacopococcia-simplex-top10.jpg + +Language: Italian + +Date: 2024 + +https://www.jacopococcia.com/10-migliori-app-messaggistica-per-chat-sicure-anonime-privacy/ + +## What Is SimpleX Chat? + +No Trust Verify + +Article + +This article from the NoTrustVerify blog describes SimpleX as the first messaging platform that doesn't require any login, using the SimpleX Messaging Protocol (SMP) with one-way message queues. It highlights features like incognito mode with random names per contact and live message typing visibility, while acknowledging that multi-device synchronization and large group management still need improvement. + +Image: notrustverify-blog-simplex.jpg + +Language: English + +Date: 2024 + +https://blog.notrustverify.ch/what-is-simplex-chat + +## Interview With the Author of SimpleX Chat: The Most Secure Messaging by Design + +GatoOscuro + +Interview + +This interview with SimpleX founder Evgeny Poberezkin covers the protocol's design beginning in 2020 and mobile app launch in March 2022. Poberezkin acknowledges the "100% private" claim is aspirational marketing rather than absolute truth, and explains that the fully open-source, decentralized architecture prevents government backdoors from compromising the entire network. + +Image: gatooscuro-interview-english.jpg + +Language: English + +Date: 2024 + +https://gatooscuro.xyz/interview-with-the-author-of-simplex-chat-the-most-secure-messaging-by-design/ + +## Safe and Private Messaging Apps Similar to Signal + +Factually + +Comparison + +This product review compares secure messaging apps similar to Signal, noting that Session and SimpleX prioritize privacy-first engineering over mainstream polish. SimpleX is highlighted for avoiding phone-number registration and using no persistent user IDs, offering stronger anonymity than Signal at the cost of convenience and sometimes reliability, while Session uses onion routing which can cause delays and missed notifications. + +Image: factually-signal-alternatives.jpg + +Language: English + +Date: 2026 + +https://factually.co/product-reviews/electronics-tech/safe-private-messaging-apps-similar-to-signal-84872a + +## Best Secure Messaging Apps 2025: Private Chat Reviews + +Tileris + +Comparison + +Image: youtube-best-secure-2025.jpg + +Language: English + +Date: Jul 30, 2025 + +https://www.youtube.com/watch?v=GH4u8pQLY90 + +## SimpleX: Best Private Messenger? + +Tom Spark's Reviews + +Review, Video + +Image: youtube-best-private-messenger.jpg + +Language: English + +Date: 2024 + +https://www.youtube.com/watch?v=PbYm1G-QVUc + +## SimpleX Chat Tutorial + +How to use apps? + +Guide, Video + +Image: youtube-simplex-tutorial.jpg + +Language: English + +Date: 2024 + +https://www.youtube.com/watch?v=X7CJlbBJNcc + +## SimpleX Chat Review + +QuickLearn + +Review, Video + +Image: youtube-simplex-review-2024.jpg + +Language: English + +Date: 2024 + +https://www.youtube.com/watch?v=cGGrRnXAh1w + +## SimpleX Chat: Settings and Usage Guide + +(匿名メッセージアプリ - Simple X Chat の設定と使い方) + +ひとりかくれんぼ / note.com + +Guide + +This Japanese article covers SimpleX Chat's settings and usage, including configuration of global chat settings for complete message deletion, disabling link previews, activating SimpleX Lock with a passcode, and enabling self-destruct mode. The author recommends using a VPN alongside SimpleX for genuine anonymity and stresses that message deletion requires mutual consent between users. + +Image: notecom-deeplife-simplex.jpg + +Language: Japanese + +Date: 2024 + +https://note.com/deeplife/n/n84b9e0a75ac5 + +## How to Bypass SimpleX Chat Blocking + +dept.one + +Article + +This Japanese article explains that SimpleX Chat was blocked in Russia in September 2024 due to its confidentiality and security features, and provides three methods to bypass the block: using a developer-provided proxy, a third-party SOCKS proxy, or a local proxy app like Orbot. It describes SimpleX as a highly secure messenger with no user identification, decentralized architecture, and scrambled message ordering to prevent timing attacks. + +Image: dept-one-simplex-memo.jpg + +Language: Japanese + +Date: 2024 + +https://dept.one/memo/simplex-chat/ + +## SimpleX Chat: Another Kusaimara Article + +Kusaimara + +Article + +This Japanese blog post announces that SimpleX Chat added Japanese language support in version 5.1 released in late May 2023. It notes SimpleX doesn't assign unique user IDs and supports Tor network communication, while acknowledging that account portability requires database passphrase export rather than simple login across devices. + +Image: kusaimara-simplex-3.jpg + +Language: Japanese + +Date: Jun 2023 + +https://kusaimara.net/2023/06/444 + +## Vitalik Buterin Donates ETH to Privacy Apps + +Coinspeaker + +News + +This Japanese crypto news article reports that Vitalik Buterin donated 128 ETH to SimpleX Chat as part of a privacy-focused initiative. It describes SimpleX's one-way message queue design that eliminates global identifiers and enables account creation without linking personal information like phone numbers. + +Image: coinspeaker-jp-vitalik.jpg + +Language: Japanese + +Date: Nov 2025 + +https://www.coinspeaker.com/jp/vitalik-buterin-donates-eth-privacy-apps/ + +## Yahoo Japan: Vitalik Buterin Donation Coverage + +Yahoo Japan News + +News + +Image: yahoo-japan-vitalik-simplex.jpg + +Language: Japanese + +Date: Nov 2025 + +https://news.yahoo.co.jp/articles/a55ac83411dcaca913007857948d335b0ed4d21a + +## Encrypted Messengers: Comparison + +(Encrypted messengers - comparison) + +Juraj Bednar + +Comparison + +This encrypted messenger comparison describes SimpleX as the youngest app in the review, using asymmetric connections through QR codes or URLs that make it difficult to correlate sending and receiving patterns. The reviewer notes the interface shows signs of ongoing development and some reliability issues, and suggests SimpleX suits users prioritizing anonymity for temporary rather than permanent communications. + +Image: bednar-encrypted-messengers-en.jpg + +Language: English + +Date: May 3, 2022 + +https://juraj.bednar.io/en/blog-en/2022/05/03/encrypted-messengers-comparison/ + +## SimpleX Chat on IQ.wiki + +IQ.wiki + +Review + +This IQ.wiki page provides a comprehensive overview of SimpleX Chat, covering its custom protocols (SimpleXMQ and SMP), end-to-end encryption with the Signal Double Ratchet algorithm, and post-quantum resistant encryption via CRYSTALS-Kyber. It notes key milestones including the v1 mobile release in 2022, Jack Dorsey's investment in August 2024, and planned Community Vouchers utility token system for 2026. + +Image: iqwiki-simplex-entry.jpg + +Language: English + +Date: 2024 + +https://iq.wiki/wiki/simplex-chat + +## SimpleX: How to Use the Most Private Messaging App + +(SimpleX: how to use the world's most private messaging app) + +Soberano News + +Guide + +This English guide explains SimpleX's approach of deleting metadata rather than just encrypting messages, using servers as blind relays with temporary one-way queues where servers cannot identify senders or recipients. It acknowledges the app can be slower and have a less polished interface than competitors, positioning it for journalists, activists, and those concerned about digital surveillance. + +Image: soberano-simplex-english.jpg + +Language: English + +Date: 2024 + +https://soberano.news/en/guides-and-tools/simplex-how-to-use-the-worlds-most-private-messaging-app/ + +## Libertarian Institute Article on SimpleX + +Institute for Libertarian Ideas / Japan + +Article + +This Libertarian Institute article emphasizes that SimpleX serves everyday citizens rather than just activists, providing significant privacy improvements with minimal sacrifice of convenience. It highlights identity flexibility where users can change display names per contact, hidden profiles behind password protection, and notes SimpleX functions as an anonymization network similar to Tor. + +Image: libertarian-institute-simplex.jpg + +Language: English + +Date: 2024 + +https://institute-for-libertarian.org/the-libertarian/2148/ + +## Messaging Applications on Medium + +(Mesajlasma Uygulamalari) + +Gizli Kalsin / Medium + +Article + +This Turkish Medium article lists SimpleX Chat as one of six recommended secure messaging applications, describing it as a decentralized instant messaging app that operates without phone numbers or user IDs. It notes users join conversations by scanning QR codes or clicking invite links, and the application provides complete anonymity. + +Image: gizlikalsin-medium-simplex.jpg + +Language: Turkish + +Date: Sep 3, 2023 + +https://medium.com/@gizlikalsin/mesajla%C5%9Fma-uygulamalar%C4%B1-74dc81d78737 + +## BlockTop: From Privacy to Social - Web3 and Encrypted Social Applications + +(Cong yin si dao she jiao: Web3 xu yao yi zhan shi jia mi she jiao ying yong) + +BlockTop + +News + +This Chinese article positions SimpleX alongside Session as representing truly decentralized privacy communication in the Web3 ecosystem. It highlights Vitalik Buterin's donation of 128 ETH to SimpleX and describes the platform's elimination of phone numbers, emails, or usernames in favor of the SimpleX Messaging Protocol, calling it potential "killer-level infrastructure" for the crypto industry. + +Image: blocktop-web3-privacy.jpg + +Language: Chinese + +Date: Dec 17, 2025 + +https://blocktop.cn/newsContent/1/176524 + + +## Better and More Secure Messenger Than Signal + +(Lepszy i bezpieczniejszy komunikator niz Signal) + +Selfhosty.pl + +Comparison + +This Polish article argues there are more secure messengers than Signal, noting that Signal's requirement for a phone number is a serious privacy weakness since phone numbers and IP addresses can be used to track users. The article recommends exploring alternatives that do not require a phone number for registration, positioning them as safer choices for users concerned about government surveillance and data breaches. + +Image: selfhosty-simplex-signal.jpg + +Language: Polish + +Date: 2024 + +https://selfhosty.pl/lepszy-i-bezpieczniejszy-komunikator-niz-signal/ + +## Security in Instant Messaging Services + +(Seguridad en servicios de mensajeria instantanea) + +Colectivo 406 + +Guide + +This Spanish security guide describes SimpleX Chat as using unidirectional message queues with end-to-end encryption including post-quantum algorithms, where servers act only as message bridges with minimal knowledge. It notes SimpleX is fully self-hostable unlike Signal, but acknowledges the complex changing queue addresses make maintaining long-term contacts more difficult. + +Image: colectivo-406-messaging-security.jpg + +Language: Spanish + +Date: Oct 27, 2024 + +https://406.neocities.org/a/apps_mensajeria/ + +## Anonymous Messaging Apps: Recommended List + +(Ni ming tong xin huan jing gou jian - messeji apuri osusume ichiran) + +ひとりかくれんぼ / note.com + +Review + +This Japanese article strongly recommends SimpleX Chat as the top choice for anonymous secure communication, emphasizing that it requires no phone number or email and doesn't use user identifiers or collect metadata. The author notes that user experience is mostly acceptable with only minor delays in call connections, and observes that SimpleX adoption remains relatively low in Japan. + +Image: deeplife-anonymous-apps-list.jpg + +Language: Japanese + +Date: 2024 (estimated) + +https://note.com/deeplife/n/ne7ffdd8a50cd + +## Building Your First Anonymous Digital Life: Fundamentals + +(Tokumei de ikiru tame no saisho no kankyo kochiku to kihon no hanashi) + +ひとりかくれんぼ / note.com + +Guide + +This Japanese article about building an anonymous digital life uses SimpleX Chat as the recommended contact method for readers seeking personalized guidance on privacy topics. It presents SimpleX solely as a secure, end-to-end encrypted communication channel without elaborating on its specific features. + +Image: deeplife-anonymous-life-guide.jpg + +Language: Japanese + +Date: 2024 (estimated) + +https://note.com/deeplife/n/nea6f1c4e08a7 + +## Privacy Protection: Instant Messaging + +Hacking Articles + +Guide + +This security-focused article describes SimpleX Chat as a privacy-focused messaging network that keeps profile and contact information hidden from its servers, using proprietary protocols designed with privacy as a core principle. It recommends SimpleX for lightweight, decentralized communication and anonymous use without central servers, while noting it may lack some advanced features compared to more established platforms. + +Image: hacking-articles-privacy-messaging.jpg + +Language: English + +Date: Sep 17, 2025 + +https://www.hackingarticles.in/privacy-protection-instant-messaging/ + +## SimpleX on Yggdrasil Network + +Yggdrasil Wiki + +Guide + +This Russian wiki page describes SimpleX as the only messenger that uses no user profile identifiers, not even random numbers, with fully open-source client and server code that anyone can self-host. It explains that SimpleX delivers messages using per-contact message queue identifiers rather than user IDs, with plans to automate queue rotation so that even conversations will have no long-term network-visible identifiers. + +Image: yggwiki-simplex-howto.jpg + +Language: Russian + +Date: 2024 (estimated) + +https://yggwiki.cc/social_media:simplex + +## SimpleX Chat Review + +Tests & Tips + +Review + +German review and testing site covering SimpleX Chat. Evaluates the messenger's privacy features, usability, and security properties as part of a broader mobile app testing catalog. + +Image: tests-tips-simplex-review.jpg + +Language: German + +Date: 2024 (estimated) + +https://tests.tips/en/?software/mobile-apps/simplex + +## Self-Host SimpleX Chat + +(Hospeda SimpleX Chat) + +CLASES DE LINUX + +Guide, Video + +Image: hospeda-simplex-chat.jpg + +Language: Spanish + +Date: Feb 10, 2024 + +https://www.youtube.com/watch?v=p1NF68KIt7M + +## SimpleX Chat: The Libertarian WhatsApp + +(SimpleX Chat - Whatsapp libertario) + +Paranoia + +Guide, Video + +Image: simplex-whatsapp-libertario.jpg + +Language: Portuguese + +Date: Jan 9, 2025 + +https://www.youtube.com/watch?v=4sOvaD-YZLU + +## SimpleX Chat Is a Revolution in Encrypted Communication + +Kryptoanarchista.cz + +Review + +This Czech crypto-anarchist blog presents SimpleX as an open-source encrypted messenger requiring no KYC, supporting text, voice, video calls, file sharing, and Tor connectivity. It notes the absence of persistent identities prevents tracking of user connections but acknowledges current limitations including the need for QR code exchange to start conversations and lack of cross-device synchronization. + +Image: kryptoanarchista-english-review.jpg + +Language: English + +Date: Aug 17, 2023 + +https://kryptoanarchista.cz/en/simplex-chat-is-a-revolution-in-encrypted-communication/ + +## Which Messengers Work in Russia and Which Are Blocked + +(Kakie messendzhery rabotayut v Rossii a kakie zablokirovany) + +AppVisor.ru + +Article + +This Russian article reports that SimpleX Chat was among several decentralized messengers blocked by Moscow City Court order in September 2024, alongside Briar, Session, and Verum. It notes the blocking was due to SimpleX's architecture that makes user tracking technically extremely difficult. + +Image: appvisor-blocked-messengers.jpg + +Language: Russian + +Date: Mar 6, 2026 + +https://appvisor.ru/post/news/kakie-messendzhery-rabotayut-v-rossii-a-kakie-zablokirovany/ + +## SimpleX: Ultra-Private Messaging + +(SimpleX, mensajeria ultraprivada) + +Bala Extra + +Podcast + +Image: bala-extra-simplex-podcast.jpg + +Language: Spanish + +Date: Nov 25, 2025 + +https://www.listennotes.com/podcasts/bala-extra/simplex-mensajer%C3%ADa-z3pHYeVJcA2/ + +## Protocols Not Platforms: NOSTR, BTC, SimpleX + +Closed Network Privacy Podcast + +Podcast + +Image: closed-network-protocols.jpg + +Language: English + +Date: 2024 (estimated) + +https://closednetwork.io/podcast/episode-38-protocols-not-platforms-nostr-btc-simplex/ + +## Introducing TorGuard's New Private VPN Cloud App: SimpleX Server + +TorGuard + +Service + +Image: torguard-simplex-server.jpg + +Language: English + +Date: 2025 (estimated) + +https://blog.torguard.net/introducing-torguards-new-private-vpn-cloud-app-simplex-server/ + +## SimpleX Chat Hosting and Services + +Taurix IT + +Service + +Taurix IT offers managed SimpleX Chat hosting services, including hosting their own SMP servers in separate locations and providing setup assistance for clients who want to self-host. The page emphasizes that SimpleX protects metadata which competitors like Signal and WhatsApp fail to adequately safeguard, making it particularly valuable for whistleblowers, journalists, and activists. + +Image: taurix-simplex-hosting.jpg + +Language: English + +Date: 2025 (estimated) + +https://www.taurix.net/simplex-chat/ + +## Taurix SimpleX Customer Service Bot + +Taurix IT + +Service + +This repository contains a Python-based customer service bot for SimpleX Chat that joins a specified control group and announces newly created customer chats. Control group members can then use commands to join customer conversations, automating the connection between customer service representatives and incoming inquiries. + +Image: taurix-customerservice-bot.jpg + +Language: English + +Date: 2025 (estimated) + +https://code.taurix.net/TaurixIT/simplex-customerservicebot + +## RoboSats Orderbook Alert Bot for SimpleX Chat + +TempleOfSats + +Service + +This open-source bot monitors the RoboSats peer-to-peer Bitcoin trading platform and sends alerts via SimpleX Chat when orders matching user-defined criteria (currency, premium rates, payment methods, trade amounts) are posted. Users manage alerts through simple commands, with a default 7-day alert lifetime and extension capabilities. + +Image: robosats-simplex-bot.jpg + +Language: English + +Date: 2024 (estimated) + +https://github.com/TempleOfSats/Robosats-Orderbook-Alert-Bot-for-SimpleX-Chat + +## SimpleX SMP Server Setup Guide + +Freedom Lab NYC + +Guide + +FreedomLab provides a tutorial for self-hosting a SimpleX SMP (Simple Messaging Protocol) server on a Debian/Ubuntu VPS with Tor support. The guide covers installation steps for running your own private, decentralized messaging relay server as part of the SimpleX network. + +Image: freedomlab-simplex-smp.jpg + +Language: English + +Date: 2025 (estimated) + +https://freedomlab.nyc/resources/simplex-smp/ + +## SimpleX Chat on LunarDAO Wiki + +LunarDAO + +Community + +This LunarDAO wiki page presents SimpleX as a privacy-focused federated messaging platform that requires no phone number or account creation, using one-way message queues instead of traditional accounts. It describes features including live typing indicators, voice/video calls, disappearing messages, self-destruct passcodes, and SOCKS proxy routing, while acknowledging challenges with multi-device synchronization and large group management. + +Image: lunardao-simplex-wiki.jpg + +Language: English + +Date: 2024 (estimated) + +https://wiki.lunardao.net/simplexchat.html + +## SimpleX Communities on Monerica + +Monerica + +Review + +This Monerica directory page lists several Monero-focused SimpleX Chat communities spanning multiple languages and regions, including groups for Monero discussion in Slovenian, German, Italian, and Hebrew. It includes an automated bot that sends hourly Monero price updates via SimpleX. + +Image: monerica-simplex-communities.jpg + +Language: English + +Date: 2025 (estimated) + +https://monerica.com/communities/simplex + +## SimpleX Chat: Censorship-Resistant Communication + +SplinterCon + +Review + +The SplinterCon anti-censorship project page describes SimpleX as a decentralized messenger that operates without user IDs, using end-to-end encryption for messages and file transfers. It highlights that SimpleX can run on the Tor network and allows users to deploy their own servers, with messages routed through servers that can operate without persistence. + +Image: splintercon-simplex-listing.jpg + +Language: English + +Date: 2025 (estimated) + +https://splintercon.net/project/simplex-chat/ + +## Tech Guides for Anarchists: End-to-End Encrypted Messaging + +AnarSec + +Guide + +This anarchist security guide notes SimpleX Chat uses decentralized servers (not peer-to-peer) with in-memory storage and no phone number requirement. It recommends Cwtch over SimpleX for text-only communication but suggests SimpleX as an acceptable option for voice and video calls, noting that content padding exists to frustrate correlation attacks via message size. + +Image: anarsec-e2ee-guide.jpg + +Language: English + +Date: 2024 (estimated) + +https://www.anarsec.guide/posts/e2ee/ + +## Join Beginner Privacy on SimpleX + +Beginner Privacy + +Community + +The Beginner Privacy community selected SimpleX Chat as their primary communication platform for its strong privacy features. The page provides setup instructions for beginners across Linux, Mac, Windows, iOS, and Android, emphasizing accessibility through both graphical and command-line interfaces. + +Image: beginner-privacy-simplex-group.jpg + +Language: English + +Date: 2025 (estimated) + +https://beginnerprivacy.com/about/join-simplex-group/ + +## Sofwul.cz: E-Commerce with SimpleX Contact + +Sofwul + +Service + +This Czech e-commerce site lists SimpleX as one of several encrypted messaging contact options for reaching their customer support, alongside Threema, Session, Jami, and Teleguard. + +Image: sofwul-simplex-contact.jpg + +Language: Czech + +Date: 2025 (estimated) + +https://www.sofwul.cz/kontakty-messengery + +## SimpleX Chat Server Now Available for StartOS + +Start9 + +Service + +This Stacker News post announces SimpleX Chat server availability for StartOS (Start9's operating system), describing its support for direct messages, group messages, calls, and video calls with end-to-end encryption. Commenters praise it as "very promising tech" that is "plug and play right at the initial download" and comfortable to recommend to non-technical friends. + +Image: start9-simplex-startos.jpg + +Language: English + +Date: 2023 (estimated) + +https://stacker.news/items/196092 + +## Hack Liberty: Cypherpunk Community on SimpleX + +Hack Liberty + +Community + +Image: hackliberty-simplex-community.jpg + +Language: English + +Date: 2024 (estimated) + +https://links.hackliberty.org/ + +## Nowhere.moe: Privacy Hosting with SimpleX Infrastructure + +Nowhere.moe + +Service + +Privacy-focused hosting service operates community SimpleX SMP and XFTP relay servers with both clearnet and Tor onion addresses. Runs anonymous SimpleX chatrooms and provides tutorials on privacy and self-hosting. + +Image: nowhere-moe-simplex-servers.jpg + +Language: English + +Date: 2024 (estimated) + +https://nowhere.moe/ + +## XMRBazaar: Monero Marketplace on SimpleX + +XMRBazaar + +Service + +This Monero community post announces an unofficial XMRBazaar group on SimpleX Chat, providing two join links - one described as "possibly slower but uncensorable" using direct server protocols and another via the SimpleX Directory Service described as "faster but censorable." The group migrated from Matrix to SimpleX for discussing the Monero marketplace. + +Image: xmrbazaar-simplex-community.jpg + +Language: English + +Date: 2024 (estimated) + +https://monero.town/post/4623536 + +## Simplified Privacy: Tech Groups on SimpleX + +Simplified Privacy + +Community + +Simplified Privacy lists several active SimpleX Chat groups covering privacy, security, cryptocurrency (Monero and Bitcoin/Lightning Network), Nostr protocol, and a Switzerland-based privacy community. The page positions SimpleX alongside Session and XMPP as decentralized messaging platforms for privacy-conscious technical discussions. + +Image: simplified-privacy-techgroups.jpg + +Language: English + +Date: 2024 (estimated) + +https://simplifiedprivacy.com/techgroups/ + +## NBTV Community on SimpleX + +NBTV / Ludlow Institute + +Community + +The Ludlow Institute (NBTV) offers SimpleX as one of several community communication platforms alongside Signal and Element, describing it as an end-to-end encrypted messaging app for real-time discussions with other NBTV community members. + +Image: nbtv-simplex-community.jpg + +Language: English + +Date: 2025 (estimated) + +https://ludlowinstitute.org/community + +## Taurix "tellme" Bot + +Taurix IT + +Service + +TellMe is a notification system that monitors events (completed processes, server uptime, etc.) and sends alerts through SimpleX Chat or Matrix via websockets. It supports custom messages, process monitoring, periodic command output watching, and host ping availability tracking. + +Image: taurix-tellme-bot.jpg + +Language: English + +Date: 2025 (estimated) + +https://code.taurix.net/TaurixIT/tellme + +## BitList.co Bitcointalk Notification Bot + +BitList.co + +Service + +This Bitcointalk thread presents a notification bot for SimpleX Chat that monitors the forum for merit awards, mentions, quotes, specific users, topics, and keyword phrases. The developer notes that due to SimpleX's decentralized architecture, notifications may experience delays compared to Telegram alternatives, and data is stored for retry when users aren't connected. + +Image: bitlist-bitcointalk-bot.jpg + +Language: English + +Date: 2025 (estimated) + +https://bitcointalk.org/index.php?topic=5535681.0 + +## SimpleX Without Tears: Wrapped in Onion, Served via Tor + +nemental.de + +Guide + +This guide explains how to host a SimpleX SMP server as a Tor hidden service using Docker containers, making the relay accessible only through .onion addresses. It notes that while SimpleX already has strong privacy by design, adding Tor hides the server's public IP and eliminates DNS exposure, pulling the relay further out of reach of traditional tracking. + +Image: nemental-simplex-tor-guide.jpg + +Language: English + +Date: 2025 (estimated) + +https://nemental.de/simplex-without-tears-wrapped-in-onion-served-via-tor/ + +## SimpleX Server Docker Installation Guide (SMP/XFTP) + +Hack Liberty + +Guide + +This Hack Liberty forum guide provides detailed instructions for deploying SimpleX Chat's SMP and XFTP servers using Docker Compose, with dual-network configuration for both clearnet and Tor operation. It covers port configuration, security measures including running containers as unprivileged users, and emphasizes backing up private CA keys before deletion from the server. + +Image: hackliberty-docker-guide.jpg + +Language: English + +Date: 2024 (estimated) + +https://forum.hackliberty.org/t/simplex-server-docker-installation-guide-smp-xftp/140 + +## SimpleX Theme Archive + +SimpleX-Themes + +Community + +This community-maintained archive offers over 100 downloadable SimpleX Chat themes with preview screenshots, ranging from dark modes (AMOLED Black, Dracula) to colorful designs (Aurora Sunset, Catppuccin, neon synthwave). It is an independent community project not affiliated with SimpleX Chat Ltd, and users can submit their own custom themes. + +Image: simplex-themes-archive.jpg + +Language: English + +Date: 2025 (estimated) + +https://slcw.github.io/SimpleX-Themes/ + +## Goodbye Telegram, Welcome SimpleX! + +(Viszlat Telegram, udvozollek SimpleX!) + +HUP.hu + +Community + +This Hungarian article shares a user's year-long experience with SimpleX Chat, praising that it requires no email, phone number, username, or password to register. The author describes the SimpleX address system, the Directory Bot for finding public groups, and notes the tradeoff that deleting the app without a database backup means permanently losing your profile and all contacts, while also referencing the Wired article about neo-Nazis on SimpleX and the SimpleX developer's response defending privacy. + +Image: hup-hu-goodbye-telegram.jpg + +Language: Hungarian + +Date: Oct 2024 + +https://hup.hu/node/186622 + +## Sharing Several E2EE Open-Source Chat Apps 100x Safer Than Telegram + +(Fen xiang ji ge bi dian bao Telegram an quan 100 bei de duan dui duan jia mi kai yuan liao tian ruan jian) + +V2EX + +Community + +This Chinese V2EX forum post recommends SimpleX as having the highest encryption strength among reviewed E2EE messaging apps, highlighting that unlike Session's fixed IDs, SimpleX eliminates even fixed identifiers entirely. It notes SimpleX uses temporary anonymous pairing identifiers, creates independent fingerprints per conversation, supports Tor integration, and can be self-hosted. + +Image: v2ex-simplex-telegram-100x.jpg + +Language: Chinese + +Date: Aug 26, 2024 + +https://www.v2ex.com/t/1067954 + +## Sharing E2EE Open-Source Chat Apps 100x Safer Than Telegram + +(Fen xiang ji ge bi dian bao Telegram an quan 100 bei de kai yuan liao tian ruan jian) + +Matters.town + +Article + +This Chinese article recommends several end-to-end encrypted open-source chat apps that are far more secure than Telegram, written in the context of the Telegram founder's detention in France. It explains end-to-end encryption and mentions Session and SimpleX among the recommended alternatives that protect message content from being readable even by the relay servers. + +Image: matters-simplex-telegram-comparison.jpg + +Language: Chinese + +Date: Aug 26, 2024 + +https://matters.town/a/hvb8p0wepluy + +## Comparison of Instant Messengers + +Eylenburg + +Comparison + +This comprehensive messenger comparison states that SimpleX "probably has the best privacy and anonymity of all the messengers compared here," noting it requires no accounts or IDs and users connect via one-time invitation links. It identifies the main limitation as adoption - being a relatively new app with few users makes it impractical for most people who need to communicate with existing contacts. + +Image: eylenburg-messenger-comparison.jpg + +Language: English + +Date: Apr 2026 (updated) + +https://eylenburg.github.io/im_comparison.htm + +## SimpleX VS Quiet: Privacy and Security + +Hack Forums + +Comparison + +Image: hackforums-simplex-vs-quiet.jpg + +Language: English + +Date: 2024 (estimated) + +https://hackforums.net/blog/SimpleX-VS-Quiet + + +## MoneroKon 2024: SimpleX Chat talk + +SimpleX Chat + +Conference talk + +MoneroKon 2024 conference talk by Evgeny Poberezkin on how SimpleX Chat bridges the gap between privacy-focused and mass-market messaging applications. + +Language: English + +Date: 2024 + +https://www.youtube.com/watch?v=Fl-QS0-qENw + + +## SimpleXChat on Monero + Price and More! + +Monero Talk + +Interview, Video + +Monero Talk episode 239 featuring a discussion of SimpleX Chat's integration with Monero, payment incentives for server operators, and the broader privacy messaging landscape. + +Image: monero-talk-ep239-simplex.jpg + +Language: English + +Date: 2024 (estimated) + +https://www.youtube.com/live/quk4WY3fJCc?si=IQ0rutoHQpWtF2Un&t=3812 + + +## MoneroTopia 2026: The Future of SimpleX Network + +SimpleX Chat + +Conference talk, Video + +SimpleX Chat presentation at MoneroTopia 2026 conference in Mexico City, covering the latest developments in the SimpleX network and its alignment with the Monero privacy ecosystem. + +Image: monerotopia-2026-simplex.jpg + +Language: English + +Date: Feb 15, 2026 + +https://www.youtube.com/live/Alp-hVCoF7c?si=FqOTt1mCeQJVo-cM&t=16688 + +## SimpleX Chat - Simple Messaging With Unusually Good Privacy + +Bornhack 2023 conference, Chaos Computer Club + +Conference talk, Video + +This is a spontaneous talk about the relatively new (mobile apps released 2022) open source SimpleX Chat instant messenger protocol and software, and some reasons why it's a far better choice than in particular Matrix. + +Language: English + +Date: Aug 07, 2023 + +https://media.ccc.de/v/bornhack2023-56143-simplex-chat-simple-m diff --git a/docs/REPRODUCE.md b/docs/REPRODUCE.md new file mode 100644 index 0000000000..50c0b99280 --- /dev/null +++ b/docs/REPRODUCE.md @@ -0,0 +1,203 @@ +--- +title: Verify and reproduce builds +permalink: /reproduce/index.html +revision: 19.12.2025 +--- + +# Verifying and reproducing release builds + +- [Obtain release signing key](#obtain-release-signing-key) +- [Verify release signature](#verify-release-signature) +- [How to reproduce builds](#how-to-reproduce-builds) + - [Server binaries](#server-binaries) + - [Linux desktop apps and CLI](#linux-desktop-apps-and-cli) + - [Android apps](#android-apps) + +## Obtain release signing key + +To verify the signature of `_sha256sums` or apks you need to obtain the signing key. You can do it from keyservers: + +```sh +gpg --keyserver hkps://keys.openpgp.org --search build@simplex.chat +gpg --keyserver hkps://keyserver.ubuntu.com --search build@simplex.chat +``` + +```sh +gpg --list-keys build@simplex.chat +``` + +Once you obtain the signing key, verify that its fingerprint is: + +``` +BBDF 7BDA D154 8B16 836A F5B9 D53B DFD1 53C3 66BA +``` + +Additionally, compare the key fingerprint with: + +- [simplexchat.eth](https://app.ens.domains/simplexchat.eth) (release key record) +- [Mastodon](https://mastodon.social/@simplex) (profile) +- [Reddit](https://www.reddit.com/r/SimpleXChat/) (side panel) + +You can set the imported key as "ultimately trusted": + +```sh +echo -e "trust\n5\ny\nquit" | gpg --command-fd 0 --edit-key build@simplex.chat +``` + +## Verify release signature + +**Linux desktop apps and CLI**: + +Download the file with executable hashes and the signature. For example, to verify the `v6.5.0-beta.3` release: + +```sh +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/_sha256sums.asc' +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/_sha256sums' +``` + +Verify the signature: + +```sh +gpg --verify _sha256sums.asc _sha256sums +``` + +**Android APKs**: + +Download the APK files and signatures. For example, to verify the `v6.5.0-beta.3` release: + +```sh +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/simplex-aarch64.apk' +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/_simplex-aarch64.apk.asc' +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/simplex-armv7a.apk' +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/_simplex-armv7a.apk.asc' +``` + +Verify the signatures: + +```sh +gpg --verify _simplex-armv7a.apk.asc simplex-armv7a.apk +gpg --verify _simplex-aarch64.apk.asc simplex-aarch64.apk +``` + +## How to reproduce builds + +To reproduce the build you must have: + +- Linux machine +- `x86-64` architecture +- Installed `docker`, `curl` and `git` + +### Server binaries + +1. Download script: + + ```sh + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/master/scripts/simplexmq-reproduce-builds.sh' + ``` + +2. Make it executable: + + ```sh + chmod +x simplexmq-reproduce-builds.sh + ``` + +3. Execute the script with the required tag: + + ```sh + ./simplexmq-reproduce-builds.sh 'v6.3.1' + ``` + + The script executes these steps (please review the script to confirm): + + 1) builds all server binaries for the release in docker container. + 2) downloads binaries from the same GitHub release and compares them with the built binaries. + 3) if they all match, generates _sha256sums file with their checksums. + + This will take a while. + +4. After compilation, you should see the folder named as the tag and repository name (e.g., `v6.3.1-simplexmq`) with two subfolders: + + ```sh + ls v6.3.1-simplexmq + ``` + + ```sh + from-source prebuilt _sha256sums + ``` + + The file _sha256sums contains the hashes of all builds - you can compare it with the same file in GitHub release. + +### Linux desktop apps and CLI + +1. Download script: + + ```sh + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplex-chat/refs/heads/master/scripts/simplex-chat-reproduce-builds.sh' + ``` + +2. Make it executable: + + ```sh + chmod +x simplex-chat-reproduce-builds.sh + ``` + +3. Execute the script with the required tag: + + ```sh + ./simplex-chat-reproduce-builds.sh 'v6.4.8' + ``` + + The script executes these steps (please review the script to confirm): + + 1) builds all Linux CLI and Desktop binaries for the release in docker container. + 2) downloads binaries from the same GitHub release and compares them with the built binaries. + 3) if they all match, generates _sha256sums file with their checksums. + + This will take a while. + +4. After compilation, you should see the folder named as the tag and repository name (e.g., `v6.4.8-simplex-chat`) with two subfolders: + + ```sh + ls v6.4.8-simplex-chat + ``` + + ```sh + from-source prebuilt _sha256sums + ``` + + The file _sha256sums contains the hashes of all builds - you can compare it with the same file in GitHub release. + +### Android apps + +In addition to basic requirements, Android build will: + +- Take ~150gb of disc space +- Take ~20h to build all the architectures (depends on core count) +- Require at least 16gb of RAM + +1. Download script: + + ```sh + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplex-chat/refs/heads/master/scripts/simplex-chat-reproduce-builds-android.sh' + ``` + +2. Make it executable: + + ```sh + chmod +x simplex-chat-reproduce-builds-android.sh + ``` + +3. Execute the script with the required tag: + + ```sh + ./simplex-chat-reproduce-builds-android.sh 'v6.5.0-beta.3' + ``` + + The script executes these steps (please review the script to confirm): + + 1) Downloads and checks that APKs from GitHub are signed with valid key. + 2) Builds Android APKs in a docker container. + 3) Compares the releases by copying the signature from downloaded APKs to locally built APKs. + 4) If the resulting build is bit-by-bit identical, prints the message that this tag was reproduced. + + This will take a while. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 72db650c35..b9218fbebc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -54,7 +54,7 @@ We will determine the risk of each issue, taking into account our experience dea **Issue severity levels** -- **CRITICAL severity**. Such issues should affect common configurations and be exploitable with low or medium difficulty. For example: significant disclosure of the encrypted users messages or files either via relays or via communication channels, vulnerabilities which can be easily exploited remotely to compromise clients or servers private keys. These issues will be kept private and will trigger a new release of all supported versions. +- **CRITICAL severity**. Such issues should affect common configurations and be exploitable with low or medium difficulty. For example: significant disclosure of the encrypted users' messages or files either via relays or via communication channels, vulnerabilities which can be easily exploited remotely to compromise clients or servers private keys. These issues will be kept private and will trigger a new release of all supported versions. - **HIGH severity**. This includes issues that are of a lower risk than critical, possibly due to affecting less common configurations, or have high difficulty to be exploited. These issues will be kept private and will trigger a new release of all supported versions. - **MEDIUM severity**. This includes issues like crashes in client applications caused by the received messages or files, flaws in protocols that are less commonly used, and local flaws. These will in general be kept private until the next release, and that release will be scheduled so that it can roll up several such flaws at one time. - **LOW severity**. This includes issues such as those that only affect the SimpleX CLI app, or unlikely configurations, or issues that would be classified as medium but are very difficult to exploit. These will in general be fixed immediately in latest development versions, and may be back-ported to older versions that are still getting updates. These issues may be kept private or be included in commit messages. diff --git a/docs/SERVER.md b/docs/SERVER.md index f45403be8a..a35ede5cd0 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -59,7 +59,7 @@ To create SMP server as a systemd service, you'll need: - Your server domain, with A and AAAA records specifying server IPv4 and IPv6 addresses (`smp1.example.com`) - A basic Linux knowledge. -*Please note*: while you can run an SMP server without a domain name, in the near future client applications will start using server domain name in the invitation links (instead of `simplex.chat` domain they use now). In case a server does not have domain name and server pages (see below), the clients will be generaing the links with `simplex:` scheme that cannot be opened in the browsers. +*Please note*: while you can run an SMP server without a domain name, in the near future client applications will start using server domain name in the invitation links (instead of `simplex.chat` domain they use now). In case a server does not have domain name and server pages (see below), the clients will be generating the links with `simplex:` scheme that cannot be opened in the browsers. 1. Install server with [Installation script](https://github.com/simplex-chat/simplexmq#using-installation-script). @@ -82,7 +82,7 @@ To create SMP server as a systemd service, you'll need: --control-port \ --socks-proxy \ --source-code \ - --fqdn=smp1.example.com + --fqdn=smp1.example.com' ``` 4. Install tor: @@ -114,7 +114,7 @@ To create SMP server as a systemd service, you'll need: ```sh # Enable log (otherwise, tor doesn't seem to deploy onion address) Log notice file /var/log/tor/notices.log - # Enable single hop routing (2 options below are dependencies of the third) - It will reduce the latency at the cost of lower anonimity of the server - as SMP-server onion address is used in the clients together with public address, this is ok. If you deploy SMP-server with onion-only address, keep standard configuration. + # Enable single hop routing (2 options below are dependencies of the third) - It will reduce the latency at the cost of lower anonymity of the server - as SMP-server onion address is used in the clients together with public address, this is ok. If you deploy SMP-server with onion-only address, keep standard configuration. SOCKSPort 0 HiddenServiceNonAnonymousMode 1 HiddenServiceSingleHopMode 1 @@ -194,12 +194,12 @@ To create SMP server as a systemd service, you'll need: key_name='web.key' cert_name='web.crt' - # Copy certifiacte from Caddy directory to smp-server directory + # Copy certificate from Caddy directory to smp-server directory cp "${folder_in}/${domain}.crt" "${folder_out}/${cert_name}" # Assign correct permissions chown "$user":"$group" "${folder_out}/${cert_name}" - # Copy certifiacte key from Caddy directory to smp-server directory + # Copy certificate key from Caddy directory to smp-server directory cp "${folder_in}/${domain}.key" "${folder_out}/${key_name}" # Assign correct permissions chown "$user":"$group" "${folder_out}/${key_name}" @@ -535,7 +535,7 @@ To verify server binaries after you downloaded them: > Good signature from "SimpleX Chat " -5. Compute the hashes of the binaries you plan to use with `shu256sum ` or with `openssl sha256 ` and compare them with the hashes in the file `_sha256sums` - they must be the same. +5. Compute the hashes of the binaries you plan to use with `sha256sum ` or with `openssl sha256 ` and compare them with the hashes in the file `_sha256sums` - they must be the same. That is it - you now verified authenticity of our GitHub server binaries. @@ -634,7 +634,7 @@ to initialize your `smp-server` configuration with: --- -After that, your installation is complete and you should see in your teminal output something like this: +After that, your installation is complete and you should see in your terminal output something like this: ```sh Certificate request self-signature ok @@ -742,7 +742,7 @@ websockets: off [PROXY] # Network configuration for SMP proxy client. # `host_mode` can be 'public' (default) or 'onion'. -# It defines prefferred hostname for destination servers with multiple hostnames. +# It defines preferred hostname for destination servers with multiple hostnames. # host_mode: public # required_host_mode: off @@ -757,7 +757,7 @@ websockets: off # or 'always' to be used for all destination hosts (can be used if it is an .onion server). # socks_mode: onion -# Limit number of threads a client can spawn to process proxy commands in parrallel. +# Limit number of threads a client can spawn to process proxy commands in parallel. # client_concurrency: 32 [INACTIVE_CLIENTS] @@ -823,7 +823,7 @@ Follow the steps to secure your CA keys: /etc/opt/simplex/ca.key ``` -3. Delete the CA key from the server. **Please make sure you've saved you CA key somewhere safe. Otherwise, you would lose the ability to [rotate the online certificate](#online-certificate-rotation)**: +3. Delete the CA key from the server. **Please make sure you've saved your CA key somewhere safe. Otherwise, you would lose the ability to [rotate the online certificate](#online-certificate-rotation)**: ```sh rm /etc/opt/simplex/ca.key @@ -913,9 +913,9 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject 1. Install tor: - We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [offical tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide. + We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [official tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide. - - Configure offical Tor PPA repository: + - Configure official Tor PPA repository: ```sh CODENAME="$(lsb_release -c | awk '{print $2}')" @@ -951,12 +951,12 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject vim /etc/tor/torrc ``` - And insert the following lines to the bottom of configuration. Please note lines starting with `#`: this is comments about each individual options. + And insert the following lines to the bottom of configuration. Please note lines starting with `#`: these are comments about each individual option. ```sh # Enable log (otherwise, tor doesn't seem to deploy onion address) Log notice file /var/log/tor/notices.log - # Enable single hop routing (2 options below are dependencies of the third) - It will reduce the latency at the cost of lower anonimity of the server - as SMP-server onion address is used in the clients together with public address, this is ok. If you deploy SMP-server with onion-only address, you may want to keep standard configuration instead. + # Enable single hop routing (2 options below are dependencies of the third) - It will reduce the latency at the cost of lower anonymity of the server - as SMP-server onion address is used in the clients together with public address, this is ok. If you deploy SMP-server with onion-only address, you may want to keep standard configuration instead. SOCKSPort 0 HiddenServiceNonAnonymousMode 1 HiddenServiceSingleHopMode 1 @@ -974,7 +974,7 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject 3. Start tor: - Enable `systemd` service and start tor. Offical `tor` is a bit flaky on the first start and may not create onion host address, so we're restarting it just in case. + Enable `systemd` service and start tor. Official `tor` is a bit flaky on the first start and may not create onion host address, so we're restarting it just in case. ```sh systemctl enable --now tor && systemctl restart tor @@ -994,7 +994,7 @@ SMP-server versions starting from `v5.8.0-beta.0` can be configured to PROXY smp 1. Install tor as described in the [previous section](#installation-for-onion-address). -2. Execute the following command to creatae a new Tor daemon instance: +2. Execute the following command to create a new Tor daemon instance: ```sh tor-instance-create tor2 @@ -1101,7 +1101,7 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. hosting_country: ``` -2. Install the webserver. For easy deployment we'll describe the installtion process of [Caddy](https://caddyserver.com) webserver on Ubuntu server: +2. Install the webserver. For easy deployment we'll describe the installation process of [Caddy](https://caddyserver.com) webserver on Ubuntu server: 1. Install the packages: @@ -1127,7 +1127,7 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. sudo apt update && sudo apt install caddy ``` - [Full Caddy instllation instructions](https://caddyserver.com/docs/install) + [Full Caddy installation instructions](https://caddyserver.com/docs/install) 3. Replace Caddy configuration with the following: @@ -1176,12 +1176,12 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. key_name='web.key' cert_name='web.crt' - # Copy certifiacte from Caddy directory to smp-server directory + # Copy certificate from Caddy directory to smp-server directory cp "${folder_in}/${domain}.crt" "${folder_out}/${cert_name}" # Assign correct permissions chown "$user":"$group" "${folder_out}/${cert_name}" - # Copy certifiacte key from Caddy directory to smp-server directory + # Copy certificate key from Caddy directory to smp-server directory cp "${folder_in}/${domain}.key" "${folder_out}/${key_name}" # Assign correct permissions chown "$user":"$group" "${folder_out}/${key_name}" @@ -1237,7 +1237,7 @@ smp://[:]@[,] - **optional** `` - Your configured password of `smp-server`. You can check your configured pasword in `/etc/opt/simplex/smp-server.ini`, under `[AUTH]` section in `create_password:` field. + Your configured password of `smp-server`. You can check your configured password in `/etc/opt/simplex/smp-server.ini`, under `[AUTH]` section in `create_password:` field. - ``, **optional** `` @@ -1368,9 +1368,9 @@ Here's the full list of commands, their descriptions and who can access them. | `stats-rts` | GHC/Haskell statistics. Can be enabled with `+RTS -T -RTS` option | - | | `clients` | Clients information. Useful for debugging. | yes | | `sockets` | General sockets information. | - | -| `socket-threads` | Thread infomation per socket. Useful for debugging. | yes | +| `socket-threads` | Thread information per socket. Useful for debugging. | yes | | `threads` | Threads information. Useful for debugging. | yes | -| `server-info` | Aggregated server infomation. | - | +| `server-info` | Aggregated server information. | - | | `delete` | Delete known queue. Useful for content moderation. | - | | `save` | Save queues/messages from memory. | yes | | `help` | Help menu. | - | @@ -1417,31 +1417,31 @@ fromTime,qCreated,qSecured,qDeleted,msgSent,msgRecv,dayMsgQueues,weekMsgQueues,m | 20 | `pRelays_pRequests` | - requests | | 21 | `pRelays_pSuccesses` | - successes | | 22 | `pRelays_pErrorsConnect` | - connection errors | -| 23 | `pRelays_pErrorsCompat` | - compatability errors | +| 23 | `pRelays_pErrorsCompat` | - compatibility errors | | 24 | `pRelays_pErrorsOther` | - other errors | | Requested sessions with own relays: | | 25 | `pRelaysOwn_pRequests` | - requests | | 26 | `pRelaysOwn_pSuccesses` | - successes | | 27 | `pRelaysOwn_pErrorsConnect` | - connection errors | -| 28 | `pRelaysOwn_pErrorsCompat` | - compatability errors | +| 28 | `pRelaysOwn_pErrorsCompat` | - compatibility errors | | 29 | `pRelaysOwn_pErrorsOther` | - other errors | | Message forwards to all relays: | | 30 | `pMsgFwds_pRequests` | - requests | | 31 | `pMsgFwds_pSuccesses` | - successes | | 32 | `pMsgFwds_pErrorsConnect` | - connection errors | -| 33 | `pMsgFwds_pErrorsCompat` | - compatability errors | +| 33 | `pMsgFwds_pErrorsCompat` | - compatibility errors | | 34 | `pMsgFwds_pErrorsOther` | - other errors | | Message forward to own relays: | | 35 | `pMsgFwdsOwn_pRequests` | - requests | | 36 | `pMsgFwdsOwn_pSuccesses` | - successes | | 37 | `pMsgFwdsOwn_pErrorsConnect` | - connection errors | -| 38 | `pMsgFwdsOwn_pErrorsCompat` | - compatability errors | +| 38 | `pMsgFwdsOwn_pErrorsCompat` | - compatibility errors | | 39 | `pMsgFwdsOwn_pErrorsOther` | - other errors | | Received message forwards: | | 40 | `pMsgFwdsRecv` | | -| Message queue subscribtion errors: | +| Message queue subscription errors: | | 41 | `qSub` | All | -| 42 | `qSubAuth` | Authentication erorrs | +| 42 | `qSubAuth` | Authentication errors | | 43 | `qSubDuplicate` | Duplicate SUB errors | | 44 | `qSubProhibited` | Prohibited SUB errors | | Message errors: | @@ -1526,9 +1526,9 @@ To update your smp-server to latest version, choose your installation method and sudo systemctl start smp-server ``` - - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + - [Official installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - 1. Execute the followin command: + 1. Execute the following command: ```sh sudo simplex-servers-update @@ -1640,7 +1640,7 @@ To reproduce the build you must have: ## Configuring the app to use the server -To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. +To configure the app to use your messaging server copy its full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. It is also possible to share the address of your server with your friends by letting them scan QR code from server settings - it will include server password, so they will be able to receive messages via your server as well. diff --git a/docs/SIMPLEX.md b/docs/SIMPLEX.md index ec25afaf88..e24275d656 100644 --- a/docs/SIMPLEX.md +++ b/docs/SIMPLEX.md @@ -89,7 +89,7 @@ There are several P2P chat/messaging protocols and implementations that aim to s 5. All known P2P networks are likely to be vulnerable to [Sybil attack][12], because each node is discoverable, and the network operates as a whole. Known measures to reduce the probability of the Sybil attack either require a centralized component or expensive [proof of work][13]. The proposed design, on the opposite, has no server discoverability - servers are not connected, not known to each other and to all clients. The SimpleX network is fragmented and operates as multiple isolated connections. It makes network-wide attacks on SimpleX network impossible - even if some servers are compromised, other parts of the network can operate normally, and affected clients can switch to using other servers without losing contacts or messages. -6. P2P networks are likely to be [vulnerable][14] to [DRDoS attack][15]. In the proposed design clients only relay traffic from known trusted connection and cannot be used to reflect and amplify the traffic in the whole network. +6. P2P networks are likely to be [vulnerable][14] to [DRDoS attack][15]. In the proposed design clients only relay traffic from known trusted connections and cannot be used to reflect and amplify the traffic in the whole network. [1]: https://en.wikipedia.org/wiki/End-to-end_encryption [2]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack diff --git a/docs/TRANSLATIONS.md b/docs/TRANSLATIONS.md index d5c1cdef0b..a0250b6ab2 100644 --- a/docs/TRANSLATIONS.md +++ b/docs/TRANSLATIONS.md @@ -1,5 +1,5 @@ --- -title: Contributing translations to SimpleX Chat +title: Contributing SimpleX app translations revision: 19.03.2023 --- @@ -35,7 +35,7 @@ The steps are: ### Translating Android app -1. Please start from [Android app](https://hosted.weblate.org/projects/simplex-chat/android/), both when you do the most time-consuming initial translation, and add any strings later. Firstly, iOS strings can be a bit delayed from appearing in Weblate, as it requires a manual step from us before they are visible. Secondary, Android app is set up as a glossary for iOS app, and 2/3 of all strings require just to clicks to transfer them from Android to iOS (it still takes some time, Weblate doesn't automate it, unfortunately). +1. Please start from [Android app](https://hosted.weblate.org/projects/simplex-chat/android/), both when you do the most time-consuming initial translation, and add any strings later. Firstly, iOS strings can be a bit delayed from appearing in Weblate, as it requires a manual step from us before they are visible. Secondly, Android app is set up as a glossary for iOS app, and 2/3 of all strings require just two clicks to transfer them from Android to iOS (it still takes some time, Weblate doesn't automate it, unfortunately). 2. Some of the strings do not need translations, but they still need to be copied over - there is a button in weblate UI for that: diff --git a/docs/TRANSPARENCY.md b/docs/TRANSPARENCY.md index bd0dcabb53..c3e74d6b28 100644 --- a/docs/TRANSPARENCY.md +++ b/docs/TRANSPARENCY.md @@ -1,18 +1,18 @@ --- title: Transparency Reports permalink: /transparency/index.html -revision: 15.01.2025 +revision: 09.02.2026 --- # Transparency Reports -**Updated**: Jan 15, 2025 +**Updated**: Feb 09, 2026 SimpleX Chat Ltd. is a company registered in the UK – it develops communication software enabling users to operate and communicate via SimpleX network, without user profile identifiers of any kind, and without having their data hosted by any network infrastructure operators. This page will include any and all reports on requests for user data. -*To date, we received none*. +In 2025 we received 12 requests from law enforcement of different countries. No responsive information was identified/provided. In 2024 we received enquiries from several law enforcement agencies seeking information on our procedures for handling data requests. We responded by noting that we operate under the UK law and will consider such requests pursuant to UK law. @@ -29,6 +29,6 @@ Our objective is to consistently ensure that no user data and absolute minimum o - Trail of Bits, SimpleX cryptography and networking, [October 2022](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). - Trail of Bits, the cryptographic review of SimpleX protocols design, [July 2024](../blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md). -Have a more specific question? Reach out to us via [SimpleX Chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) or via email [chat@simplex.chat](mailto:chat@simplex.chat). +Have a more specific question? Reach out to us via [SimpleX Chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw) or via email [chat@simplex.chat](mailto:chat@simplex.chat). For any sensitive questions please use SimpleX Chat or encrypted email messages using the key for this address from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and make your key available for a secure reply. diff --git a/docs/WEBRTC.md b/docs/WEBRTC.md index 8ce31bf959..b4862e0d5b 100644 --- a/docs/WEBRTC.md +++ b/docs/WEBRTC.md @@ -1,5 +1,5 @@ --- -title: Using custom WebRTC ICE servers in SimpleX Chat +title: Using custom WebRTC ICE servers revision: 31.01.2023 --- @@ -18,7 +18,7 @@ For this guide, we'll be using the most featureful and battle-tested STUN/TURN s 1. Install `coturn` package from the main repository. ```sh -apt update && apt install coturn` +apt update && apt install coturn ``` 2. Uncomment `TURNSERVER_ENABLED=1` from `/etc/default/coturn`: @@ -44,7 +44,7 @@ user=$YOUR_LOGIN:$YOUR_PASSWORD server-name=$YOUR_DOMAIN # The default realm to be used for the users when no explicit origin/realm relationship was found realm=$YOUR_DOMAIN -# Path to your certificates. Make sure they're readable by cotun process user/group +# Path to your certificates. Make sure they're readable by coturn process user/group cert=/var/lib/turn/cert.pem pkey=/var/lib/turn/key.pem # Use 2066 bits predefined DH TLS key @@ -97,7 +97,7 @@ To configure your mobile app to use your server: 1. Open `Settings / Network & Servers / WebRTC ICE servers` and switch toggle `Configure ICE servers`. -2. Enter all server addresses in the field, one per line, for example if you servers are on the port 5349: +2. Enter all server addresses in the field, one per line, for example if your servers are on the port 5349: ``` stun:stun.example.com:5349 @@ -116,7 +116,7 @@ This is it - you now can make audio and video calls via your own server, without ping ``` - If packets being transmitted, server is up! + If packets are being transmitted, the server is up! - **Determine if ports are open**: @@ -155,4 +155,3 @@ This is it - you now can make audio and video calls via your own server, without If results show `srflx` and `relay` candidates, everything is set up correctly! - diff --git a/docs/WHY.md b/docs/WHY.md new file mode 100644 index 0000000000..e1582984df --- /dev/null +++ b/docs/WHY.md @@ -0,0 +1,19 @@ +# Why we are building SimpleX Network + +You were born without an account. + +Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature — it was the way of life. + +Then we moved online, and every platform asked for a piece of you — your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way — telephone, email, messengers, social media. It seemed the only way possible. + +There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + +Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it — you are sovereign. + +Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + +The oldest human freedom — to speak to another person without being watched — built on infrastructure that cannot betray it. + +Because we destroyed the power to know who you are. So that your power can never be taken. + +Be free in your network. diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index ba4770644e..43edbdda7f 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -9,7 +9,7 @@ revision: 31.07.2023 - [Overview](#overview) - [Installation options](#installation-options) - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) - - [docker container](#docker-сontainer) + - [docker container](#docker-container) - [Linode marketplace](#linode-marketplace) - [Tor installation](#tor-installation) - [Configuration](#configuration) @@ -72,7 +72,7 @@ Manual installation is the most advanced deployment that provides the most flexi 1. Install binary: - - Using offical binaries: + - Using official binaries: ```sh curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server @@ -129,9 +129,9 @@ Manual installation is the most advanced deployment that provides the most flexi And execute `sudo systemctl daemon-reload`. -### Docker сontainer +### Docker container -You can deploy smp-server using Docker Compose. This is second recommended option due to its popularity and relatively easy deployment. +You can deploy xftp-server using Docker Compose. This is the second recommended option due to its popularity and relatively easy deployment. This deployment provides two Docker Compose files: the **automatic** one and **manual**. If you're not sure, choose **automatic**. @@ -197,9 +197,9 @@ xftp-server can also be deployed to serve from [tor](https://www.torproject.org) 1. Install tor: - We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [offical tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide. + We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [official tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide. - - Configure offical Tor PPA repository: + - Configure official Tor PPA repository: ```sh CODENAME="$(lsb_release -c | awk '{print $2}')" @@ -235,10 +235,10 @@ xftp-server can also be deployed to serve from [tor](https://www.torproject.org) vim /etc/tor/torrc ``` - And insert the following lines to the bottom of configuration. Please note lines starting with `#`: this is comments about each individual options. + And insert the following lines to the bottom of configuration. Please note lines starting with `#`: these are comments about each individual option. ```sh - # Enable log (otherwise, tor doesn't seemd to deploy onion address) + # Enable log (otherwise, tor doesn't seem to deploy onion address) Log notice file /var/log/tor/notices.log # Enable single hop routing (2 options below are dependencies of third). Will reduce latency in exchange of anonimity (since tor runs alongside xftp-server and onion address will be displayed in clients, this is totally fine) SOCKSPort 0 @@ -257,7 +257,7 @@ xftp-server can also be deployed to serve from [tor](https://www.torproject.org) 3. Start tor: - Enable `systemd` service and start tor. Offical `tor` is a bit flunky on the first start and may not create onion host address, so we're restarting it just in case. + Enable `systemd` service and start tor. Official `tor` is a bit flaky on the first start and may not create onion host address, so we're restarting it just in case. ```sh systemctl enable tor && systemctl start tor && systemctl restart tor @@ -356,7 +356,7 @@ To password-protect your `xftp-server`, change it in the configuration: ``` --- -After that, your installation is complete and you should see in your teminal output something like this: +After that, your installation is complete and you should see in your terminal output something like this: ```sh Certificate request self-signature ok @@ -398,7 +398,7 @@ xftp://[:]@[,] - **optional** `` - Your configured password of `xftp-server`. You can check your configured pasword in `/etc/opt/simplex-xftp/file-server.ini`, under `[AUTH]` section in `create_password:` field. + Your configured password of `xftp-server`. You can check your configured password in `/etc/opt/simplex-xftp/file-server.ini`, under `[AUTH]` section in `create_password:` field. - ``, **optional** `` @@ -609,8 +609,8 @@ To update your XFTP server to latest version, choose your installation method an sudo systemctl start xftp-server ``` - - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - 1. Execute the followin command: + - [Official installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Execute the following command: ```sh sudo simplex-servers-update ``` diff --git a/docs/contributing/CODE.md b/docs/contributing/CODE.md new file mode 100644 index 0000000000..1dcf795c00 --- /dev/null +++ b/docs/contributing/CODE.md @@ -0,0 +1,107 @@ +# Coding and building + +This file provides guidance on coding style and approaches and on building the code. + +## Code Security + +When designing code and planning implementations: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. +- Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances. + +## Code Style, Formatting and Approaches + +The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`. + +**Key formatting rules:** +- 2-space indentation +- Trailing function arrows, commas, and import/export style +- Record brace without space: `{field = value}` +- Single newline between declarations +- Never use unicode symbols +- Inline `let` style with right-aligned `in` + +**Format code before committing:** + +```bash +# Format a single file +fourmolu -i src/Simplex/Messaging/Protocol.hs +``` + +Some files that use CPP language extension cannot be formatted as a whole, so individual code fragments need to be formatted. + +**Follow existing code patterns:** +- Match the style of surrounding code +- Use qualified imports with short aliases (e.g., `import qualified Data.ByteString.Char8 as B`) +- Use record syntax for types with multiple fields +- Prefer explicit pattern matching over partial functions + +**Comments policy:** +- Avoid redundant comments that restate what the code already says +- Only comment on non-obvious design decisions or tricky implementation details +- Function names and type signatures should be self-documenting +- Do not add comments like "wire format encoding" (Encoding class is always wire format) or "check if X" when the function name already says that +- Assume a competent Haskell reader + +**Diff and refactoring:** +- Avoid unnecessary changes and code movements +- Never rename existing variables, parameters, or functions unless the rename is the point of the change +- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring +- Aim to minimize the code changes - do what is minimally required to solve users' problems + +**Type-driven development:** +- Types must reflect business semantics, not data shape. E.g., `CIChannelRcv` (channel message) vs `CIGroupRcv GroupMember` (member message) are semantically distinct — do not collapse them into `CIGroupRcv (Maybe GroupMember)` just because the data overlaps. Duplicate pattern match arms across semantic constructors are acceptable. +- Duplicate function bodies are not acceptable. When adding a new variant of existing behavior, parameterize existing functions to handle both variants — do not copy function bodies into parallel code paths. +- Concrete example: if `groupMessageFileDescription` and `channelMessageFileDescription` share 90% of their logic, extract a shared helper and make both into thin wrappers — do not maintain two near-identical function bodies. +- When the return type differs between variants (e.g., one returns `Maybe X`, another returns `()`), use the more general return type and have callers discard what they don't need. + +**Document and code structure:** +- **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure. +- When adding new sections to documents, continue the existing numbering scheme. +- Minimize diff size - prefer small, targeted changes over reorganization. + +**Code analysis and review:** +- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. +- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. +- Do not save time on analysis. Read every function in the data flow even when the interface seems clear — wrong assumptions about internals are the main source of missed bugs. + +### Haskell Extensions +- `StrictData` enabled by default +- Use STM for safe concurrency +- Assume concurrency in PostgreSQL queries +- Comprehensive warning flags with strict pattern matching + +## Build Commands + +```bash +# Standard build +cabal build + +# Fast build +cabal build --ghc-options -O0 + +# Build specific executables +cabal build exe:simplex-chat + +# Build with PostgreSQL client support +cabal build -fclient_postgres + +# Client-only library build (no server code) +cabal build -fclient_library + +# Find binary location +cabal list-bin exe:simplex-chat +``` + +### Cabal Flags + +- `swift`: Enable Swift JSON format +- `client_library`: Build without server code +- `client_postgres`: Use PostgreSQL instead of SQLite for agent persistence +- `server_postgres`: PostgreSQL support for server queue/notification store + +## External Dependencies + +Custom forks specified in `cabal.project`: +- `aeson`, `hs-socks` (SimpleX forks) +- `direct-sqlcipher`, `sqlcipher-simple` (encrypted SQLite) +- `warp`, `warp-tls` (HTTP server) diff --git a/docs/contributing/PROJECT.md b/docs/contributing/PROJECT.md new file mode 100644 index 0000000000..3f7e6e0e54 --- /dev/null +++ b/docs/contributing/PROJECT.md @@ -0,0 +1,92 @@ +# SimpleX-Chat repository + +This file provides guidance on the project structure to help working with code in this repository. + +## Project Overview + +SimpleX Chat is a decentralized, privacy-focused messaging platform with **no user identifiers**. Users are identified by disposable, per-connection message queue addresses instead of any persistent ID. + +**Key components:** +- **Core library** (Haskell): `src/Simplex/Chat/` - chat protocol, controller, message handling, database storage +- **Terminal CLI**: `src/Simplex/Chat/Terminal/` +- **Mobile apps**: `apps/multiplatform/` (Kotlin Compose Multiplatform for Android/Desktop) +- **iOS app**: `apps/ios/` (SwiftUI) +- **Bot framework**: `bots/`, `packages/simplex-chat-nodejs/` +- **Website**: `website/` (11ty + Tailwind CSS) + +## Specifications + +Chat protocol: docs/protocol/simplex-chat.md + +RFCs: docs/rfcs + +## Core Haskell Modules + +- `Controller.hs` - Main chat controller, orchestrates all chat operations +- `Types.hs` - Core type definitions (contacts, groups, messages, profiles) +- `Protocol.hs` - Chat protocol encoding/decoding +- `Messages.hs` - Message types and handling +- `Store/` - Database layer (SQLite by default, PostgreSQL optional) + - `Messages.hs` - Message storage + - `Groups.hs` - Group storage + - `Direct.hs` - Direct chat storage + - `Connections.hs` - Connection management +- `Mobile.hs` - FFI interface +- `Library/` - commands and events processing + - `Commands.hs` - all supported chat commands. They can be sent via CLI or via FFI functions. + - `Subscriber.hs` - processing events from the [agent](../../../simplexmq/src/Simplex/Messaging/Agent/Protocol.hs) + +### Database Migrations + +SQLite migrations are in `src/Simplex/Chat/Store/SQLite/Migrations/`. PostgreSQL migrations are in `src/Simplex/Chat/Store/Postgres/Migrations/`. Each migration is a separate module named `M{YYYYMMDD}_{description}.hs`. + +**Important:** The `chat_schema.sql` files in both migration directories are **auto-generated by tests** - do not edit them directly. They reflect the final schema state after all migrations are applied. + +When creating a new migration: +1. Create the migration module (e.g., `M20260122_feature.hs`) +2. Register it in the corresponding `Migrations.hs` file +3. Add the module to `simplex-chat.cabal` under exposed-modules +4. Schema files will be updated automatically when tests are run + +### Test Structure + +Tests are in `tests/`: +- `ChatTests/` - Integration tests (Direct, Groups, Files, Profiles) +- `ProtocolTests.hs` - Protocol encoding/decoding tests +- `JSONTests.hs` - JSON serialization tests +- `Bots/` - Bot-specific tests + +## Key Dependencies + +The project uses several custom forks managed via `cabal.project`: +- `simplexmq` - Core SimpleX Messaging Protocol (separate [repo](../../../simplexmq/README.md)) +- `direct-sqlcipher` - SQLite with encryption +- `aeson` - JSON serialization (custom fork) + +## Android/Desktop (Kotlin Multiplatform) + +```bash +cd apps/multiplatform + +# Build Android debug APK +./gradlew assembleDebug + +# Build desktop +./gradlew :desktop:packageDistributionForCurrentOS + +# Run Android tests +./gradlew connectedAndroidTest +``` + +### iOS + +Open `apps/ios/SimpleX.xcodeproj` in Xcode. Build targets include the main app, Share Extension, and Notification Service Extension. + +### Website + +```bash +cd website +npm install +npm run start # Dev server +npm run build # Production build +``` diff --git a/docs/lang/cs/README.md b/docs/lang/cs/README.md index 35a37be73b..5ba6a87803 100644 --- a/docs/lang/cs/README.md +++ b/docs/lang/cs/README.md @@ -18,11 +18,11 @@   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) - 🖲 Chrání vaše zprávy a metadata - s kým a kdy mluvíte. - 🔐 Koncové šifrování s další vrstvou šifrování. -- 📱 Mobilní aplikace pro Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) a [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 📱 Mobilní aplikace pro Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk)) a [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) s novými funkcemi o 1-2 týdny dříve - **omezeno na 10 000 uživatelů**! - 🖥 K dispozici jako terminálová (konzolová) [aplikace / CLI](#zap-quick-installation-of-a-terminal-app) v systémech Linux, MacOS, Windows. @@ -324,4 +324,4 @@ Jakákoli zjištění možných útoků korelace provozu umožňujících korelo   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) diff --git a/docs/lang/fr/README.md b/docs/lang/fr/README.md index eaabfebe7d..bc06e9e228 100644 --- a/docs/lang/fr/README.md +++ b/docs/lang/fr/README.md @@ -32,11 +32,11 @@   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) - 🖲 Protégez vos messages et vos métadonnées - avec qui vous parlez et quand. - 🔐 Chiffrement de bout en bout à double ratchet, avec couche de chiffrement supplémentaire. -- 📱 Apps mobiles pour Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) et [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 📱 Apps mobiles pour Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk)) et [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [Bêta TestFlight pour iOS](https://testflight.apple.com/join/DWuT2LQu) avec les nouvelles fonctionnalités 1 à 2 semaines plus tôt - **limitée à 10 000 utilisateurs** ! - 🖥 Disponible en tant que [terminal (console) / CLI](#⚡-installation-rapide-dune-application-pour-terminal) sur Linux, MacOS, Windows. @@ -351,4 +351,4 @@ Veuillez traiter toute découverte d'une éventuelle attaque par corrélation de   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) diff --git a/docs/lang/pl/README.md b/docs/lang/pl/README.md index 9081b644f0..fc94863658 100644 --- a/docs/lang/pl/README.md +++ b/docs/lang/pl/README.md @@ -32,11 +32,11 @@   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) - 🖲 Chroni Twoje wiadomości i metadane - z kim rozmawiasz i kiedy. - 🔐 Szyfrowanie end-to-end double ratchet, z dodatkową warstwą szyfrowania. -- 📱 Aplikacje mobilne dla Androida ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) oraz [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 📱 Aplikacje mobilne dla Androida ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk)) oraz [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [TestFlight dla iOS](https://testflight.apple.com/join/DWuT2LQu) z nowymi funkcjami na tydzień-dwa wcześniej - **limitowane do 10,000 użytkowników**! - 🖥 Dostępny jako terminalowa (konsolowa) [aplikacja / CLI](#zap-quick-installation-of-a-terminal-app) na Linuxa, MacOSa, Windowsa. @@ -195,9 +195,9 @@ Twórca SimpleX Chat. ## Dlaczego prywatność ma znaczenie -Każdy powinien dbać o prywatność i bezpieczeństwo swojej komunikacji - nieszkodliwe rozmowy mogą narazić Cię na niebezpieczeństwo, nawet jeśli nie masz nic do ukrycia. +Każdy powinien dbać o prywatność i bezpieczeństwo swojej komunikacji - nieszkodliwe rozmowy mogą narazić Cię na niebezpieczeństwo, nawet jeśli nie masz nic do ukrycia. -Jedną z najbardziej wstrząsających historii jest doświadczenie [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi). opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech. +Jedną z najbardziej wstrząsających historii jest doświadczenie [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi). opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech. Używanie szyfrowanego komunikatora end-to-end nie jest wystarczające. Powinniśmy używać komunikatorów, które zapewniają prywatność naszym powiązaniom, czyli tym z kim jesteśmy jakkolwiek połączeni. @@ -429,4 +429,4 @@ Prosimy o traktowanie wszelkich ustaleń dotyczących możliwych ataków korelac   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) diff --git a/docs/links/images/3arbi4tech-simplex-comparison.jpg b/docs/links/images/3arbi4tech-simplex-comparison.jpg new file mode 100644 index 0000000000..b8b78936b7 Binary files /dev/null and b/docs/links/images/3arbi4tech-simplex-comparison.jpg differ diff --git a/docs/links/images/ababtools-simplex-review.jpg b/docs/links/images/ababtools-simplex-review.jpg new file mode 100644 index 0000000000..792a8f777a Binary files /dev/null and b/docs/links/images/ababtools-simplex-review.jpg differ diff --git a/docs/links/images/adminforge-simplex-server.jpg b/docs/links/images/adminforge-simplex-server.jpg new file mode 100644 index 0000000000..300adaa08f Binary files /dev/null and b/docs/links/images/adminforge-simplex-server.jpg differ diff --git a/docs/links/images/aiutocomputerhelp-simplex-revolution.jpg b/docs/links/images/aiutocomputerhelp-simplex-revolution.jpg new file mode 100644 index 0000000000..e6f1f3d36e Binary files /dev/null and b/docs/links/images/aiutocomputerhelp-simplex-revolution.jpg differ diff --git a/docs/links/images/alexemidio-substack-simplex.jpg b/docs/links/images/alexemidio-substack-simplex.jpg new file mode 100644 index 0000000000..54c06afef0 Binary files /dev/null and b/docs/links/images/alexemidio-substack-simplex.jpg differ diff --git a/docs/links/images/ameblo-vpn53049-simplex-recommend.jpg b/docs/links/images/ameblo-vpn53049-simplex-recommend.jpg new file mode 100644 index 0000000000..f19ab863fb Binary files /dev/null and b/docs/links/images/ameblo-vpn53049-simplex-recommend.jpg differ diff --git a/docs/links/images/ameblo-vpn53049-simplex-revolutionary.jpg b/docs/links/images/ameblo-vpn53049-simplex-revolutionary.jpg new file mode 100644 index 0000000000..f19ab863fb Binary files /dev/null and b/docs/links/images/ameblo-vpn53049-simplex-revolutionary.jpg differ diff --git a/docs/links/images/anarsec-e2ee-guide.jpg b/docs/links/images/anarsec-e2ee-guide.jpg new file mode 100644 index 0000000000..e337f160f3 Binary files /dev/null and b/docs/links/images/anarsec-e2ee-guide.jpg differ diff --git a/docs/links/images/appvisor-blocked-messengers.jpg b/docs/links/images/appvisor-blocked-messengers.jpg new file mode 100644 index 0000000000..60ddcbcb49 Binary files /dev/null and b/docs/links/images/appvisor-blocked-messengers.jpg differ diff --git a/docs/links/images/bastion-military-messengers.jpg b/docs/links/images/bastion-military-messengers.jpg new file mode 100644 index 0000000000..e7cd4d50ac Binary files /dev/null and b/docs/links/images/bastion-military-messengers.jpg differ diff --git a/docs/links/images/bednar-encrypted-messengers-en.jpg b/docs/links/images/bednar-encrypted-messengers-en.jpg new file mode 100644 index 0000000000..552e49e93b Binary files /dev/null and b/docs/links/images/bednar-encrypted-messengers-en.jpg differ diff --git a/docs/links/images/bednar-encrypted-messengers.jpg b/docs/links/images/bednar-encrypted-messengers.jpg new file mode 100644 index 0000000000..80c79dd2b1 Binary files /dev/null and b/docs/links/images/bednar-encrypted-messengers.jpg differ diff --git a/docs/links/images/bednar-encrypted-notifications.jpg b/docs/links/images/bednar-encrypted-notifications.jpg new file mode 100644 index 0000000000..98f05ba5e0 Binary files /dev/null and b/docs/links/images/bednar-encrypted-notifications.jpg differ diff --git a/docs/links/images/beebom-best-secure-2026.jpg b/docs/links/images/beebom-best-secure-2026.jpg new file mode 100644 index 0000000000..c8a9ae637f Binary files /dev/null and b/docs/links/images/beebom-best-secure-2026.jpg differ diff --git a/docs/links/images/beginner-privacy-simplex-group.jpg b/docs/links/images/beginner-privacy-simplex-group.jpg new file mode 100644 index 0000000000..18918cc90e Binary files /dev/null and b/docs/links/images/beginner-privacy-simplex-group.jpg differ diff --git a/docs/links/images/bhb-simplex-tutorial-2.jpg b/docs/links/images/bhb-simplex-tutorial-2.jpg new file mode 100644 index 0000000000..a482c3c909 Binary files /dev/null and b/docs/links/images/bhb-simplex-tutorial-2.jpg differ diff --git a/docs/links/images/billionnapkin-simplex-review.jpg b/docs/links/images/billionnapkin-simplex-review.jpg new file mode 100644 index 0000000000..4d77bebada Binary files /dev/null and b/docs/links/images/billionnapkin-simplex-review.jpg differ diff --git a/docs/links/images/bitcoin-ar-simplex-tutorial.jpg b/docs/links/images/bitcoin-ar-simplex-tutorial.jpg new file mode 100644 index 0000000000..eacc967e52 Binary files /dev/null and b/docs/links/images/bitcoin-ar-simplex-tutorial.jpg differ diff --git a/docs/links/images/bitcoinlighthouse-simplex-test.jpg b/docs/links/images/bitcoinlighthouse-simplex-test.jpg new file mode 100644 index 0000000000..a5b9b82ff7 Binary files /dev/null and b/docs/links/images/bitcoinlighthouse-simplex-test.jpg differ diff --git a/docs/links/images/blockbeats-vitalik-simplex.jpg b/docs/links/images/blockbeats-vitalik-simplex.jpg new file mode 100644 index 0000000000..1b4f926ce4 Binary files /dev/null and b/docs/links/images/blockbeats-vitalik-simplex.jpg differ diff --git a/docs/links/images/blockweeks-vitalik-simplex.jpg b/docs/links/images/blockweeks-vitalik-simplex.jpg new file mode 100644 index 0000000000..583ac57689 Binary files /dev/null and b/docs/links/images/blockweeks-vitalik-simplex.jpg differ diff --git a/docs/links/images/brightcoding-privacy-by-design.jpg b/docs/links/images/brightcoding-privacy-by-design.jpg new file mode 100644 index 0000000000..2c2a593d75 Binary files /dev/null and b/docs/links/images/brightcoding-privacy-by-design.jpg differ diff --git a/docs/links/images/bug-hr-app-of-day.jpg b/docs/links/images/bug-hr-app-of-day.jpg new file mode 100644 index 0000000000..58a9b5d889 Binary files /dev/null and b/docs/links/images/bug-hr-app-of-day.jpg differ diff --git a/docs/links/images/chinese-youtube-secure-tools.jpg b/docs/links/images/chinese-youtube-secure-tools.jpg new file mode 100644 index 0000000000..e7bc007c8f Binary files /dev/null and b/docs/links/images/chinese-youtube-secure-tools.jpg differ diff --git a/docs/links/images/citadel-dispatch-cd196.jpg b/docs/links/images/citadel-dispatch-cd196.jpg new file mode 100644 index 0000000000..0300cfc74c Binary files /dev/null and b/docs/links/images/citadel-dispatch-cd196.jpg differ diff --git a/docs/links/images/cloudsek-best-secure-2026.jpg b/docs/links/images/cloudsek-best-secure-2026.jpg new file mode 100644 index 0000000000..ba021d7d83 Binary files /dev/null and b/docs/links/images/cloudsek-best-secure-2026.jpg differ diff --git a/docs/links/images/cnews-cz-simplex-privacy.jpg b/docs/links/images/cnews-cz-simplex-privacy.jpg new file mode 100644 index 0000000000..769b5697b8 Binary files /dev/null and b/docs/links/images/cnews-cz-simplex-privacy.jpg differ diff --git a/docs/links/images/cnews-telegram-simplex-migration.jpg b/docs/links/images/cnews-telegram-simplex-migration.jpg new file mode 100644 index 0000000000..25233d0b7c Binary files /dev/null and b/docs/links/images/cnews-telegram-simplex-migration.jpg differ diff --git a/docs/links/images/codeby-simplex-free-messengers.jpg b/docs/links/images/codeby-simplex-free-messengers.jpg new file mode 100644 index 0000000000..9777e8a0fd Binary files /dev/null and b/docs/links/images/codeby-simplex-free-messengers.jpg differ diff --git a/docs/links/images/coinspeaker-jp-vitalik.jpg b/docs/links/images/coinspeaker-jp-vitalik.jpg new file mode 100644 index 0000000000..e45ec7f1cf Binary files /dev/null and b/docs/links/images/coinspeaker-jp-vitalik.jpg differ diff --git a/docs/links/images/computekni-simplex-chat.jpg b/docs/links/images/computekni-simplex-chat.jpg new file mode 100644 index 0000000000..75730fa6c6 Binary files /dev/null and b/docs/links/images/computekni-simplex-chat.jpg differ diff --git a/docs/links/images/cryptoslate-buterin-analysis.jpg b/docs/links/images/cryptoslate-buterin-analysis.jpg new file mode 100644 index 0000000000..fabbe9814e Binary files /dev/null and b/docs/links/images/cryptoslate-buterin-analysis.jpg differ diff --git a/docs/links/images/cryptotimes-buterin-simplex.jpg b/docs/links/images/cryptotimes-buterin-simplex.jpg new file mode 100644 index 0000000000..1b92ef223a Binary files /dev/null and b/docs/links/images/cryptotimes-buterin-simplex.jpg differ diff --git a/docs/links/images/cyberinsider-most-secure-2026.jpg b/docs/links/images/cyberinsider-most-secure-2026.jpg new file mode 100644 index 0000000000..34d49afcc0 Binary files /dev/null and b/docs/links/images/cyberinsider-most-secure-2026.jpg differ diff --git a/docs/links/images/datacampus-self-hosting.jpg b/docs/links/images/datacampus-self-hosting.jpg new file mode 100644 index 0000000000..cda10213f3 Binary files /dev/null and b/docs/links/images/datacampus-self-hosting.jpg differ diff --git a/docs/links/images/dcinside-vpngate-simplex-translation.jpg b/docs/links/images/dcinside-vpngate-simplex-translation.jpg new file mode 100644 index 0000000000..cd60d11659 Binary files /dev/null and b/docs/links/images/dcinside-vpngate-simplex-translation.jpg differ diff --git a/docs/links/images/dcinside-wikileaks-simplex.jpg b/docs/links/images/dcinside-wikileaks-simplex.jpg new file mode 100644 index 0000000000..1527c05b37 Binary files /dev/null and b/docs/links/images/dcinside-wikileaks-simplex.jpg differ diff --git a/docs/links/images/ddpa-simplex-overview.jpg b/docs/links/images/ddpa-simplex-overview.jpg new file mode 100644 index 0000000000..a7017f6989 Binary files /dev/null and b/docs/links/images/ddpa-simplex-overview.jpg differ diff --git a/docs/links/images/deeplife-anonymous-apps-list.jpg b/docs/links/images/deeplife-anonymous-apps-list.jpg new file mode 100644 index 0000000000..ff68796e02 Binary files /dev/null and b/docs/links/images/deeplife-anonymous-apps-list.jpg differ diff --git a/docs/links/images/deeplife-anonymous-life-guide.jpg b/docs/links/images/deeplife-anonymous-life-guide.jpg new file mode 100644 index 0000000000..c31cc4a5af Binary files /dev/null and b/docs/links/images/deeplife-anonymous-life-guide.jpg differ diff --git a/docs/links/images/dept-one-simplex-memo.jpg b/docs/links/images/dept-one-simplex-memo.jpg new file mode 100644 index 0000000000..d0d8dbac17 Binary files /dev/null and b/docs/links/images/dept-one-simplex-memo.jpg differ diff --git a/docs/links/images/dev-community-privacy-setup-2026.jpg b/docs/links/images/dev-community-privacy-setup-2026.jpg new file mode 100644 index 0000000000..a1d5d2e1d0 Binary files /dev/null and b/docs/links/images/dev-community-privacy-setup-2026.jpg differ diff --git a/docs/links/images/digitalcourage-simplex-recommendation.jpg b/docs/links/images/digitalcourage-simplex-recommendation.jpg new file mode 100644 index 0000000000..badd42c295 Binary files /dev/null and b/docs/links/images/digitalcourage-simplex-recommendation.jpg differ diff --git a/docs/links/images/diolinux-simplex-messenger.jpg b/docs/links/images/diolinux-simplex-messenger.jpg new file mode 100644 index 0000000000..bcd5fcb04c Binary files /dev/null and b/docs/links/images/diolinux-simplex-messenger.jpg differ diff --git a/docs/links/images/ecosistemastartup-simplex.jpg b/docs/links/images/ecosistemastartup-simplex.jpg new file mode 100644 index 0000000000..d947ec934e Binary files /dev/null and b/docs/links/images/ecosistemastartup-simplex.jpg differ diff --git a/docs/links/images/edivaldo-brito-simplex-review.jpg b/docs/links/images/edivaldo-brito-simplex-review.jpg new file mode 100644 index 0000000000..bf3dfac3a7 Binary files /dev/null and b/docs/links/images/edivaldo-brito-simplex-review.jpg differ diff --git a/docs/links/images/edivaldobrito-simplex-flatpak.jpg b/docs/links/images/edivaldobrito-simplex-flatpak.jpg new file mode 100644 index 0000000000..ed8fc7db1f Binary files /dev/null and b/docs/links/images/edivaldobrito-simplex-flatpak.jpg differ diff --git a/docs/links/images/ekoreanews-telegram-alternatives.jpg b/docs/links/images/ekoreanews-telegram-alternatives.jpg new file mode 100644 index 0000000000..fcc70bec8d Binary files /dev/null and b/docs/links/images/ekoreanews-telegram-alternatives.jpg differ diff --git a/docs/links/images/eksisozluk-simplex.jpg b/docs/links/images/eksisozluk-simplex.jpg new file mode 100644 index 0000000000..2410f84666 Binary files /dev/null and b/docs/links/images/eksisozluk-simplex.jpg differ diff --git a/docs/links/images/esgeeks-decentralized-messengers.jpg b/docs/links/images/esgeeks-decentralized-messengers.jpg new file mode 100644 index 0000000000..8893a74ab9 Binary files /dev/null and b/docs/links/images/esgeeks-decentralized-messengers.jpg differ diff --git a/docs/links/images/esgeeks-most-secure-app.jpg b/docs/links/images/esgeeks-most-secure-app.jpg new file mode 100644 index 0000000000..b3c0973549 Binary files /dev/null and b/docs/links/images/esgeeks-most-secure-app.jpg differ diff --git a/docs/links/images/expressvpn-most-secure-2026.jpg b/docs/links/images/expressvpn-most-secure-2026.jpg new file mode 100644 index 0000000000..b5f3499d74 Binary files /dev/null and b/docs/links/images/expressvpn-most-secure-2026.jpg differ diff --git a/docs/links/images/franciscobarral-simplex.jpg b/docs/links/images/franciscobarral-simplex.jpg new file mode 100644 index 0000000000..831cbbc3b3 Binary files /dev/null and b/docs/links/images/franciscobarral-simplex.jpg differ diff --git a/docs/links/images/free-com-tw-simplex.jpg b/docs/links/images/free-com-tw-simplex.jpg new file mode 100644 index 0000000000..9d497e34ea Binary files /dev/null and b/docs/links/images/free-com-tw-simplex.jpg differ diff --git a/docs/links/images/freedom-tech-simplex-review.jpg b/docs/links/images/freedom-tech-simplex-review.jpg new file mode 100644 index 0000000000..6753595147 Binary files /dev/null and b/docs/links/images/freedom-tech-simplex-review.jpg differ diff --git a/docs/links/images/freedomlab-simplex-smp.jpg b/docs/links/images/freedomlab-simplex-smp.jpg new file mode 100644 index 0000000000..2a30c89e52 Binary files /dev/null and b/docs/links/images/freedomlab-simplex-smp.jpg differ diff --git a/docs/links/images/freedomnode-session-simplex.jpg b/docs/links/images/freedomnode-session-simplex.jpg new file mode 100644 index 0000000000..2244d7b5c5 Binary files /dev/null and b/docs/links/images/freedomnode-session-simplex.jpg differ diff --git a/docs/links/images/freeonline-simplex-review.jpg b/docs/links/images/freeonline-simplex-review.jpg new file mode 100644 index 0000000000..dd9465f021 Binary files /dev/null and b/docs/links/images/freeonline-simplex-review.jpg differ diff --git a/docs/links/images/gatooscuro-interview-english.jpg b/docs/links/images/gatooscuro-interview-english.jpg new file mode 100644 index 0000000000..0d4dc2e319 Binary files /dev/null and b/docs/links/images/gatooscuro-interview-english.jpg differ diff --git a/docs/links/images/gatooscuro-simplex-interview.jpg b/docs/links/images/gatooscuro-simplex-interview.jpg new file mode 100644 index 0000000000..d71d4139ed Binary files /dev/null and b/docs/links/images/gatooscuro-simplex-interview.jpg differ diff --git a/docs/links/images/gatooscuro-simplex-review.jpg b/docs/links/images/gatooscuro-simplex-review.jpg new file mode 100644 index 0000000000..d71d4139ed Binary files /dev/null and b/docs/links/images/gatooscuro-simplex-review.jpg differ diff --git a/docs/links/images/gazeta-mig-simplex-telegram.jpg b/docs/links/images/gazeta-mig-simplex-telegram.jpg new file mode 100644 index 0000000000..6d92871f9b Binary files /dev/null and b/docs/links/images/gazeta-mig-simplex-telegram.jpg differ diff --git a/docs/links/images/gnulinux-ch-simplex-overview.jpg b/docs/links/images/gnulinux-ch-simplex-overview.jpg new file mode 100644 index 0000000000..463e657142 Binary files /dev/null and b/docs/links/images/gnulinux-ch-simplex-overview.jpg differ diff --git a/docs/links/images/gnulinux-ch-simplex-smartphones.jpg b/docs/links/images/gnulinux-ch-simplex-smartphones.jpg new file mode 100644 index 0000000000..2f66de7130 Binary files /dev/null and b/docs/links/images/gnulinux-ch-simplex-smartphones.jpg differ diff --git a/docs/links/images/golden-finance-web3-privacy.jpg b/docs/links/images/golden-finance-web3-privacy.jpg new file mode 100644 index 0000000000..8ca86dc822 Binary files /dev/null and b/docs/links/images/golden-finance-web3-privacy.jpg differ diff --git a/docs/links/images/habr-anonymous-messengers.jpg b/docs/links/images/habr-anonymous-messengers.jpg new file mode 100644 index 0000000000..f1d1f926d4 Binary files /dev/null and b/docs/links/images/habr-anonymous-messengers.jpg differ diff --git a/docs/links/images/habr-anonymous-standard.jpg b/docs/links/images/habr-anonymous-standard.jpg new file mode 100644 index 0000000000..d310443ec3 Binary files /dev/null and b/docs/links/images/habr-anonymous-standard.jpg differ diff --git a/docs/links/images/habr-globalsign-classification.jpg b/docs/links/images/habr-globalsign-classification.jpg new file mode 100644 index 0000000000..e8f33d3183 Binary files /dev/null and b/docs/links/images/habr-globalsign-classification.jpg differ diff --git a/docs/links/images/habr-globalsign-p2p-chats.jpg b/docs/links/images/habr-globalsign-p2p-chats.jpg new file mode 100644 index 0000000000..b21515dea0 Binary files /dev/null and b/docs/links/images/habr-globalsign-p2p-chats.jpg differ diff --git a/docs/links/images/habr-simplex-first-messenger.jpg b/docs/links/images/habr-simplex-first-messenger.jpg new file mode 100644 index 0000000000..c4fc5edef4 Binary files /dev/null and b/docs/links/images/habr-simplex-first-messenger.jpg differ diff --git a/docs/links/images/hacking-articles-privacy-messaging.jpg b/docs/links/images/hacking-articles-privacy-messaging.jpg new file mode 100644 index 0000000000..959728197b Binary files /dev/null and b/docs/links/images/hacking-articles-privacy-messaging.jpg differ diff --git a/docs/links/images/hackspoiler-simplex-star.jpg b/docs/links/images/hackspoiler-simplex-star.jpg new file mode 100644 index 0000000000..09b70de9d1 Binary files /dev/null and b/docs/links/images/hackspoiler-simplex-star.jpg differ diff --git a/docs/links/images/heise-german-language-simplex.jpg b/docs/links/images/heise-german-language-simplex.jpg new file mode 100644 index 0000000000..eedabbce76 Binary files /dev/null and b/docs/links/images/heise-german-language-simplex.jpg differ diff --git a/docs/links/images/heise-simplex-100-release.jpg b/docs/links/images/heise-simplex-100-release.jpg new file mode 100644 index 0000000000..66bfea70ea Binary files /dev/null and b/docs/links/images/heise-simplex-100-release.jpg differ diff --git a/docs/links/images/heise-simplex-smartphone.jpg b/docs/links/images/heise-simplex-smartphone.jpg new file mode 100644 index 0000000000..f21904463d Binary files /dev/null and b/docs/links/images/heise-simplex-smartphone.jpg differ diff --git a/docs/links/images/heise-simplex-v3-apple.jpg b/docs/links/images/heise-simplex-v3-apple.jpg new file mode 100644 index 0000000000..1b39a768c8 Binary files /dev/null and b/docs/links/images/heise-simplex-v3-apple.jpg differ diff --git a/docs/links/images/heise-simplex-v4-private.jpg b/docs/links/images/heise-simplex-v4-private.jpg new file mode 100644 index 0000000000..eedabbce76 Binary files /dev/null and b/docs/links/images/heise-simplex-v4-private.jpg differ diff --git a/docs/links/images/help-net-security-product-showcase.jpg b/docs/links/images/help-net-security-product-showcase.jpg new file mode 100644 index 0000000000..5eae8399f2 Binary files /dev/null and b/docs/links/images/help-net-security-product-showcase.jpg differ diff --git a/docs/links/images/hi-tech-mail-simplex.jpg b/docs/links/images/hi-tech-mail-simplex.jpg new file mode 100644 index 0000000000..89b3a2810c Binary files /dev/null and b/docs/links/images/hi-tech-mail-simplex.jpg differ diff --git a/docs/links/images/hospeda-simplex-chat.jpg b/docs/links/images/hospeda-simplex-chat.jpg new file mode 100644 index 0000000000..d534a25095 Binary files /dev/null and b/docs/links/images/hospeda-simplex-chat.jpg differ diff --git a/docs/links/images/htr-simplex-review.jpg b/docs/links/images/htr-simplex-review.jpg new file mode 100644 index 0000000000..162d9c375b Binary files /dev/null and b/docs/links/images/htr-simplex-review.jpg differ diff --git a/docs/links/images/io-tech-secure-messaging.jpg b/docs/links/images/io-tech-secure-messaging.jpg new file mode 100644 index 0000000000..272f691514 Binary files /dev/null and b/docs/links/images/io-tech-secure-messaging.jpg differ diff --git a/docs/links/images/io-tech-simplex-forum.jpg b/docs/links/images/io-tech-simplex-forum.jpg new file mode 100644 index 0000000000..a4b3ce6362 Binary files /dev/null and b/docs/links/images/io-tech-simplex-forum.jpg differ diff --git a/docs/links/images/iode-degoogle-messaging.jpg b/docs/links/images/iode-degoogle-messaging.jpg new file mode 100644 index 0000000000..aff57e2d0c Binary files /dev/null and b/docs/links/images/iode-degoogle-messaging.jpg differ diff --git a/docs/links/images/iphon-fr-most-incognito.jpg b/docs/links/images/iphon-fr-most-incognito.jpg new file mode 100644 index 0000000000..cbb270bd88 Binary files /dev/null and b/docs/links/images/iphon-fr-most-incognito.jpg differ diff --git a/docs/links/images/iphone-ticker-simplex-privacy.jpg b/docs/links/images/iphone-ticker-simplex-privacy.jpg new file mode 100644 index 0000000000..2d28fd4adf Binary files /dev/null and b/docs/links/images/iphone-ticker-simplex-privacy.jpg differ diff --git a/docs/links/images/iqwiki-simplex-entry.jpg b/docs/links/images/iqwiki-simplex-entry.jpg new file mode 100644 index 0000000000..dedcb07cd5 Binary files /dev/null and b/docs/links/images/iqwiki-simplex-entry.jpg differ diff --git a/docs/links/images/italian-youtube-anonymous-chat.jpg b/docs/links/images/italian-youtube-anonymous-chat.jpg new file mode 100644 index 0000000000..52cb930c29 Binary files /dev/null and b/docs/links/images/italian-youtube-anonymous-chat.jpg differ diff --git a/docs/links/images/itforprof-alternatives-2026.jpg b/docs/links/images/itforprof-alternatives-2026.jpg new file mode 100644 index 0000000000..60224bef5d Binary files /dev/null and b/docs/links/images/itforprof-alternatives-2026.jpg differ diff --git a/docs/links/images/itsfoss-simplex-review.jpg b/docs/links/images/itsfoss-simplex-review.jpg new file mode 100644 index 0000000000..8469de2397 Binary files /dev/null and b/docs/links/images/itsfoss-simplex-review.jpg differ diff --git a/docs/links/images/ixbt-messengers-2026.jpg b/docs/links/images/ixbt-messengers-2026.jpg new file mode 100644 index 0000000000..aeda101fb5 Binary files /dev/null and b/docs/links/images/ixbt-messengers-2026.jpg differ diff --git a/docs/links/images/jacopococcia-simplex-top10.jpg b/docs/links/images/jacopococcia-simplex-top10.jpg new file mode 100644 index 0000000000..49163a5f80 Binary files /dev/null and b/docs/links/images/jacopococcia-simplex-top10.jpg differ diff --git a/docs/links/images/jornaldebrasilia-simplex-telegram.jpg b/docs/links/images/jornaldebrasilia-simplex-telegram.jpg new file mode 100644 index 0000000000..e06a18233c Binary files /dev/null and b/docs/links/images/jornaldebrasilia-simplex-telegram.jpg differ diff --git a/docs/links/images/joselito-simplex-vs-xmpp.jpg b/docs/links/images/joselito-simplex-vs-xmpp.jpg new file mode 100644 index 0000000000..bd33bd19d4 Binary files /dev/null and b/docs/links/images/joselito-simplex-vs-xmpp.jpg differ diff --git a/docs/links/images/kaiyuanapp-simplex.jpg b/docs/links/images/kaiyuanapp-simplex.jpg new file mode 100644 index 0000000000..999c2e1d5f Binary files /dev/null and b/docs/links/images/kaiyuanapp-simplex.jpg differ diff --git a/docs/links/images/kdroidwin-simplex-hatena-2.jpg b/docs/links/images/kdroidwin-simplex-hatena-2.jpg new file mode 100644 index 0000000000..2e51cef786 Binary files /dev/null and b/docs/links/images/kdroidwin-simplex-hatena-2.jpg differ diff --git a/docs/links/images/kdroidwin-simplex-hatena.jpg b/docs/links/images/kdroidwin-simplex-hatena.jpg new file mode 100644 index 0000000000..0be91eff23 Binary files /dev/null and b/docs/links/images/kdroidwin-simplex-hatena.jpg differ diff --git a/docs/links/images/kodnar-session-simplex.jpg b/docs/links/images/kodnar-session-simplex.jpg new file mode 100644 index 0000000000..2244d7b5c5 Binary files /dev/null and b/docs/links/images/kodnar-session-simplex.jpg differ diff --git a/docs/links/images/korben-wiki-messagerie.jpg b/docs/links/images/korben-wiki-messagerie.jpg new file mode 100644 index 0000000000..992a1642f5 Binary files /dev/null and b/docs/links/images/korben-wiki-messagerie.jpg differ diff --git a/docs/links/images/kr-labs-secure-messenger.jpg b/docs/links/images/kr-labs-secure-messenger.jpg new file mode 100644 index 0000000000..77a4d8d5bd Binary files /dev/null and b/docs/links/images/kr-labs-secure-messenger.jpg differ diff --git a/docs/links/images/kryptoanarchista-english-review.jpg b/docs/links/images/kryptoanarchista-english-review.jpg new file mode 100644 index 0000000000..3dd00b90b9 Binary files /dev/null and b/docs/links/images/kryptoanarchista-english-review.jpg differ diff --git a/docs/links/images/kryptoanarchista-simplex-revolution.jpg b/docs/links/images/kryptoanarchista-simplex-revolution.jpg new file mode 100644 index 0000000000..3dd00b90b9 Binary files /dev/null and b/docs/links/images/kryptoanarchista-simplex-revolution.jpg differ diff --git a/docs/links/images/kuketz-forum-simplex-security.jpg b/docs/links/images/kuketz-forum-simplex-security.jpg new file mode 100644 index 0000000000..90e5949483 Binary files /dev/null and b/docs/links/images/kuketz-forum-simplex-security.jpg differ diff --git a/docs/links/images/kuketz-group-chat-test.jpg b/docs/links/images/kuketz-group-chat-test.jpg new file mode 100644 index 0000000000..3a761b1b3b Binary files /dev/null and b/docs/links/images/kuketz-group-chat-test.jpg differ diff --git a/docs/links/images/kuketz-messenger-matrix.jpg b/docs/links/images/kuketz-messenger-matrix.jpg new file mode 100644 index 0000000000..3b33fad709 --- /dev/null +++ b/docs/links/images/kuketz-messenger-matrix.jpg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/links/images/kuketz-simplex-review.jpg b/docs/links/images/kuketz-simplex-review.jpg new file mode 100644 index 0000000000..3a761b1b3b Binary files /dev/null and b/docs/links/images/kuketz-simplex-review.jpg differ diff --git a/docs/links/images/kusaimara-session-vs-simplex.jpg b/docs/links/images/kusaimara-session-vs-simplex.jpg new file mode 100644 index 0000000000..2faf2e7ed6 Binary files /dev/null and b/docs/links/images/kusaimara-session-vs-simplex.jpg differ diff --git a/docs/links/images/kusaimara-simplex-3.jpg b/docs/links/images/kusaimara-simplex-3.jpg new file mode 100644 index 0000000000..b61c917779 Binary files /dev/null and b/docs/links/images/kusaimara-simplex-3.jpg differ diff --git a/website/src/img/share_simplex.png b/docs/links/images/kusaimara-simplex-first-impressions.jpg similarity index 100% rename from website/src/img/share_simplex.png rename to docs/links/images/kusaimara-simplex-first-impressions.jpg diff --git a/docs/links/images/lealternative-anonymous-apps.jpg b/docs/links/images/lealternative-anonymous-apps.jpg new file mode 100644 index 0000000000..52ab3d75e5 Binary files /dev/null and b/docs/links/images/lealternative-anonymous-apps.jpg differ diff --git a/docs/links/images/lealternative-simplex-review.jpg b/docs/links/images/lealternative-simplex-review.jpg new file mode 100644 index 0000000000..dc49ce2467 Binary files /dev/null and b/docs/links/images/lealternative-simplex-review.jpg differ diff --git a/docs/links/images/lemedia05-simplex-100.jpg b/docs/links/images/lemedia05-simplex-100.jpg new file mode 100644 index 0000000000..285311a0b5 Binary files /dev/null and b/docs/links/images/lemedia05-simplex-100.jpg differ diff --git a/docs/links/images/lemedia05-smartphone.jpg b/docs/links/images/lemedia05-smartphone.jpg new file mode 100644 index 0000000000..285311a0b5 Binary files /dev/null and b/docs/links/images/lemedia05-smartphone.jpg differ diff --git a/docs/links/images/libreselfhosted-simplex-overview.jpg b/docs/links/images/libreselfhosted-simplex-overview.jpg new file mode 100644 index 0000000000..dad31c112e --- /dev/null +++ b/docs/links/images/libreselfhosted-simplex-overview.jpg @@ -0,0 +1 @@ +downloads: 1Mdownloads1M \ No newline at end of file diff --git a/docs/links/images/linkedin-pt-simplex.jpg b/docs/links/images/linkedin-pt-simplex.jpg new file mode 100644 index 0000000000..bfa148b0c9 --- /dev/null +++ b/docs/links/images/linkedin-pt-simplex.jpg @@ -0,0 +1,8 @@ + diff --git a/docs/links/images/linux-magazin-simplex-privacy.jpg b/docs/links/images/linux-magazin-simplex-privacy.jpg new file mode 100644 index 0000000000..8d0e5b67eb Binary files /dev/null and b/docs/links/images/linux-magazin-simplex-privacy.jpg differ diff --git a/docs/links/images/livecoins-vitalik-simplex.jpg b/docs/links/images/livecoins-vitalik-simplex.jpg new file mode 100644 index 0000000000..1be58f86dc Binary files /dev/null and b/docs/links/images/livecoins-vitalik-simplex.jpg differ diff --git a/docs/links/images/marius-privacy-messengers-overview.jpg b/docs/links/images/marius-privacy-messengers-overview.jpg new file mode 100644 index 0000000000..2b58527f08 Binary files /dev/null and b/docs/links/images/marius-privacy-messengers-overview.jpg differ diff --git a/docs/links/images/matters-simplex-telegram-comparison.jpg b/docs/links/images/matters-simplex-telegram-comparison.jpg new file mode 100644 index 0000000000..0b73e2b773 Binary files /dev/null and b/docs/links/images/matters-simplex-telegram-comparison.jpg differ diff --git a/docs/links/images/midia-segura-simplex-review.jpg b/docs/links/images/midia-segura-simplex-review.jpg new file mode 100644 index 0000000000..604ef75151 Binary files /dev/null and b/docs/links/images/midia-segura-simplex-review.jpg differ diff --git a/docs/links/images/monerotopia-2026-simplex.jpg b/docs/links/images/monerotopia-2026-simplex.jpg new file mode 100644 index 0000000000..d30d1cd357 Binary files /dev/null and b/docs/links/images/monerotopia-2026-simplex.jpg differ diff --git a/docs/links/images/nbtv-simplex-review.jpg b/docs/links/images/nbtv-simplex-review.jpg new file mode 100644 index 0000000000..92a58f7f53 Binary files /dev/null and b/docs/links/images/nbtv-simplex-review.jpg differ diff --git a/docs/links/images/nemental-simplex-tor-guide.jpg b/docs/links/images/nemental-simplex-tor-guide.jpg new file mode 100644 index 0000000000..a616e7367b Binary files /dev/null and b/docs/links/images/nemental-simplex-tor-guide.jpg differ diff --git a/docs/links/images/netxhack-telegram-alternatives.jpg b/docs/links/images/netxhack-telegram-alternatives.jpg new file mode 100644 index 0000000000..f97aeedc1e Binary files /dev/null and b/docs/links/images/netxhack-telegram-alternatives.jpg differ diff --git a/docs/links/images/neweconomy-buterin-simplex.jpg b/docs/links/images/neweconomy-buterin-simplex.jpg new file mode 100644 index 0000000000..ac252bd168 Binary files /dev/null and b/docs/links/images/neweconomy-buterin-simplex.jpg differ diff --git a/docs/links/images/nicolas-forcet-comparison.jpg b/docs/links/images/nicolas-forcet-comparison.jpg new file mode 100644 index 0000000000..08b1f9223f Binary files /dev/null and b/docs/links/images/nicolas-forcet-comparison.jpg differ diff --git a/docs/links/images/niebezpiecznik-simplex-mention.jpg b/docs/links/images/niebezpiecznik-simplex-mention.jpg new file mode 100644 index 0000000000..e7ef78ee71 Binary files /dev/null and b/docs/links/images/niebezpiecznik-simplex-mention.jpg differ diff --git a/docs/links/images/nobsbitcoin-funding-v60.jpg b/docs/links/images/nobsbitcoin-funding-v60.jpg new file mode 100644 index 0000000000..9edfdabf14 Binary files /dev/null and b/docs/links/images/nobsbitcoin-funding-v60.jpg differ diff --git a/docs/links/images/nobsbitcoin-startos.jpg b/docs/links/images/nobsbitcoin-startos.jpg new file mode 100644 index 0000000000..d3d5b6501e Binary files /dev/null and b/docs/links/images/nobsbitcoin-startos.jpg differ diff --git a/docs/links/images/nobsbitcoin-v52-receipts.jpg b/docs/links/images/nobsbitcoin-v52-receipts.jpg new file mode 100644 index 0000000000..0570d3d227 Binary files /dev/null and b/docs/links/images/nobsbitcoin-v52-receipts.jpg differ diff --git a/docs/links/images/nobsbitcoin-v53-desktop.jpg b/docs/links/images/nobsbitcoin-v53-desktop.jpg new file mode 100644 index 0000000000..ff5c8d1413 Binary files /dev/null and b/docs/links/images/nobsbitcoin-v53-desktop.jpg differ diff --git a/docs/links/images/nobsbitcoin-v54-desktop.jpg b/docs/links/images/nobsbitcoin-v54-desktop.jpg new file mode 100644 index 0000000000..88733181aa Binary files /dev/null and b/docs/links/images/nobsbitcoin-v54-desktop.jpg differ diff --git a/docs/links/images/nobsbitcoin-v57-quantum.jpg b/docs/links/images/nobsbitcoin-v57-quantum.jpg new file mode 100644 index 0000000000..fafa208ea5 Binary files /dev/null and b/docs/links/images/nobsbitcoin-v57-quantum.jpg differ diff --git a/docs/links/images/nobsbitcoin-v58-routing.jpg b/docs/links/images/nobsbitcoin-v58-routing.jpg new file mode 100644 index 0000000000..69a9bed4ca Binary files /dev/null and b/docs/links/images/nobsbitcoin-v58-routing.jpg differ diff --git a/docs/links/images/nobsbitcoin-v61.jpg b/docs/links/images/nobsbitcoin-v61.jpg new file mode 100644 index 0000000000..166fa11537 Binary files /dev/null and b/docs/links/images/nobsbitcoin-v61.jpg differ diff --git a/docs/links/images/notebookcheck-cn-simplex.jpg b/docs/links/images/notebookcheck-cn-simplex.jpg new file mode 100644 index 0000000000..bce52f2e94 Binary files /dev/null and b/docs/links/images/notebookcheck-cn-simplex.jpg differ diff --git a/docs/links/images/notebookcheck-pt-simplex.jpg b/docs/links/images/notebookcheck-pt-simplex.jpg new file mode 100644 index 0000000000..bce52f2e94 Binary files /dev/null and b/docs/links/images/notebookcheck-pt-simplex.jpg differ diff --git a/docs/links/images/notebookcheck-ru-simplex.jpg b/docs/links/images/notebookcheck-ru-simplex.jpg new file mode 100644 index 0000000000..edfdd31a33 Binary files /dev/null and b/docs/links/images/notebookcheck-ru-simplex.jpg differ diff --git a/docs/links/images/notebookcheck-simplex-succeeds.jpg b/docs/links/images/notebookcheck-simplex-succeeds.jpg new file mode 100644 index 0000000000..bce52f2e94 Binary files /dev/null and b/docs/links/images/notebookcheck-simplex-succeeds.jpg differ diff --git a/docs/links/images/notecom-deeplife-simplex.jpg b/docs/links/images/notecom-deeplife-simplex.jpg new file mode 100644 index 0000000000..9f3915bc7b Binary files /dev/null and b/docs/links/images/notecom-deeplife-simplex.jpg differ diff --git a/docs/links/images/noticiasnavarra-simplex-criminologist.jpg b/docs/links/images/noticiasnavarra-simplex-criminologist.jpg new file mode 100644 index 0000000000..9dae7ea2e7 Binary files /dev/null and b/docs/links/images/noticiasnavarra-simplex-criminologist.jpg differ diff --git a/docs/links/images/nowhere-moe-simplex-servers.jpg b/docs/links/images/nowhere-moe-simplex-servers.jpg new file mode 100644 index 0000000000..8db82ac277 Binary files /dev/null and b/docs/links/images/nowhere-moe-simplex-servers.jpg differ diff --git a/docs/links/images/opennet-simplex-65.jpg b/docs/links/images/opennet-simplex-65.jpg new file mode 100644 index 0000000000..3e6e6a0ded Binary files /dev/null and b/docs/links/images/opennet-simplex-65.jpg differ diff --git a/docs/links/images/opentech-guru-simplex.jpg b/docs/links/images/opentech-guru-simplex.jpg new file mode 100644 index 0000000000..756b411086 Binary files /dev/null and b/docs/links/images/opentech-guru-simplex.jpg differ diff --git a/docs/links/images/oppet-moln-simplex.jpg b/docs/links/images/oppet-moln-simplex.jpg new file mode 100644 index 0000000000..76ce1ab4f6 Binary files /dev/null and b/docs/links/images/oppet-moln-simplex.jpg differ diff --git a/docs/links/images/optout-improving-simplex.jpg b/docs/links/images/optout-improving-simplex.jpg new file mode 100644 index 0000000000..1ebff7959d Binary files /dev/null and b/docs/links/images/optout-improving-simplex.jpg differ diff --git a/docs/links/images/optout-simplex-s3e02.jpg b/docs/links/images/optout-simplex-s3e02.jpg new file mode 100644 index 0000000000..1ebff7959d Binary files /dev/null and b/docs/links/images/optout-simplex-s3e02.jpg differ diff --git a/docs/links/images/paflegeek-simplex-selfhost.jpg b/docs/links/images/paflegeek-simplex-selfhost.jpg new file mode 100644 index 0000000000..4d757dcb19 Binary files /dev/null and b/docs/links/images/paflegeek-simplex-selfhost.jpg differ diff --git a/docs/links/images/panorama-simplex-ultra-secret.jpg b/docs/links/images/panorama-simplex-ultra-secret.jpg new file mode 100644 index 0000000000..08a70ae03b Binary files /dev/null and b/docs/links/images/panorama-simplex-ultra-secret.jpg differ diff --git a/docs/links/images/paskoocheh-simplex-iran.jpg b/docs/links/images/paskoocheh-simplex-iran.jpg new file mode 100644 index 0000000000..df34741395 Binary files /dev/null and b/docs/links/images/paskoocheh-simplex-iran.jpg differ diff --git a/docs/links/images/peertube-uno-simplex.jpg b/docs/links/images/peertube-uno-simplex.jpg new file mode 100644 index 0000000000..afe8400f01 Binary files /dev/null and b/docs/links/images/peertube-uno-simplex.jpg differ diff --git a/docs/links/images/portuguese-simplex-hidden-portal.jpg b/docs/links/images/portuguese-simplex-hidden-portal.jpg new file mode 100644 index 0000000000..68315d9221 Binary files /dev/null and b/docs/links/images/portuguese-simplex-hidden-portal.jpg differ diff --git a/docs/links/images/portuguese-simplex-revolutionary.jpg b/docs/links/images/portuguese-simplex-revolutionary.jpg new file mode 100644 index 0000000000..c52c33bdd6 Binary files /dev/null and b/docs/links/images/portuguese-simplex-revolutionary.jpg differ diff --git a/docs/links/images/portuguese-simplex-tutorial.jpg b/docs/links/images/portuguese-simplex-tutorial.jpg new file mode 100644 index 0000000000..4c1462ec4f Binary files /dev/null and b/docs/links/images/portuguese-simplex-tutorial.jpg differ diff --git a/docs/links/images/portuguese-simplex-ultra-annihilation.jpg b/docs/links/images/portuguese-simplex-ultra-annihilation.jpg new file mode 100644 index 0000000000..778f78ddd7 Binary files /dev/null and b/docs/links/images/portuguese-simplex-ultra-annihilation.jpg differ diff --git a/docs/links/images/pplware-simplex-telegram.jpg b/docs/links/images/pplware-simplex-telegram.jpg new file mode 100644 index 0000000000..255dd9c7d5 Binary files /dev/null and b/docs/links/images/pplware-simplex-telegram.jpg differ diff --git a/docs/links/images/prihor-simplex-guide.jpg b/docs/links/images/prihor-simplex-guide.jpg new file mode 100644 index 0000000000..fef6aa033d Binary files /dev/null and b/docs/links/images/prihor-simplex-guide.jpg differ diff --git a/docs/links/images/privacy-guides-recommendation.jpg b/docs/links/images/privacy-guides-recommendation.jpg new file mode 100644 index 0000000000..df2c4d9785 Binary files /dev/null and b/docs/links/images/privacy-guides-recommendation.jpg differ diff --git a/docs/links/images/pro32-best-messengers-2026.jpg b/docs/links/images/pro32-best-messengers-2026.jpg new file mode 100644 index 0000000000..9c9b67688c Binary files /dev/null and b/docs/links/images/pro32-best-messengers-2026.jpg differ diff --git a/docs/links/images/programista-pasji-simplex.jpg b/docs/links/images/programista-pasji-simplex.jpg new file mode 100644 index 0000000000..82f2bf75fe Binary files /dev/null and b/docs/links/images/programista-pasji-simplex.jpg differ diff --git a/docs/links/images/questona-encrypted-chat.jpg b/docs/links/images/questona-encrypted-chat.jpg new file mode 100644 index 0000000000..0ffe1e69e7 Binary files /dev/null and b/docs/links/images/questona-encrypted-chat.jpg differ diff --git a/docs/links/images/reclaimthenet-ip-privacy.jpg b/docs/links/images/reclaimthenet-ip-privacy.jpg new file mode 100644 index 0000000000..ee5e83fbf2 Binary files /dev/null and b/docs/links/images/reclaimthenet-ip-privacy.jpg differ diff --git a/docs/links/images/reclaimthenet-quantum-beta.jpg b/docs/links/images/reclaimthenet-quantum-beta.jpg new file mode 100644 index 0000000000..6135f8209d Binary files /dev/null and b/docs/links/images/reclaimthenet-quantum-beta.jpg differ diff --git a/docs/links/images/renaro-signal-session-simplex.jpg b/docs/links/images/renaro-signal-session-simplex.jpg new file mode 100644 index 0000000000..9c04021c7d Binary files /dev/null and b/docs/links/images/renaro-signal-session-simplex.jpg differ diff --git a/docs/links/images/robosats-simplex-bot.jpg b/docs/links/images/robosats-simplex-bot.jpg new file mode 100644 index 0000000000..5d6720bd18 Binary files /dev/null and b/docs/links/images/robosats-simplex-bot.jpg differ diff --git a/docs/links/images/russian-paranoid-messenger-tutorial.jpg b/docs/links/images/russian-paranoid-messenger-tutorial.jpg new file mode 100644 index 0000000000..da219f96e3 Binary files /dev/null and b/docs/links/images/russian-paranoid-messenger-tutorial.jpg differ diff --git a/docs/links/images/russian-simplex-anonymous-no-id.jpg b/docs/links/images/russian-simplex-anonymous-no-id.jpg new file mode 100644 index 0000000000..f388896955 Binary files /dev/null and b/docs/links/images/russian-simplex-anonymous-no-id.jpg differ diff --git a/docs/links/images/russian-simplex-max-protection.jpg b/docs/links/images/russian-simplex-max-protection.jpg new file mode 100644 index 0000000000..ff9ecfe6ba Binary files /dev/null and b/docs/links/images/russian-simplex-max-protection.jpg differ diff --git a/docs/links/images/russian-simplex-overview-functions.jpg b/docs/links/images/russian-simplex-overview-functions.jpg new file mode 100644 index 0000000000..6f8f628133 Binary files /dev/null and b/docs/links/images/russian-simplex-overview-functions.jpg differ diff --git a/docs/links/images/rutube-simplex-video.jpg b/docs/links/images/rutube-simplex-video.jpg new file mode 100644 index 0000000000..aa7d416855 Binary files /dev/null and b/docs/links/images/rutube-simplex-video.jpg differ diff --git a/docs/links/images/security-nl-simplex-users.jpg b/docs/links/images/security-nl-simplex-users.jpg new file mode 100644 index 0000000000..7de10eff2d Binary files /dev/null and b/docs/links/images/security-nl-simplex-users.jpg differ diff --git a/docs/links/images/securityinabox-simplex-turkish.jpg b/docs/links/images/securityinabox-simplex-turkish.jpg new file mode 100644 index 0000000000..230b3e1463 Binary files /dev/null and b/docs/links/images/securityinabox-simplex-turkish.jpg differ diff --git a/docs/links/images/selfhosted-simplex-tutorial.jpg b/docs/links/images/selfhosted-simplex-tutorial.jpg new file mode 100644 index 0000000000..d534a25095 Binary files /dev/null and b/docs/links/images/selfhosted-simplex-tutorial.jpg differ diff --git a/docs/links/images/selfhosty-simplex-signal.jpg b/docs/links/images/selfhosty-simplex-signal.jpg new file mode 100644 index 0000000000..85d9f24b4c Binary files /dev/null and b/docs/links/images/selfhosty-simplex-signal.jpg differ diff --git a/docs/links/images/serokell-haskell-simplex.jpg b/docs/links/images/serokell-haskell-simplex.jpg new file mode 100644 index 0000000000..7ea448125c Binary files /dev/null and b/docs/links/images/serokell-haskell-simplex.jpg differ diff --git a/docs/links/images/sethforprivacy-privacy-steps.jpg b/docs/links/images/sethforprivacy-privacy-steps.jpg new file mode 100644 index 0000000000..c2210ca2fa Binary files /dev/null and b/docs/links/images/sethforprivacy-privacy-steps.jpg differ diff --git a/docs/links/images/simplex-messaging-perfect-privacy.jpg b/docs/links/images/simplex-messaging-perfect-privacy.jpg new file mode 100644 index 0000000000..d71d4139ed Binary files /dev/null and b/docs/links/images/simplex-messaging-perfect-privacy.jpg differ diff --git a/docs/links/images/simplex-power-to-people-livestream.jpg b/docs/links/images/simplex-power-to-people-livestream.jpg new file mode 100644 index 0000000000..b4a3d03db7 Binary files /dev/null and b/docs/links/images/simplex-power-to-people-livestream.jpg differ diff --git a/docs/links/images/simplex-status-bot.jpg b/docs/links/images/simplex-status-bot.jpg new file mode 100644 index 0000000000..033a8cfcc4 Binary files /dev/null and b/docs/links/images/simplex-status-bot.jpg differ diff --git a/docs/links/images/simplex-themes-archive.jpg b/docs/links/images/simplex-themes-archive.jpg new file mode 100644 index 0000000000..c6e8afda19 Binary files /dev/null and b/docs/links/images/simplex-themes-archive.jpg differ diff --git a/docs/links/images/simplex-unusually-good-privacy.jpg b/docs/links/images/simplex-unusually-good-privacy.jpg new file mode 100644 index 0000000000..9059fc7a28 Binary files /dev/null and b/docs/links/images/simplex-unusually-good-privacy.jpg differ diff --git a/docs/links/images/simplex-whatsapp-libertario.jpg b/docs/links/images/simplex-whatsapp-libertario.jpg new file mode 100644 index 0000000000..8048e590ec Binary files /dev/null and b/docs/links/images/simplex-whatsapp-libertario.jpg differ diff --git a/docs/links/images/soberano-simplex-english.jpg b/docs/links/images/soberano-simplex-english.jpg new file mode 100644 index 0000000000..08c28cd2cc Binary files /dev/null and b/docs/links/images/soberano-simplex-english.jpg differ diff --git a/docs/links/images/soberano-simplex-guide.jpg b/docs/links/images/soberano-simplex-guide.jpg new file mode 100644 index 0000000000..08c28cd2cc Binary files /dev/null and b/docs/links/images/soberano-simplex-guide.jpg differ diff --git a/docs/links/images/sofwul-simplex-contact.jpg b/docs/links/images/sofwul-simplex-contact.jpg new file mode 100644 index 0000000000..b508ba4696 Binary files /dev/null and b/docs/links/images/sofwul-simplex-contact.jpg differ diff --git a/docs/links/images/spanish-simplex-sin-identificadores.jpg b/docs/links/images/spanish-simplex-sin-identificadores.jpg new file mode 100644 index 0000000000..6294b77977 Binary files /dev/null and b/docs/links/images/spanish-simplex-sin-identificadores.jpg differ diff --git a/docs/links/images/spanish-simplex-ultra-private.jpg b/docs/links/images/spanish-simplex-ultra-private.jpg new file mode 100644 index 0000000000..8b43c390cc Binary files /dev/null and b/docs/links/images/spanish-simplex-ultra-private.jpg differ diff --git a/docs/links/images/splintercon-simplex-listing.jpg b/docs/links/images/splintercon-simplex-listing.jpg new file mode 100644 index 0000000000..2d6c790fdc Binary files /dev/null and b/docs/links/images/splintercon-simplex-listing.jpg differ diff --git a/docs/links/images/stackuj-luptak-podcast.jpg b/docs/links/images/stackuj-luptak-podcast.jpg new file mode 100644 index 0000000000..d0c8a6c5ff Binary files /dev/null and b/docs/links/images/stackuj-luptak-podcast.jpg differ diff --git a/docs/links/images/start9-simplex-startos.jpg b/docs/links/images/start9-simplex-startos.jpg new file mode 100644 index 0000000000..06933341aa Binary files /dev/null and b/docs/links/images/start9-simplex-startos.jpg differ diff --git a/docs/links/images/syskb-simplex-643.jpg b/docs/links/images/syskb-simplex-643.jpg new file mode 100644 index 0000000000..f58e1924c6 Binary files /dev/null and b/docs/links/images/syskb-simplex-643.jpg differ diff --git a/docs/links/images/tabnews-simplex-first-messenger.jpg b/docs/links/images/tabnews-simplex-first-messenger.jpg new file mode 100644 index 0000000000..430be61d03 Binary files /dev/null and b/docs/links/images/tabnews-simplex-first-messenger.jpg differ diff --git a/docs/links/images/tarnkappe-simplex-1-0.jpg b/docs/links/images/tarnkappe-simplex-1-0.jpg new file mode 100644 index 0000000000..e301c52546 Binary files /dev/null and b/docs/links/images/tarnkappe-simplex-1-0.jpg differ diff --git a/docs/links/images/taurix-simplex-hosting.jpg b/docs/links/images/taurix-simplex-hosting.jpg new file mode 100644 index 0000000000..d92596e37f Binary files /dev/null and b/docs/links/images/taurix-simplex-hosting.jpg differ diff --git a/docs/links/images/te-st-simplex-review.jpg b/docs/links/images/te-st-simplex-review.jpg new file mode 100644 index 0000000000..ed8ed65657 Binary files /dev/null and b/docs/links/images/te-st-simplex-review.jpg differ diff --git a/docs/links/images/techflow-vitalik-simplex.jpg b/docs/links/images/techflow-vitalik-simplex.jpg new file mode 100644 index 0000000000..221bcd4e77 Binary files /dev/null and b/docs/links/images/techflow-vitalik-simplex.jpg differ diff --git a/docs/links/images/techlore-recommend-simplex.jpg b/docs/links/images/techlore-recommend-simplex.jpg new file mode 100644 index 0000000000..7d7f4c4df4 Binary files /dev/null and b/docs/links/images/techlore-recommend-simplex.jpg differ diff --git a/docs/links/images/techlore-talks-simplex-interview.jpg b/docs/links/images/techlore-talks-simplex-interview.jpg new file mode 100644 index 0000000000..e8f8f2fd48 Binary files /dev/null and b/docs/links/images/techlore-talks-simplex-interview.jpg differ diff --git a/docs/links/images/tecmundo-simplex-telegram.jpg b/docs/links/images/tecmundo-simplex-telegram.jpg new file mode 100644 index 0000000000..02d83f888a Binary files /dev/null and b/docs/links/images/tecmundo-simplex-telegram.jpg differ diff --git a/docs/links/images/tencent-news-crypto-2025.jpg b/docs/links/images/tencent-news-crypto-2025.jpg new file mode 100644 index 0000000000..82176f1228 Binary files /dev/null and b/docs/links/images/tencent-news-crypto-2025.jpg differ diff --git a/docs/links/images/tudongchat-simplex-vietnam.jpg b/docs/links/images/tudongchat-simplex-vietnam.jpg new file mode 100644 index 0000000000..f905f91614 Binary files /dev/null and b/docs/links/images/tudongchat-simplex-vietnam.jpg differ diff --git a/docs/links/images/tugatech-simplex-telegram.jpg b/docs/links/images/tugatech-simplex-telegram.jpg new file mode 100644 index 0000000000..e0e938313c Binary files /dev/null and b/docs/links/images/tugatech-simplex-telegram.jpg differ diff --git a/docs/links/images/tuta-whatsapp-alternatives.jpg b/docs/links/images/tuta-whatsapp-alternatives.jpg new file mode 100644 index 0000000000..42a82d1205 Binary files /dev/null and b/docs/links/images/tuta-whatsapp-alternatives.jpg differ diff --git a/docs/links/images/v2ex-im-china.jpg b/docs/links/images/v2ex-im-china.jpg new file mode 100644 index 0000000000..eebefd1455 Binary files /dev/null and b/docs/links/images/v2ex-im-china.jpg differ diff --git a/docs/links/images/v2ex-simplex-telegram-100x.jpg b/docs/links/images/v2ex-simplex-telegram-100x.jpg new file mode 100644 index 0000000000..a5541af8d3 Binary files /dev/null and b/docs/links/images/v2ex-simplex-telegram-100x.jpg differ diff --git a/docs/links/images/vc-ru-simplex.jpg b/docs/links/images/vc-ru-simplex.jpg new file mode 100644 index 0000000000..2df513e9e3 Binary files /dev/null and b/docs/links/images/vc-ru-simplex.jpg differ diff --git a/docs/links/images/verasoul-simplex-review.jpg b/docs/links/images/verasoul-simplex-review.jpg new file mode 100644 index 0000000000..e4c609da1b Binary files /dev/null and b/docs/links/images/verasoul-simplex-review.jpg differ diff --git a/docs/links/images/vpn-taizen-simplex-guide.jpg b/docs/links/images/vpn-taizen-simplex-guide.jpg new file mode 100644 index 0000000000..6eafe62eb3 Binary files /dev/null and b/docs/links/images/vpn-taizen-simplex-guide.jpg differ diff --git a/docs/links/images/webappsmagazine-simplex-anonymity.jpg b/docs/links/images/webappsmagazine-simplex-anonymity.jpg new file mode 100644 index 0000000000..2e532baa13 Binary files /dev/null and b/docs/links/images/webappsmagazine-simplex-anonymity.jpg differ diff --git a/docs/links/images/whonix-simplex-recommendation.jpg b/docs/links/images/whonix-simplex-recommendation.jpg new file mode 100644 index 0000000000..dadc207bd9 Binary files /dev/null and b/docs/links/images/whonix-simplex-recommendation.jpg differ diff --git a/docs/links/images/wwwhatsnew-simplex.jpg b/docs/links/images/wwwhatsnew-simplex.jpg new file mode 100644 index 0000000000..5a1e7b6eb4 Binary files /dev/null and b/docs/links/images/wwwhatsnew-simplex.jpg differ diff --git a/docs/links/images/wykop-simplex-dsa.jpg b/docs/links/images/wykop-simplex-dsa.jpg new file mode 100644 index 0000000000..7884b362e7 Binary files /dev/null and b/docs/links/images/wykop-simplex-dsa.jpg differ diff --git a/docs/links/images/xakep-simplex-signal-brothers.jpg b/docs/links/images/xakep-simplex-signal-brothers.jpg new file mode 100644 index 0000000000..9ccb6b1e2e Binary files /dev/null and b/docs/links/images/xakep-simplex-signal-brothers.jpg differ diff --git a/docs/links/images/yahoo-finance-buterin.jpg b/docs/links/images/yahoo-finance-buterin.jpg new file mode 100644 index 0000000000..6c8d0bfbad Binary files /dev/null and b/docs/links/images/yahoo-finance-buterin.jpg differ diff --git a/docs/links/images/youtube-best-private-messenger.jpg b/docs/links/images/youtube-best-private-messenger.jpg new file mode 100644 index 0000000000..ff2c2e134c Binary files /dev/null and b/docs/links/images/youtube-best-private-messenger.jpg differ diff --git a/docs/links/images/youtube-best-secure-2025.jpg b/docs/links/images/youtube-best-secure-2025.jpg new file mode 100644 index 0000000000..78d379f145 Binary files /dev/null and b/docs/links/images/youtube-best-secure-2025.jpg differ diff --git a/docs/links/images/youtube-simplex-review-2024.jpg b/docs/links/images/youtube-simplex-review-2024.jpg new file mode 100644 index 0000000000..08a57214ef Binary files /dev/null and b/docs/links/images/youtube-simplex-review-2024.jpg differ diff --git a/docs/links/images/youtube-simplex-tutorial.jpg b/docs/links/images/youtube-simplex-tutorial.jpg new file mode 100644 index 0000000000..0e94c8fc58 Binary files /dev/null and b/docs/links/images/youtube-simplex-tutorial.jpg differ diff --git a/docs/links/images/zhousa-simplex-review.jpg b/docs/links/images/zhousa-simplex-review.jpg new file mode 100644 index 0000000000..6712c3bf7e Binary files /dev/null and b/docs/links/images/zhousa-simplex-review.jpg differ diff --git a/docs/protocol/channels-overview.md b/docs/protocol/channels-overview.md new file mode 100644 index 0000000000..d4cd2d2965 --- /dev/null +++ b/docs/protocol/channels-overview.md @@ -0,0 +1,341 @@ +Revision 1, 2026-04-28 + +# SimpleX Channels: stateful information delivery and management + +## Table of contents + +- [Introduction](#introduction) + - [What are SimpleX Channels](#what-are-simplex-channels) + - [Channels as transport layer](#channels-as-transport-layer) + - [Content visibility and participant privacy](#content-visibility-and-participant-privacy) + - [In comparison](#in-comparison) + - [Non-goals](#non-goals) +- [Architecture](#architecture) + - [State and distribution](#state-and-distribution) + - [Identity and ownership](#identity-and-ownership) + - [Governance](#governance) + - [Roles](#roles) +- [Cryptographic primitives](#cryptographic-primitives) +- [Security](#security) + - [Design objectives](#design-objectives) + - [Signing scope: roster only, content optional](#signing-scope-roster-only-content-optional) + - [Threat model](#threat-model) + - [Current gaps](#current-gaps) +- [Future work](#future-work) + - [Stateful access and history navigation](#stateful-access-and-history-navigation) + - [Transcript integrity](#transcript-integrity) + - [End-to-end encrypted side conversations](#end-to-end-encrypted-side-conversations) + - [Relay addition and removal](#relay-addition-and-removal) + - [Governance evolution](#governance-evolution) + - [Pre-moderation](#pre-moderation) + - [Scheduled delivery](#scheduled-delivery) + - [Link preview proxying](#link-preview-proxying) +- [Conclusion](#conclusion) + + +## Introduction + +The SimpleX network provides private point-to-point communication without user or endpoint identifiers, but most speech that matters is public. Every existing platform that distributes content at scale identifies both publishers and their audiences to the operator - none protect participation privacy. SimpleX Chat supported peer-to-peer groups, but they cannot scale to large audiences. SimpleX Channels close this gap. + +### What are SimpleX Channels + +SimpleX Channels are a stateful information delivery and management layer built on the [SimpleX network](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md). SMP queues provide stateless, unidirectional packet delivery between two endpoints. Channels add persistence, state, and scalable distribution - enabling one-to-many publishing with cryptographic identity independent of infrastructure operators. + +[SimpleX Chat](https://simplex.chat) is the first application, presenting channels as a broadcast publication model where owners publish and subscribers read, react, and comment. But channels are not limited to this use case - they are a general-purpose layer for distributing and managing stateful information (feeds, telemetry, automated pipelines, coordination services, social media). This document describes channels as a transport mechanism - the same mechanism will also be used for large groups, communities, wikis, forums, and other social media primitives. + +The critical difference from conventional publish-subscribe systems is that channel identity and governance are controlled cryptographically by the channel owners, not by the infrastructure operators. Relays - SimpleX network clients that forward and optionally cache channel content - can be added, removed, and replaced without changing the channel's identity, address, content, or cryptographic trust chain. A channel's relationship with its relays is transient; its identity is permanent. The authoritative record of content is hosted on channel owners' devices; relays perform transmission and caching similar to CDN infrastructure. + +Channel owners hold full control of the channel - its identity, content, governance rules, and membership - through self-custody of cryptographic keys. No infrastructure operator, relay provider, or third party can control or alter a channel without the owner's keys. Blockchain systems achieve a related property for financial assets - no third party can control holdings - through network-wide consensus. Channels achieve it through local authority and cryptographic signatures, without global consensus or a public ledger. Unlike blockchain state, channel state is mutable by the owner and not publicly verifiable by third parties. + +### Channels as transport layer + +The SimpleX network has three transport layers, each built on the one below: + +1. **SMP** ([SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md)) - stateless, unidirectional packet delivery between two endpoints through SMP routers. Provides fixed-size blocks, 2-node onion routing, and transport metadata protection. + +2. **SimpleX agents** ([agent protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/agent-protocol.md)) - bidirectional, redundant connections between endpoints, with end-to-end post-quantum double ratchet encryption. The [SimpleX Chat Protocol](./simplex-chat.md) runs on top of this layer, providing direct messaging, group communication, and metadata delivery for file transfers via [XFTP protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/xftp.md). + +3. **Channels** - stateful, one-to-many information delivery and management with cryptographic ownership and programmable governance. This layer runs on top of chat and agent layer 2, and it is described in this document. + +No network-wide user profile identifiers exist at any of these layers. Just as SMP enables private messaging by providing transport without user identifiers, channels enable public communication while preserving participation privacy at the distribution layer. + +Channel relays are themselves SimpleX clients in the SMP network, connecting to SMP routers using the same protocol, the same 2-node onion routing, and the same fixed-size transport blocks as any other endpoint. Even though the SMP network can distinguish a relay from a person's phone by its transport patterns, it prevents relays from learning anything about other network endpoints. In the case of SimpleX Chat, any CLI client can act as a chat relay without modifications. + +Channels therefore inherit all of SMP's transport privacy properties: + +- **Relays cannot observe subscriber network addresses.** The relay sees SMP queue addresses, not IP addresses or network sessions. The subscriber's IP is known only to their SMP router, which cannot see the message content (encrypted at the agent layer) or the IP addresses of whoever sends messages. + +- **SMP routers cannot see channel content.** Messages between relay and subscriber are end-to-end encrypted. The SMP router forwards fixed-size encrypted blocks without knowing whether they carry channel messages, direct messages, or anything else. + +- **Participation in multiple channels is unlinkable.** Each channel connection uses independent SMP queues with separate cryptographic credentials. Because of packet-level anonymity in 2-node routing, even if a subscriber uses the same SMP routers for all channels, the sending relays cannot determine this without collusion with those routers. Clients choose independently operated routers by default. + +No single point in the system sees both content and network identity. SMP routers see network addresses but not content, and no single SMP router can see which endpoints are communicating because clients choose independently operated routers. Relays see content but not network addresses. + +### Content visibility and participant privacy + +Any channel joinable via a public link, whether encrypted or not, must be considered completely public - the cost of joining through automated means has collapsed with large language models and is approaching zero. End-to-end encrypting such content provides no privacy; it only undermines users' security by creating false expectations and increases infrastructure operators' risks by making them unable to see what they deliver. Private channels with encrypted content are a separate use case discussed in [Future work](#end-to-end-encrypted-side-conversations). + +Content of public channels is therefore not end-to-end encrypted between owner and subscriber. Relays can read the messages they forward. Relay operators cannot undetectably alter channel content when multiple relays serve the channel, and cannot alter signed content at all - the authoritative state is held by owners. That each channel can use multiple chat relays provides both technical reliability and censorship resistance against any relay-specific content policies. + +The achievable privacy property for public communication is participation privacy - protecting who reads and writes content. The SMP transport carries no user identifiers, and relays are ordinary SMP clients, so subscribers connect without revealing their identity, network address, or any information that persists across channels. If an adversary joins a SimpleX channel, they see everything that is sent, but cannot determine who sent it or link any participant to anything outside the channel. + +Other systems make the opposite choice: content encryption in exchange for participant identification. For groups and channels joinable via public links this is the opposite of what is needed - the content encryption is meaningless (anyone can join and read), while the participant identification is the security threat. + +### In comparison + +**Telegram channels** - the operator controls channel identity (usernames are revocable), has full access to both content and participant identity. Channels cannot exist without Telegram's permission. + +**Nostr relays** - a single persistent key is used for publishing, following, and identity. Relays see content, the user's key, and their IP address. All posts and follow lists are signed and non-repudiable, linked to the same key - making both publishing and reading activity traceable and undeniable. + +**Signal groups** - content is end-to-end encrypted, but the operator manages group state and can observe the membership graph. Groups are capped at 1,000 members with no concept of a channel. + +**Matrix rooms** - server operators see room membership and metadata. Room identity is bound to the creating server's domain - if the server disappears, the room identity is lost. + +**Mastodon / ActivityPub** - publisher identity is bound to a server domain - if the server disappears, the identity is lost. Server operators see all content and all follower relationships. No encryption or privacy of any kind. + +| Property | Telegram | Nostr | Signal | Matrix | Mastodon | **SimpleX** | +|---|---|---|---|---|---|---| +| Content visible to operator | Yes | Yes | No | Configurable | Yes | **Yes** | +| Participant identity visible to operator | Yes | Yes | Yes | Yes | Yes | **No** | +| Channel identity independent of infrastructure | No | Yes | No | No | No | **Yes** | +| Sovereign ownership (no 3rd party can seize) | No | Yes | No | No | No | **Yes** | +| Programmable governance | No | No | No | No | No | **Planned** | +| Cryptographic content deniability | No | No | Yes | Yes | No | **Yes (default)** | +| Scalable one-to-many delivery | Yes | Yes | No | Limited | Yes | **Yes** | + +### Non-goals + +Channels do not attempt to: + +- **Encrypt public content from relay operators.** See [Content visibility and participant privacy](#content-visibility-and-participant-privacy). +- **Assign persistent identities to participants.** There are no usernames, public keys, or any identifiers that persist across channels or link activity across contexts. +- **Require network-wide consensus.** Channel state is authoritative on owner devices. The network does not validate channel transactions. +- **Guarantee immutability of content.** Channel state is fully controlled and mutable by owners, unlike blockchain state, which is immutable by design. + +## Architecture + +The introduction established what channels provide and why. This section describes how: where state lives, how identity and ownership work, how governance evolves, and what each participant does. + +### State and distribution + +The authoritative record of a channel - content, member roster, profile, cryptographic keys, governance rules - is held by channel owners on their own devices, not on relays, not on any server, and not on any shared ledger. Relays hold transient copies for distribution and optional caching, analogous to CDN edge nodes: the origin holds the truth, CDN nodes come and go. Consensus is only required between channel owners, not across the entire network. + +``` + ┌──────────┐ + │ Owner │ <- authoritative state + └────┬─────┘ + │ + ┌───────────┼───────────┐ + │ │ │ + ┌────▼───┐ ┌────▼───┐ ┌────▼───┐ + │Relay A │ │Relay B │ │Relay C │ <- cache / distribution + └────┬───┘ └────┬───┘ └────┬───┘ + │ │ │ + ┌─────┼─────┐ ... ┌─────┼─────┐ + │ │ │ │ │ │ + S1 S2 S3 S7 S8 S9 <- received copies +``` + +Content originates on the owner's device and flows through relays to subscribers. Each relay independently forwards to all of its subscribers. Subscribers do not connect to owners or to each other - this provides better scalability than peer-to-peer SimpleX groups, where adding a member requires N new connections. When multiple relays serve the same channel, subscribers deduplicate at the client level. + +**Failure modes:** + +- **Loss of a relay is loss of a cache node, not loss of data.** The owner can send the same content through a replacement relay. + +- **Loss of all owner devices is the catastrophic event** - relay caches become orphaned and the channel's private keys are gone. Multiple owners and backups mitigate this risk. + +- **Disagreements between relays are resolved by the origin.** The owner's version is authoritative, settling cache inconsistency through any reachable relay. + +Subscribers hold their own received copies. Signed messages are independently verifiable without consulting the relay or owner. Unsigned content depends on cross-relay consistency or future transcript integrity mechanisms. + +### Identity and ownership + +A channel's identity is the SHA-256 hash of the genesis root public key, computed at creation time and never changed - even if relays are added, removed, or the channel link is rotated. It is self-authenticating: derived from a key pair that only the channel creator held. It is embedded in the channel's link, distributed in the profile to all members, and used as a binding prefix in all signed messages. + +Subscribers validate that the identity in the link matches the identity in the profile, preventing link substitution. Profile updates that attempt to change the identity are rejected. Full validation that the identity equals the hash of the root key is deferred: if current clients enforced this check, they would reject future rotated links as invalid. The identity is correctly managed today; validation will be enforced with the key rotation protocol. See the [group identity binding RFC](../rfcs/2026-03-28-group-identity-binding.md). + +The root key does not sign messages directly. Instead, it authorizes owner keys through a signed chain. At creation, the owner generates a root key pair and a separate member key pair for signing. The member key is published as an authorization entry signed by the root key. New owners can be added by any previously authorized owner signing a new entry. Anyone retrieving the channel link can verify this chain without network access. + +The root key is a bootstrap key - it certifies owners, then need not be used again. All owners are cryptographically indistinguishable to subscribers (they all have equally valid authorization chains), which - provided multiple owners were signed by the root key - conceals the creator's identity. + +The channel link is the out-of-band trust anchor - relays and SMP routers cannot modify link content. All members announce their signing keys on joining. Owner keys are verifiable against the link. Role changes (promoting members to admin, moderator) are signed by owners at the protocol level. + +A planned extension will record role changes as a linearly ordered signed roster log with consistent sequencing across all owners, relays, and subscribers. This linearization prevents ambiguous roster states from concurrent unordered changes, and creates a verifiable chain of trust from the channel link through owners to all elevated roles. Out-of-band key verification for non-owner members will further extend this to E2E encrypted conversations. + +### Governance + +"Management" in "information delivery and management" refers not only to managing content but to managing the channel itself - who can make decisions, and how. + +The low-level protocol supports multiple owners from the initial release. The application-level governance model evolves through a planned progression: + +**Current (v6.5): Single owner.** One owner controls the channel. All administrative actions (profile changes, roster modifications, relay management) are decided by this single owner. The protocol-level owners chain supports verification of multiple entries, but the application creates and manages only one. + +**Near-term (v7): Multiple owners, any-owner-decides.** Multiple owners share control of the channel. Any owner can independently make any administrative decision - add or remove members, change the profile, manage relays. This is the most common decision-making model in practice (equivalent to "all admins are equal" in most online platforms). No coordination between owners is required for any action. + +**Future: Multisig and programmable governance.** Further stages include M-of-N multisig for administrative actions and, eventually, programmable governance rules defined as code in the channel's definition. The protocol must support these without prescribing a specific governance model. + +### Roles + +- **Owners** create the channel, hold the authoritative state and private keys on their devices, publish content, and manage the member roster. Owners sign administrative messages and optionally content messages. A channel must have at least one owner. + +- **Relays** receive content from owners and members with posting rights, optionally cache it, and forward it to subscribers. They accept new subscriber connections and introduce them to the channel owners. Relays cannot author messages. A channel must have at least one active relay. Relays are ordinary SimpleX clients - a relay can be operated by anyone (a channel operator, a third-party service provider, or a self-hosted instance) and each creates its own contact address link, bound to the channel's identity. The relay's relationship with the channel is transient - owners can add and remove relays without changing the channel's identity. + +- **Subscribers** connect to relays and receive content. They cannot send messages by default, but can be given posting rights. + +Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. + +For protocol-level detail - wire formats, message types, signing and verification mechanics, delivery pipeline - see [SimpleX Channels Protocol](./channels-protocol.md). + + +## Cryptographic primitives + +- **Ed25519** - channel identity (root key pair), owner authorization chain, and message signing. The signature binding prefix includes the channel's entity ID and the sender's member ID, preventing cross-channel replay. + +- **SHA-256** - derives the channel's entity ID from the genesis root public key. Immutable, serves as the channel's permanent identity. + +- **Double ratchet with post-quantum KEM** (inherited from [SimpleX agent layer](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/agent-protocol.md)) - end-to-end encryption for all SMP transport. Not channel-specific - channels inherit it by being built on the agent layer. Future E2E side conversations (support scope, member DMs, private channels) will use the same mechanism. + +Content messages are not signed by default to preserve cryptographic deniability - see [Signing scope](#signing-scope-roster-only-content-optional). Owners may opt into signing all content in a future release. + + +## Security + +This section examines what the architectural properties protect against, where they hold, and where gaps remain. + +### Design objectives + +The channel protocol is designed to achieve the following security objectives: + +1. **Stable message delivery** between channel participants, resilient to individual relay failures. +2. **No possibility for a relay to substitute the channel** - the channel's identity is cryptographically bound to the link and profile controlled by channel owners. +3. **No possibility for a relay to impersonate an owner** - administrative messages require valid signatures. +4. **Prevention of relay-initiated roster manipulation** - member removal, role changes, and other roster modifications require valid owner signatures. +5. **Relay transience** - the owner can add and remove relays, including the last relay, without permanently losing the channel. Subscribers can restore connectivity by retrieving updated link data. +6. **Sender anonymity within multi-owner channels** - owners can publish as the channel, hiding which specific owner authored a message from subscribers. +7. **Participant privacy** - relay operators cannot determine subscriber identity or network address, and subscribers cannot determine each other's identity. This is inherited from the SMP transport layer. + +### Signing scope: roster only, content optional + +By default, only roster-modifying and administrative messages are signed. Content messages are not signed. Two reasons: + +1. **Cryptographic deniability.** Signing creates non-repudiable proof of authorship verifiable by any third party. Without signatures, no such proof exists - a relay could have fabricated any unsigned message. + +2. **Proportional defense.** Changes to roster, channel profile, and permissions can be disruptive and irreversible - they must be authenticated at processing time. Content manipulation is detectable post-hoc through cross-relay consistency, and the authoritative record on the owner's device is unaffected. + +Owners will be able to opt into signing content on a per-channel or per-message basis - some publishers want non-repudiable authorship, others prefer deniability. + +### Threat model + +This threat model assumes the [SimpleX network threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/security.md) and addresses threats specific to the channel layer. + +**A single compromised relay** + +*can:* + +- Substitute unsigned content or selectively drop messages for its subscribers. Detectable by subscribers connected to other relays - the owner's version is authoritative. TODO: difference detection not yet implemented. +- Selectively target specific subscribers while delivering correctly to others. +- Ignore the "message from channel" directive, revealing which owner sent a message. Detectable out-of-band. +- Fabricate or hide subscriber connections, inflating or deflating counts. Detectable if subscribers are connected to other relays. + +*cannot:* + +- Undetectably substitute content - subscribers on honest relays receive the original. +- Alter the channel's authoritative state on the owner's device. +- Substitute the channel profile or impersonate an owner - these require valid signatures. +- Redirect subscribers to a different channel - the entity ID is validated across link and profile. +- Determine subscriber identity or network address - inherited from SMP transport. +- Correlate subscriber participation across channels - each connection uses independent SMP queues. The subscriber chooses their SMP router independently, so collusion between a relay and the relay's SMP router does not compromise connections through a different router. + +**All relays compromised and colluding** + +*can:* + +- Undetectably substitute unsigned content for all subscribers, unless owners sign content messages. +- Prevent delivery of any messages, including signed ones (signing prevents substitution, not dropping). +- Fabricate or hide subscriber connections undetectably. + +*cannot:* + +- Forge signed administrative messages or substitute the channel profile. +- Alter the authoritative state on the owner's device. + +**Compromise of owner keys** + +An attacker who obtains the root private key or an owner's member private key (through device compromise, backup theft, or coercion) can impersonate the owner and sign arbitrary administrative messages. This is a different threat from key loss - the channel continues operating, but under adversarial control. Mitigation depends on owner-side operational security and future multisig governance. For the threat model of the channel link itself (the trust anchor), see the [short links for groups RFC](https://github.com/simplex-chat/simplexmq/blob/stable/rfcs/2025-04-04-short-links-for-groups.md). + +**Loss of all owner devices** + +The channel can have no new content, no administrative updates, no new owners. Relay caches continue delivering existing content but cannot be refreshed, and will eventually expire in the absence of the owner connection. Multiple owners and key backups mitigate this risk. + +**A subscriber** + +*can:* + +- See all public content, by design. +- Join multiple times with different profiles, inflating counts. + +*cannot:* + +- Identify other subscribers, send messages to the channel (unless given posting rights), or forge messages of the owner or other subscribers. + +**A passive network observer** + +*can:* + +- Observe communication with an SMP router, but not whether it is channel-related. + +*cannot:* + +- Determine which channel a subscriber uses, correlate channel activity with other SimpleX activity, or identify a relay as distinct from an ordinary user, other than by traffic volume. Inherited from SMP transport. + +### Current gaps + +1. **Cross-relay consistency detection.** Duplicate messages are silently deduplicated without hash comparison. Designed but not implemented. +2. **Link entity ID validation.** Deferred to a future version with key rotation. See [group identity binding RFC](../rfcs/2026-03-28-group-identity-binding.md). +3. **Multi-relay UX.** Protocol supports multiple relays per subscriber; no UX for monitoring relay-level delivery health. It will be added in v6.5.x. + + +## Future work + +### Stateful access and history navigation + +Currently, relays send recent cached history on join but do not support navigation or search. Planned: history pagination by timestamp or message ID, remote search against relay caches, and selective retrieval of specific message ranges. Relay operators can differentiate on cache depth and search capabilities. + +### Transcript integrity + +- **Opt-in content signing.** Per-channel or per-message choice to sign content, making it non-repudiable. This will be released in SimpleX Chat v7. +- **Subscriber transcript acknowledgment.** Subscribers periodically sign a digest of received history ("I've seen it" rather than "I've authored it"), enabling detection of relay manipulation through diverging digests. +- **Merkle tree signing.** Owner periodically publishes a signed Merkle root. Subscribers verify their copies against the owner's authoritative record. + +### End-to-end encrypted side conversations + +- **E2E encrypted support scope** between subscriber and moderator/owner. +- **E2E encrypted DMs between members** where channel settings permit, using standard SimpleX connection establishment. +- **Private channels** where the entire content stream is encrypted to authorized subscribers. The relay becomes a conduit that sees neither content nor identity. + +### Relay addition and removal + +Dynamic relay addition with cache population from existing relays or owner. Relay removal with subscriber migration. Relay rotation with continuity - new relay connects before old relay is removed. It will be added in v6.5.x. + +### Governance evolution + +- **Multiple owners (v7):** concurrent administrative authority, any owner acts independently. +- **Multisig:** M-of-N approval for administrative actions, with per-action quorums. +- **Programmable governance:** rules defined as code in the channel definition. + +### Pre-moderation + +Subscriber messages reviewed by moderators before becoming visible to all subscribers. + +### Scheduled delivery + +Messages scheduled for future delivery, cached by relay until the scheduled time. + +### Link preview proxying + +The relay loads link previews on behalf of the sender - it already sees message content, so it learns nothing new, and unlike the sender its IP is not linked to any identity. + + +## Conclusion + +SimpleX Channels enable a publisher to reach an unlimited audience without any infrastructure operator knowing who that audience is. No third party can seize the channel because owners hold the keys and the authoritative state on their own devices - relays only cache and forward. Owner signatures protect content integrity and the trust chain extends to all administrative roles. These properties require a network without participant identifiers - they cannot be added to a system that has them. diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md new file mode 100644 index 0000000000..6a232ea2ff --- /dev/null +++ b/docs/protocol/channels-protocol.md @@ -0,0 +1,273 @@ +Revision 1, 2026-04-28 + +# SimpleX Channels Protocol + +For architecture, design rationale, security properties, and threat model, see [SimpleX Channels Overview](./channels-overview.md). + +## Table of contents + +- [Protocol](#protocol) + - [Channel creation](#channel-creation) + - [Relay acceptance](#relay-acceptance) + - [Relay addition](#relay-addition) + - [Subscriber connection](#subscriber-connection) + - [Message signing](#message-signing) + - [Message forwarding](#message-forwarding) + - [Binary batch format](#binary-batch-format) + - [Delivery pipeline](#delivery-pipeline) + - [Message deduplication](#message-deduplication) + - [Channel-as-sender messages](#channel-as-sender-messages) + - [Member support scope](#member-support-scope) + + +## Protocol + +This document describes the channel protocol as currently implemented. It builds on the [SimpleX Chat Protocol](./simplex-chat.md), using the same message format and connection model, with extensions for relay-mediated distribution and cryptographic message signing. + +### Channel creation + +Creating a channel involves generating cryptographic material, creating the channel link, and connecting relay members: + +1. **Key generation.** The owner generates an Ed25519 root key pair. The entity ID is computed as `sha256(rootPubKey)`. A separate member key pair is generated for message signing, and an `OwnerAuth` entry is created, signed by the root key. + +2. **Link creation.** The owner calls the agent's `prepareConnectionLink` API with the root key pair and entity ID. This returns a prepared link (including a `ConnShortLink` address) without any network calls. The link address is deterministic, derived from the fixed data hash, so it can be embedded in the group profile immediately. + +3. **Link data upload.** The owner calls `createConnectionForLink`, which makes a single network call to create the SMP queue and upload the encrypted link data. The link's fixed data contains the root public key and connection request. The mutable user data contains the `OwnerAuth` array, the channel profile (including the entity ID and the link itself), and the initial subscriber count. + +4. **Relay invitation.** For each selected relay, the owner sends a contact request containing an `x.grp.relay.inv` message with the channel's short link. The relay retrieves the link data, validates the channel profile, creates its own relay link (with the channel's entity ID in its immutable data), and responds with `x.grp.relay.acpt` containing its relay link. + +5. **Link update.** As each relay accepts and provides its relay link, the owner validates that the relay link contains the correct entity ID, then adds the relay link to the channel link's mutable data. + +6. **Local record.** The channel is stored on the owner's device with the root private key, member private key, and channel profile. This local record is the authoritative state of the channel. + +### Relay acceptance + +When a relay receives an invitation to serve a channel, it validates the channel and creates its own relay link. This flow is currently part of channel creation; adding relays to an existing channel is planned but not yet implemented. + +1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, and the channel's short link. + +2. Relay receives the invitation and creates a relay request record. A relay request worker processes it asynchronously. + +3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. + +4. The relay creates its own contact address link (the relay link) with the channel's entity ID in the immutable fixed data. + +5. The relay accepts the owner's connection request, sending its relay link in the acceptance. + +6. The owner retrieves the relay link data, validates that the entity ID in the relay link matches the channel's entity ID, and adds the relay link to the channel link's user data. + +TODO: Periodic monitoring where the relay retrieves channel link data to verify its relay link is still listed is planned but not yet implemented. + +### Relay addition + +When the owner adds a relay to an existing channel: + +1. **Acceptance.** The new relay accepts the invitation following the [Relay acceptance](#relay-acceptance) flow. The owner promotes the relay to active when the channel link's updated relay list is confirmed. + +2. **Announce.** If the channel has at least one subscriber, the owner sends `x.grp.relay.new` (carrying the new relay's short link) to every other currently-connected relay of the channel. + +3. **Forward.** Each relay forwards `x.grp.relay.new` to its subscribers. The relay does not create a member record for the announced relay — relays do not connect to other relays of the same channel. + +4. **Connect.** On receipt, the subscriber resolves the announced short link and connects to the new relay asynchronously. + +The announce is an optimisation. When it does not reach a subscriber — because the channel had no subscribers at announce time, because an older client or relay sits in the path, or because of a transient network failure — the subscriber reaches the same end state on the next channel open via its relay sync against the channel's link data. + +### Relay rejection + +When a relay operator removes the relay from a channel, the relay marks the channel as rejected and refuses future invitations from the same channel link: + +1. **Leave.** The relay operator runs `/leave #channel`. The relay marks the channel as rejected locally, keyed by the channel's short link. + +2. **Refuse.** When the owner later sends `x.grp.relay.inv` for the same channel link — typically from a re-invitation — the relay does not accept the invitation as a relay. Instead it replies with `x.grp.relay.reject` over the owner-relay direct contact channel, carrying a rejection reason. The current reason is `rejoin_rejected`; older relays or future reasons fall through to an unknown reason for forward compatibility. + +3. **Owner handling.** The owner marks the corresponding relay as rejected and notifies the operator UI. The owner also sets the relay member's status to `GSMemLeft` so the UI treats the rejected relay identically to one that ran `/leave`. The owner's next user-initiated relay addition for the same channel creates a fresh invitation, which the relay rejects again unless the rejection has been cleared. + +4. **Clear.** The relay operator runs `/group allow ` to clear the rejection for the channel. After the next user-initiated relay addition, the relay accepts the invitation and rejoins as a relay. + +An older owner client that does not recognise `x.grp.relay.reject` ignores the message and leaves the relay invitation in an invited state indefinitely — the same end state as a relay that does not respond. An older relay binary does not enforce rejection; in mixed-version deployments the operator can re-run `/leave` under the new binary to re-establish rejection. + +### Subscriber connection + +A subscriber joins a channel through the following flow: + +1. **Link retrieval.** The subscriber scans or receives the channel's short link. The client retrieves the link data, which contains the channel profile, owner authorization chain, and list of relay links. + +2. **Relay link resolution.** For each relay link listed, the client resolves the `ConnectionRequestUri` from the relay's short link. + +3. **Connection.** The client connects to relays - the first synchronously, the rest asynchronously. Each connection sends an `x.member` message with the subscriber's profile (or an incognito profile, created once and shared with all relays), member ID, and member signing key. + +4. **Relay acceptance.** Each relay accepts the connection, creates a member record for the subscriber with the configured subscriber role (default `observer`), and sends an `x.grp.link.inv` message with the channel profile and group link invitation data. + +5. **Introduction.** The relay introduces the new subscriber to the channel's moderators and owners by sending an `x.grp.mem.new` message. It also sends moderator/owner profiles to the subscriber. + +6. **History.** If the channel has history sharing enabled, the relay sends recent cached history to the new subscriber. + +The subscriber is functional (can receive messages) as soon as at least one relay connection succeeds. Additional relay connections provide redundancy and cross-relay consistency checking. + +### Message signing + +Messages that alter the channel's roster, profile, or administrative state are cryptographically signed by the sending owner. Content messages are not signed by default; see [Signing scope](#signing-scope-roster-only-content-optional) for the rationale. + +**Which messages require signatures:** + +| Message | Description | Signed | +|---|---|---| +| `x.grp.del` | Delete channel | Required | +| `x.grp.info` | Update channel profile | Required | +| `x.grp.prefs` | Update channel preferences | Required | +| `x.grp.mem.del` | Remove member | Required | +| `x.grp.mem.role` | Change member role | Required | +| `x.grp.mem.restrict` | Restrict member | Required | +| `x.grp.relay.new` | Announce new relay to subscribers | Required | +| `x.grp.leave` | Leave channel | Required (unverified allowed between subscribers) | +| `x.info` | Update member profile | Required (unverified allowed between subscribers) | +| `x.msg.new` | Content message | Not signed | +| `x.msg.update` | Edit message | Not signed | +| `x.msg.del` | Delete message | Not signed | + +**Signing process:** + +The signing context binds the signature to a specific channel and sender: + +``` +bindingPrefix = smpEncode(CBGroup) <> smpEncode(publicGroupId, memberId) +signedBytes = bindingPrefix <> messageBody +signature = Ed25519.sign(memberPrivKey, signedBytes) +``` + +The binding prefix includes the chat binding tag (`"G"` for group), the channel's entity ID, and the sender's member ID. This prevents cross-channel and cross-member replay attacks - a signature valid in one channel cannot be reused in another. + +**Verification process:** + +When a subscriber receives a signed message: + +1. The signature is present: reconstruct the binding prefix from the channel's stored entity ID and the sender's member ID. Verify all signatures against the sender's stored public key. If all verify, the message is accepted as verified. + +2. The signature is present but the sender's key is unknown: the message is accepted as signed-but-unverified only if the event does not require a signature. For `x.grp.leave` and `x.info` between subscribers whose keys haven't been exchanged yet, unverified signatures are permitted as a temporary measure. + +3. No signature is present: the message is accepted only if the event does not require a signature (i.e., the channel does not use relays, or the event is a content message). + +If verification fails for a message that requires a signature, the message is rejected and a bad signature event is shown to the user. + +### Message forwarding + +Content originates on the owner's device and flows through relays to subscribers. The forwarding mechanism preserves the original message bytes, including any signature, without re-encoding: + +**Owner to Relay:** The owner sends messages directly to each relay over their SMP connection. Messages are encoded in binary batch format. + +**Relay processing:** When a relay receives a message from an owner, it: + +1. Parses and processes the message locally (updating its cached state, e.g. for roster changes). +2. If the relay is configured to forward for this channel, creates a **delivery task** for each message that should be forwarded to subscribers. The task records the message ID, the sender's member ID, the broker timestamp, and whether the message was sent as the channel (not attributed to a specific owner). +3. The delivery task is persisted to the database for delivery reliability - ensuring forwarding can resume after a relay crash. + +**Relay to Subscribers:** A delivery task worker reads pending tasks, batches them into delivery jobs, and a delivery job worker sends each job to subscribers in paginated batches (using a cursor over group member IDs). + +For forwarded messages from subscribers to owners (e.g. support scope messages), the relay wraps the message in a forwarding envelope: + +``` +forwardEnvelope = ">" <> smpEncode(GrpMsgForward) <> encodeBatchElement(signedMsg, msgBody) +``` + +This preserves the original message's signature bytes verbatim. + +### Binary batch format + +Channels use a binary batch format that preserves exact message bytes for signature verification. This is distinct from the JSON array batching used by regular groups. + +```abnf +binaryBatch = %s"=" elementCount *batchElement +elementCount = 1*1 OCTET ; 1-255 elements +batchElement = elementLen elementBody +elementLen = 2*2 OCTET ; 16-bit big-endian length + +elementBody = signedElement / forwardElement / plainElement / fileElement + +signedElement = %s"/" chatBinding sigCount *msgSignature jsonBody +forwardElement = %s">" grpMsgForward (signedElement / plainElement) +plainElement = %s"{" *OCTET ; JSON message body +fileElement = %s"F" *OCTET ; binary file chunk + +chatBinding = 1*1 OCTET ; "G" (group), "D" (direct), "C" (channel) +sigCount = 1*1 OCTET ; number of signatures (1-255) +msgSignature = keyRef sigBytes +keyRef = %s"M" ; member key reference +sigBytes = 64*64 OCTET ; Ed25519 signature + +grpMsgForward = fwdSender brokerTs +fwdSender = memberFwd / channelFwd +memberFwd = %s"M" memberId memberName ; attributed to specific member +channelFwd = %s"C" ; attributed to channel as sender +brokerTs = 8*8 OCTET ; UTC system time +``` + +The parser (`parseChatMessages`) dispatches on the first byte: + +- `'='` -> binary batch (new format, used by channels) +- `'X'` -> compressed (decompress, then re-parse) +- `'['` -> JSON array (legacy group format) +- `'{'` -> single JSON message + +Forward elements contain the original message bytes verbatim. The relay does not re-encode the inner message. This is what makes signature verification possible after forwarding: the exact bytes that were signed by the owner are preserved through the relay. + +Nested forwarding (`>` inside `>`) is explicitly rejected by the parser. + +### Delivery pipeline + +The relay's delivery pipeline has two stages, both backed by persistent database tables for delivery reliability (not for authoritative storage - the relay's database is a delivery queue, not a content database): + +**Stage 1: Delivery tasks.** When the relay receives a message from an owner that should be forwarded, it creates a `delivery_task` record: + +``` +delivery_task: + group_id, worker_scope, job_scope, + sender_group_member_id, message_id, + message_from_channel (bool), + task_status (new -> processed) +``` + +A **task worker** (one per group per scope) reads pending tasks, batches multiple tasks into a single binary batch body, and creates a delivery job. + +**Stage 2: Delivery jobs.** A delivery job contains the pre-encoded batch body and a cursor for paginated delivery: + +``` +delivery_job: + group_id, worker_scope, job_scope, + body (pre-encoded binary batch), + cursor_group_member_id, + job_status (pending -> complete) +``` + +A **job worker** reads the body and delivers it to subscribers in paginated batches. For each page, it loads a bucket of subscribers by cursor position, sends the body to all of them, advances the cursor, and continues until all subscribers have been served. This avoids loading all subscribers into memory at once. + +For subsequent subscribers in a batch, the agent uses a value reference to the first subscriber's message body, avoiding redundant data transmission to the SMP server. + +### Message deduplication + +When multiple relays serve the same channel, each subscriber receives the same message from each relay independently. Deduplication is performed at the subscriber's client level using the message's shared message ID: + +- When saving a received message, the client checks whether a message with the same shared ID already exists for this group. +- If a duplicate is found, the message is silently dropped (in channels with relays). +- In non-relay groups, duplicate detection triggers a `x.grp.mem.con` notification to the forwarding member. + +This is essentially cache coherence verification - comparing what was received from one cache node against another. TODO: Currently, deduplication only detects the presence of duplicates. The protocol design includes provisions for detecting differences between relay-delivered copies of the same message (hash comparison, UI indicators for discrepancies). This is described in the [channels forwarding RFC](../rfcs/2025-08-11-channels-forwarding.md) and is not yet implemented. + +### Channel-as-sender messages + +Owners can send messages attributed to the channel rather than to themselves. When `asGroup = True` is set in the message container, the relay forwards the message with a channel-as-sender tag instead of attributing it to a specific member. On the subscriber side, such messages are displayed as coming from the channel (using the channel's profile image and name) rather than from a specific owner. + +This will be useful for channels with multiple owners (not yet implemented at application level) where the identity of the specific sender should not be visible to subscribers. The relay must respect this directive; ignoring it and revealing the sending owner's identity is a threat vector (detectable out-of-band by members communicating with the owner). + +The forwarding binding prefix for channel-as-sender messages uses `CBChannel` instead of `CBGroup`, and includes only the channel's entity ID (not the sender's member ID): + +``` +channelBinding = smpEncode(CBChannel) <> smpEncode(publicGroupId) +``` + +### Member support scope + +Channels support a **member support scope** - a private side-channel between a subscriber and the channel's moderators/owners. Messages sent in the support scope are delivered only to moderators and the scoped subscriber, not to all subscribers. + +A support-scoped message includes the target member's ID. The delivery pipeline uses a separate job scope for support messages, loading only the scoped member and moderators rather than all subscribers. + +Support scope messages are visible only to the subscriber who initiated the support conversation and to the channel's moderators. Other subscribers cannot see them. This allows subscribers to report issues, appeal moderation decisions, or communicate with administrators without revealing their identity to other subscribers. diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index c74465f6fe..040064864c 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -266,6 +266,12 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.msg.forward` message is sent by inviting member to forward messages between introduced members, while they are connecting. +### Channels: relay-mediated groups + +Channels are groups where message delivery is mediated by dedicated relay members rather than by direct connections between all members. Channels extend the group sub-protocol with additional roles (`relay`, `observer`), message signing for administrative actions, a binary batch format for signed and forwarded messages, and an asynchronous delivery pipeline. + +For architecture and design rationale, see [SimpleX Channels Overview](./channels-overview.md). For protocol-level detail - wire formats, message types, signing mechanics, delivery pipeline - see [SimpleX Channels Protocol](./channels-protocol.md). + ## Sub-protocol for WebRTC audio/video calls This sub-protocol is used to send call invitations and to negotiate end-to-end encryption keys and pass WebRTC signalling information. @@ -282,12 +288,13 @@ These message are used for WebRTC calls: ## Threat model -This threat model compliments SMP, XFTP, push notifications and XRCP protocols threat models: +This threat model complements SMP, XFTP, push notifications and XRCP protocols threat models, as well as the channel-specific threat model: - [SimpleX Messaging Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#threat-model); - [SimpleX File Transfer Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/xftp.md#threat-model); - [Push notifications threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/push-notifications.md#threat-model); -- [SimpleX Remote Control Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/xrcp.md#threat-model). +- [SimpleX Remote Control Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/xrcp.md#threat-model); +- [SimpleX Channels threat model](./channels-overview.md#threat-model). #### A user's contact @@ -342,3 +349,27 @@ This threat model compliments SMP, XFTP, push notifications and XRCP protocols t *cannot:* - prove that two group members with incognito profiles is the same user. + +#### A channel relay + +For the full channel threat model, see [SimpleX Channels: threat model](./channels-overview.md#threat-model). + +*can:* + +- send arbitrary unsigned content messages to subscribers, effectively fabricating the content stream while the channel identity and signed profile remain intact. + +- selectively drop any messages, both content and signed administrative events, for some or all subscribers. + +- ignore the "message from channel" directive, revealing which specific owner sent a message. + +- fabricate subscriber connections, inflating subscriber counts. + +*cannot:* + +- impersonate an owner - administrative messages (roster changes, profile updates, channel deletion) require valid cryptographic signatures that the relay cannot produce. + +- substitute the channel profile - profile changes require a valid owner signature. + +- redirect joining subscribers to a different channel - the channel's entity ID is baked into both the channel link and the relay link's immutable data. + +- determine the real-world identity of subscribers - subscriber connections carry no persistent identity. diff --git a/docs/rfcs/2025-04-14-signing-messages.md b/docs/rfcs/2025-04-14-signing-messages.md index 8845de0cd8..1ad6e6778f 100644 --- a/docs/rfcs/2025-04-14-signing-messages.md +++ b/docs/rfcs/2025-04-14-signing-messages.md @@ -81,3 +81,13 @@ Cons: - two-stage decoding may be seen as a downside, but it is offset by the fact that re-encodings are avoided, and under the hood JSON is decoded in stages anyway. While deterministic JSON is [quite simple](https://github.com/simplex-chat/aeson/pull/4/files) for aeson implementation, the Option 2 seems more attractive overall, as it avoids questionable design of including signatures into JSON and the need to re-encode JSON to sign and to verify signatures. + +## Signing scope: roster changes only, not content messages + +Only roster-modifying and group management messages are signed (e.g. `XGrpMemNew`, `XGrpMemRole`, `XGrpMemDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpDel`). Regular content messages (`XMsgNew`, etc.) are not signed. + +Two reasons: + +1. **Deniability.** Signing content messages would create non-repudiable proof of authorship — any party with access to the message bytes could prove who wrote a specific message. This is antithetical to SimpleX's privacy model, where messages should be deniable. Administrative actions (adding/removing members, changing roles) don't need deniability — they are organizational actions, not personal communications. + +2. **Different threat model.** Content message manipulation by relays is detectable post-hoc: with multiple independent relays, members can cross-check message consistency and detect forgery after the fact. This is sufficient for content because content delivery is not irreversible — a forged message can be flagged and corrected. Roster and profile changes, on the other hand, are disruptive and irreversible (a member removed, a role changed, a group deleted). By the time forgery is detected, the damage is done. These actions must be authenticated at processing time, before they take effect. diff --git a/docs/rfcs/2025-08-11-channels-forwarding.md b/docs/rfcs/2025-08-11-channels-forwarding.md new file mode 100644 index 0000000000..ba0711c57a --- /dev/null +++ b/docs/rfcs/2025-08-11-channels-forwarding.md @@ -0,0 +1,237 @@ +# Channels forwarding + +This expands on the previous [channels rfc](./2025-07-30-channels.md), specifically on message forwarding by chat relays. + +## Problem + +Current implementation of groups uses forwarding mechanism for improved message delivery between connecting members. From perspective of reusing it for channels it has following limitations: +- Messages are forwarded only for the duration of members establishing connection, until they report x.grp.mem.con to forwarding admin. +- For a given pair of members only a single admin forwards messages - inviting admin (host) forwards messages between its invitees and members introduced to them. It means: + - This admin can be a single point of failure and/or can cause arbitrary delays in message delivery based on its availability when forwarding messages between these member pairs. + - Member pairs fully trust their single forwarding admin in forwarded content. + +Not limitations of protocol, but other weak points of current implementation: +- Forward operations are synchronous to message reception (by forwarding admin), so they're not resumable on failure. +- Forward operations are carried out as "group sends" to all required members (invitees/introduced members in respect to sending member), number of which is expected to grow very large in channels and can potentially have a big load on the database. + +## Solution + +Chat relays will serve as forwarding agents instead of inviting admins, with following modifications to protocol and implementation. + +### Forwarding not limited to establishing connection + +- In channels members will not connect directly to other members and channel owners. +- Chat relays will not take into consideration status of introductions. +- Chat relays will forward messages between owners and all members (from members to owners, for example, for reactions, comments). + +### All chat relays forward all messages + +- Instead of inviting admins all chat relays will forward all messages. +- Members will deduplicate messages (already implemented). +- Members should highlight differences in deduplicated messages. This would solve trust issue. + - Question: If messages are to be signed by owners, what differences can chat relays introduce? Does this point in initial doc imply owner sending different versions of message to chat relays? Losses in delivery? Answer: Not all messages will be signed. + +#### Highlighting deduplicated messages differences + +Currently we simply ignore duplicate messages - see createNewRcvMessage. + +Some options on how to highlight difference in messages from chat relays: +1. Show only the fact that there is difference. + - Persist duplicate error (SEDuplicateGroupMessage) as flag on chat item to then display warning sign in UI. +2. Save message hashes as some additional entity linked to message or chat item for each chat relay. + - If any new hash differs, set flag on chat item for warning sign in UI. + - Add ability to load additional info (with other chat item info via APIGetChatItemInfo) showing which relays sent different hashes (e.g. 1 differs from 2 others - show 1 as warning, all differ - show all as warning). +3. As previous, but save content. Possibly save content additionally to hashes, for faster comparison. + - Saving content means converting duplicate messages to chat item content, or only saving text, but latter will fail to show difference in files. +4. Create new versions as "different version of another message" chat items. + - Timestamp would have to be made the same as on original chat item, as it's not guaranteed to be the same - each chat relay will forward a broker ts based on its own metadata of receiving message from sender. + - It could be done via special replies, to re-use reply machinery. However, this won't work for messages being replies themselves then. + - Alternatively, it could say "different message from X relay". + +Service events will not have content. We still can/should compare them and indicate difference, e.g. by creating a special chat item (similar to integrity violation). + +How to compute hashes. Problem is with file descriptions, as it is legitimate case that they will be different. Hash could be computed as hash of message, excluding file description from FileInvitation. File digest and names should be the same, the problem of different names needs to be fixed. + +Files can also cause problem with showing difference in content if file preview is the same, but file hash is different. So showing difference in content does not exclude necessity of showing difference in hashes as in option 2. + +As a note, this whole section discusses an edge case of chat relays maliciously changing messages, and countermeasures to prevent them doing so undetectably. May be not worth implementing overly complex solution as MVP (options 3, 4). + +For MVP from UI standpoint this should suffice: a marker on item, that would show in info which relays delivered it with a sign which relay delivered different hash. Possibly also special event item next to marked item (with same timestamp) - for service events that don't create a chat item, or in case we don't account it for some chat items. + +### Forwarding jobs + +- Received messages will be marked for forwarding and picked up by a forwarding worker to form forwarding jobs. +- Forwarding job can be split into smaller batches by members to make smaller group sends one at a time. + +**How forwarding works now:** + +Currently we build ad-hoc forwarding instruction for each message based on its scope (GroupForwardScope). The output of processing a batch of messages is `Map GroupForwardScope (NonEmpty (ChatMessage 'Json))`. Then for each scope message batches are sent separately to different member lists (to clarify, "sent" here means scheduled for sending in agent). All this is done synchronously in receive loop. + +**How forwarding will work:** + +Chat relay should be able to handle channels consisting of hundreds of thousands of members. Synchronous forwarding will be replaced by asynchronous forwarding jobs to reduce load in the receive loop, and make forwarding resumable on failure. Also loading all members in memory will not scale, so these jobs have to be split into smaller batches. + +Question: Should synchronous processing be changed to forwarding jobs for all types of scopes, or only for Main (GFSMain - for regular messages) and All (GFSAll - for all current members and for all pending members scopes), and not for Support scope (GFSMemberSupport)? + - Pros/cons of persisting jobs for all types of scopes: + - Possibly more uniform processing. However, for support scope group member id is required. Either encode scope as json or add field for scope group member id for persistence (see schema below). + - Requires forwarding worker to have more logic ("if"), or different worker types. + - Pros/cons of persisting jobs only for Main and All scopes: + - Forwarding worker and persistence are simpler, but logic lives in 2 places (synchronous as now + worker). + - As a note, support scopes have small number of members to forward to, so optimizing forward for them is not a necessity. + +Answer: All forwarding logic will become asynchronous. This will reduce load in the receive loop for inviting admins in regular groups as well. + +Forwarding jobs for different groups can be concurrent, inside group should be sequential to follow order of messages. One approach could be to create a dedicated forwarding worker for each group. Different scope types can also use dedicated workers (possibly: 1 worker for all + main scopes, 1 - for all support scopes), so heavy full group forwarding wouldn't slow down forwarding in much smaller support scopes. + +Forwarding workers will re-use worker abstraction from agent. + +Forwarding workers should use low priority db pool. + +We roughly need following data for a forwarding job: +- group id, +- forwarding scope (forward_scope), +- sending member - to include memberId in XGrpMsgForward, +- broker timestamp of received messages batch to include in XGrpMsgForward, +- "message from channel" flag from sending owner - see "Hiding sending owner id" section below (group_as_sender), +- list of messages (events). + +**How to build message batches for a forwarding job:** + +Currently for each message a full MsgBody is saved on `messages` table record, which is just a ByteString. Also if received messages were batched, full batch body is saved for each message record (it's a questionable design). For example, here is message body of batched deletion of 2 chat items, which is saved on 2 message records: + +``` +msg_body = [{"v":"1-16","msgId":"ZUd3bzVDTXFHWmc3Z29YSQ==","event":"x.msg.del","params":{"msgId":"QVVuN2RkaXg0aVJJdTZXSA=="}},{"v":"1-16","msgId":"amM5dzV2RkRjNmNYSDRLQQ==","event":"x.msg.del","params":{"msgId":"NzFlV3pNRStpQTRrQjRYUg=="}}] +``` + +On the other hand for forwarding operation we require ChatMessage 'Json (for XGrpMsgForward), which could be saved on message records instead. + +As a side note, it seems we can stop saving full batch body as msg_body for received messages, as it's not used for any purpose. This field is only used for retrieving and sending pending group messages (sent, not received). + +Alternatively, we could read full msg_body, and repeatedly parse list of ChatMessage 'Json. However, this conflicts with possibility that a single batch body will have messages of different scopes, which current processing logic allows, although there's no legitimate case as of now. This means we'd have make jobs forward to different lists of members, or have some supervisor to launch different jobs, which further complicates the processing. Also, this limits forwarding to the same batch sizes (in terms of number of messages) as those batches that received messages came in, and instead we could possibly fit more messages into batches if available. Overall, persisting ChatMessage' Json on each message record, and then retrieving available messages for the job, seems preferable. + +**How to split forwarding jobs into smaller delivery batches:** + +For simplicity, this will be done only for channels, and not for inviting admins in regular groups, at least initially. Inviting admins have more complex logic, involving multiple queries and filters to retrieve less (only necessary) members, as we're trying to limit load on them, as they can be mobile clients. They will be moved to asynchronous processing too, though. + +Forwarding job will read required members in loop using a cursor (e.g., use group_member_id as a cursor) to split into further delivery jobs (group sends) by members. As a reminder, group send in terms of chat logic is not a network operation, but a request to agent. We can consider this part a black box for now, and consider if groups sends should be made concurrently later. For now though, for simplicity we can consider that group sends are done in a synchronous loop with moving cursor for member retrieval. Cursor position can be remembered on the job record after each group send, for recovery. + +It's unclear if there's a need for a separate lower level abstraction for delivery jobs, that could be reused for feeds and other purposes, as they would require different logic of building delivery lists, and scheduling a delivery for agent already serves a similar purpose. For now, it seems this additional abstraction is unnecessary. + +**How a forwarding job will work overall:** + +0. There is some process on start that launches necessary workers for each group/scope client serves as a forwarding agent (for those that have messages that need forwarding). Also message receive loop "kicks" necessary workers. Below we start with a worker for some group/scope trying to retrieve next work item, that is being next message(s) to forward. +1. Worker retrieves next message from `messages` table that was marked for forwarding, that matches the worker's group/scope. + - We may need a separate field on message record to track "forward pending" state, to know what to forward. Setting forward_scope as a task with addition of forward_complete flag to mark forward completion may be enough (see schema below). +2. Worker checks, if this message is already attached to some forwarding job, and the state of the job. + 1. Worker gets job record. + - If message is not attached to any job yet, worker creates a new job record, sets job id on the message record (normal execution path). + - Otherwise worker gets existing job attached to the message (recovery path). + 2. Worker determines forwarding batch encoding for the job. + - If encoding is not saved on the job, it means it wasn't determined before (normal execution): + 1. Worker retrieves more messages that match its group/scope. + - Could be in loop or in bulk. + - We won't know how much we'd need. Some heuristic could be read 100, then mark those that are used with jobId, or load more if needed. + - Also message list has to preserve reception order. + 2. Worker prepares encoding (wraps messages in XGrpMsgForward events with metadata) and saves it on the job record. + - It's important to put encoding into job and mark all relevant messages in one transaction. + - Otherwise worker uses saved encoding (recovery). +3. With moving cursor on group member id for member retrieval, forward encoded batch. In loop: + 1. Retrieve members up to some limit and filtering according to a cursor. In normal execution path on first iteration job would not have a previously saved cursor yet. + - For Main scope all current members will be retrieved. + - For Support scopes - moderators and above + scope member. For support scope job can be done without a cursor, instead simply loading all its members and forwarding. + - For All scope - all current and pending members (In channels there is no need for member review. So possibly we don't have to account for All scope). + - Main difference from forwarding in regular groups here is that for inviting admins we retrieve only introduced and invited members for the message's sending member, that are not yet connected. For channels there will be no such filtering. + 2. Group send encoded batch to retrieved members. + 3. Update cursor on the job. +4. Marks all messages attached to the job as forwarded (forward_complete), deletes the job record. + - Possibly do in cleanup manager and delete after say a week. + +Schema draft: + +```sql +CREATE TABLE forwarding_jobs ( + forwarding_job_id INTEGER PRIMARY KEY, + msg_batch_encoding TEXT, + cursor_group_member_id INTEGER +) + +ALTER TABLE messages ADD COLUMN chat_message_json TEXT; +ALTER TABLE messages ADD COLUMN forward_scope TEXT; +ALTER TABLE messages ADD COLUMN group_as_sender INTEGER NOT NULL DEFAULT 0; -- for "message from channel" flag from owner +ALTER TABLE messages ADD COLUMN forward_complete INTEGER NOT NULL DEFAULT 0; +ALTER TABLE messages ADD COLUMN forwarding_job_id INTEGER REFERENCES forwarding_jobs ON DELETE SET NULL; + +-- indexes for fkey, search based on group/scope and order; +``` + +## Other considerations + +### Hiding sending owner id + +Initial doc mentions "messages sent from channel name" in minimal testable scope. This would be a feature allowing owners to send messages to channel without it being clear for members which owner sent it. For this, we'd also like to hide sending owner's member id from forwarding operation. + +This means MemberId in XGrpMsgForward should become optional. + +```haskell +XGrpMsgForward :: Maybe MemberId -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json +``` + +Receiving members would then save this message as received from group. We already have necessary machinery for chat items persistence - see ShowGroupAsSender. However, message processing logic would have to be reworked to allow for absence of group member. Also, sending owner would have to indicate it to chat relays in XMsgNew (TBC - other events?). + +```haskell +XMsgNew :: ShowGroupAsSender -> MsgContainer -> ChatMsgEvent 'Json +``` + +ShowGroupAsSender can be in MsgContainer, not separate. + +Chat relays should not hide sending owner from other owners: sending owner should be visible to them, but message shown as from channel. This means forwarding job has to be split into separate sends with different sets of XGrpMsgForward events (with and without sending owner's member id). + +### Connection deleting events + +Forwarding regular messages, reactions, updates and many other events is straightforward. However, naive processing of some events currently breaks forwarding logic, specifically member and group deletion. + +As it is now, forwarding operation follows event processing. So, processing of XGrpMemDel, XGrpDel first deletes member connection (or connections with all members in case of group deletion), then attempts to forward these events, which in reality never works as at this point connection is already deleted. + +Currently we ignore this problem (there are TODOs), as all members according to protocol connect with each other, and forwarding serves only as message delivery improvement/backup, and not a main route, and so members in at least supposedly most cases receive these events from the original sender. + +With chat relays, however, no messages are sent directly from owners to members, so if logic is kept as is these events would never be received. So, their processing should be special cased to delay until after forwarding operation. + +## Further considerations, ideas (Update) + +- Sending member profiles. + - Profiles of owners (and admins, moderators) should be sent to all members on joining. + - Profiles of other members should be sent on interaction with them. + - For MVP: we can show counts on reactions and avoid solving it, but we should have a design to solve this problem, as it would be necessary for comments and later for large groups. + - Solution draft - partition members based on join timestamp and sender last interaction time: + - Track last interaction timestamp on each member. + - When forwarding from this member partition members into two parts: include profile for those who joined after interaction, don't include for those who joined before interaction. + - This suggests that job would be divided into two parts. + - Protocol to request member profile as a fallback. + - This becomes more complex in case batch has multiple members - for n required profiles to send, recipients need to be partitioned into n + 1 parts. + - Better solution - schedule profile deliveries separately from batch on first post-join delivery per sender. + - Track last_profile_delivery_ts (for sender), join_ts (for recipient) on member records. + - On the sender's first overall interaction (last_profile_delivery_ts is null), first create a special task to deliver profile to all (in scope All). + - On following sends on batching, for senders whose last_profile_delivery_ts < any member's join_ts (i.e., some member missed the initial broadcast), create a profile delivery task for those specific senders. + - Message task should have a flag whether profile should be delivered to anyone (set to false on first profile delivery). Checking last_profile_delivery_ts is null seems to be sufficient. +- Don't partition for owners based on "message from channel" flag to simplify delivery - no need for two separate jobs/cursors. +- When chat relay receives group deletion event, or event removing it [chat relay itself] from the group: + - Chat relay should kill all forwarding workers for the group -> delete all jobs -> create one new job to forward group deletion. + - It could be a special type of job. +- Sending each reaction (and in future comment) won't scale well. + - Instead periodically send reaction and comment counts. + - Send reactions and comments themselves on request. + - This implies that instead of message records, special entity for forwarding tasks should be added, for worker to search for next work items - see more below. +- Connections with priority. + - Client could have 2 connections/queues with relay, and relay - 2 subscribers. + - Separation of responsibilities between connections/queues: + - Normal queue to be used for regular forwarding of messages, reactions, etc. + - High-priority queue to be used for serving client requests and sending important service events (e.g. ownership changes, group deletion). + - Possibly special case of connection redundancy. +- Design to be reworked to use special entity for forwarding tasks instead of relying on messages. + - Points for this: + - Batched reactions/comments counts. + - Special logic on group deletion/relay removal. + - Possibly special logic on sending member profiles, as it's not needed for all types of jobs. + - Sum type of task types. + - Some tasks may simply point to message records. + - Some tasks may be created for further updating their metadata / scheduling, e.g. "send reactions/comments count update", and the information itself may be taken from chat items. diff --git a/docs/rfcs/2025-10-20-chat-relays.md b/docs/rfcs/2025-10-20-chat-relays.md new file mode 100644 index 0000000000..d1a3180b70 --- /dev/null +++ b/docs/rfcs/2025-10-20-chat-relays.md @@ -0,0 +1,304 @@ +# Chat relays + +## Security objectives + +Group relay protocol should achieve following objectives: +1. Stable message delivery between group members. +2. No possibility for relay to substitute group. +3. No possibility for relay to impersonate owner(s). +4. Prevent relay from altering member roster (member removal, role change, etc.). +5. Prevent relay from terminally destabilizing group by stopping to serve it. At the same time, allow owner to remove (last) relay with possibility to restore group functionality. +6. Allow owner(s) to send messages as "message from channel", hiding specific sender out of multiple owners from members. +7. Prevent relays from altering/dropping messages. + +## Protocol for adding chat relays to group + +Activations (execution bars) with looped arrows indicate internal calls/steps. + +```mermaid +sequenceDiagram + participant O as Owner + participant OSMP as Owner's
SMP server + participant R as Chat relay(s) + participant RSMP as Chat relays'
SMP server(s) + +note over O, RSMP: Owner creates new group, adds chat relays + +activate O +O ->> O: 1. Create new group
(user action) +O ->> O: 2. Prepare group link,
owner key,
group ID (agent) +O ->> O: 3. Add link, owner key
to group profile, sign +O ->> OSMP: 4. Create group link,
signed profile as data +deactivate O +OSMP -->> O: Group link created +activate O +O ->> O: 5. Choose chat relays
(automatic/user choice) +note left of O: Relay status: New +par With each relay + O ->> R: 6. Contact request
(x.grp.relay.inv
incl. group link) + deactivate O + activate R + note left of O: Relay status: Invited + note right of R: Relay status: Invited + R ->> OSMP: 7. Retrieve group link data + deactivate R + OSMP -->> R: Group link data + activate R + R ->> R: 8. Validate group profile,
verify profile signature + opt Bad profile or signature + R -x R: Abort (reject) + end + R ->> RSMP: 9. Create relay link,
set group ID
in immutable data + deactivate R + RSMP -->> R: Relay link created + activate R + R ->> O: 10. Accept request
(x.grp.relay.acpt
incl. relay link) + deactivate R + activate O + note right of R: Relay status: Accepted + note left of O: Relay status: Accepted + note over O, R: RPC connection
with relay is ready + opt Protocol extension - 2 connections + O ->> R: * Connect via relay link
(share same owner key) + deactivate O + R -->> O: Accept messaging connection + activate O + note right of R: Relay status: Accepted,
"Connected" implied from
messaging connection + note left of O: Relay status: Accepted,
"Connected" implied from
messaging connection + note over O, R: Owner: Messaging connection with relay is ready,
relay link is tested + end + create participant M as Member + R --> M: + note over R, M: At this point relay can accept
connection requests from members + O ->> RSMP: 11. Retrieve relay link data + deactivate O + RSMP -->> O: Relay link data + activate O + O ->> O: 12. Validate group ID
in relay link data + opt Bad group ID + O -x O: Abort for relay (don't add) + end + O ->> OSMP: 13. Update group link
(add relay link) + deactivate O + OSMP -->> O: Group link updated + note left of O: Relay status: Active +end + +note over O, M: Chat relay checks link - monitoring + +loop Periodically + R ->> OSMP: Retrieve group link data for served gorup + OSMP -->> R: Group link data + activate R + R ->> R: Check relay link present + deactivate R + note right of R: Relay status: Active +end + +note over O, M: New member connects + +O -->> M: 14. Share group link
(social, out-of-band) +M ->> OSMP: 15. Retrieve short link data +par RPC connection + M ->> R: 16a. Connect via relay link +and + opt Protocol extension - Messaging connection + M ->> R: 16b*. Connect via relay link
(share same member key/
identifier to correlate) + end +end + +note over O, M: Message forwarding + +O ->> R: 17. Send message +R ->> M: 18. Forward message +activate M +M ->> M: 19. Deduplicate message +deactivate M +``` + +Notes: + +- Group ID - unique group identifier (not globally unique) baked in immutable part of group link data, and repeated by chat relays in immutable parts of respective relay links. + + Owner can validate they're adding relay link to the group link specifically for their group. + + Members can validate they join relay links corresponding to group link they connected to. + +- Protocol extension: Create connections pairs between relay and members with different priority for passing regular messages and for relay responding to member requests. + + Invitation sent in step 12 should contain same key as in group link, for relay to match connection to the same owner and "active" relay link (add to `XContact` message). + + Add new connection entity, special for groups with relay, referencing member record - parallel to first member connection. + +- Client can "know" link that will be created before creating it on server - so we can add it to profile before adding profile to group short link data. + + Agent to return link that will be created upon preparing connection record. + +- On adding group short link to group profile. + + Strengthens association between link and profile. Link already contains profile in attached data, but from perspective of group profile link itself is detached. All members "see" the same link they joined via in group profile. Chat relays "see" the same link they created relay links for, and can check it for presence of their relay link at any point. + + Link is recoverable from profile, e.g. for purpose of restoring connection with group via new chat relays. + + Overall it just seems a natural and convenient way to store group link for all members, rather than having it separately. + +- On updating group link data with one relay link at a time vs waiting for all links. + + Overhead is minimal - one request to owner's SMP server per relay. + + Waiting for a relay to send relay link can take indefinitely long. + + In proposed protocol owner doesn't have to wait for links from all relays for simplicity and to minimize wait time - it allows owner to conclude group creation potentially earlier, in case some relays are stuck or offline (owner can add their links later, once they successfully send it). + +- Lock owner group link from accepting connection on SMP server, possibly has some implementation gaps. + + Reject in owner code for foolproofing. + +- What should be in relay link user data: + + - Relay key for group. + - Relay identity if provided. + Operator relays want to provide identity for trust. + User relays may not want to provide identity. + Relay identity: profile, certificate, relay identity key (global across groups). + +## Protocol for removing chat relay from group, restoring connection to group + +```mermaid +sequenceDiagram + participant O as Owner + participant OSMP as Owner's
SMP server + participant R as Chat relay + participant RSMP as Chat relay
SMP server + participant M as Member + +note over O, M: Owner deletes chat relay, notifies relay + +O ->> OSMP: Remove relay link
(update group link data) +O ->> R: Delete chat relay
(x.grp.mem.del)
over RPC connection +par Chat relay to SMP + R ->> RSMP: Delete relay link +and Chat relay to members + R ->> M: Forward relay is deleted
over RPC connection +end + +note over O, M: Scenario 2. Owner deletes chat relay, fails to notify relay + +O ->> OSMP: Remove relay link
(update group link data) +O --x R: Fail to notify relay +opt Chat relay identifies
connection with owner is deleted + par Chat relay to SMP + destroy RSMP + R ->> RSMP: Delete relay link + and Chat relay to members + destroy R + R ->> M: Notify relay is deleted
over RPC connection + end +end + +note over O, M: Last relay is deleted + +O --x M: Owner can't send messages to members +activate M +M ->> M: Attempt to restore
connection to group (manual) +M ->> OSMP: Retrieve group link data +deactivate M +OSMP -->> M: Group link data +activate M +M -x M: Members can't restore connection to group +deactivate M + +note over O, M: Restore connection to group + +create participant NR as New chat relay +O <<->> NR: Add new relay, relay creates and sends link +O <<->> OSMP: Update group link
(add relay link) +activate M +M ->> M: Attempt to restore
connection to group (manual) +M ->> OSMP: Retrieve group link data +deactivate M +OSMP -->> M: Group link data +par RPC connection + M ->> NR: Connect via relay link +and Messaging connection + M ->> NR: Connect via relay link
(share same member key/
identifier to correlate) +end +O ->> NR: Send message +NR ->> M: Forward message +activate M +M ->> M: Deduplicate message +deactivate M +``` + +Notes: + +- New relay doesn't have group history. + + - We can prohibit to remove last relay without adding new one. + - Relays can synchronize history. + - Can be considered after MVP. + +## Correlation of design objectives with design elements + +1. Redundant delivery by multiple relays. High availability of relay clients. +2. Same group ID baked in immutable data of group link and relay links. +3. Owner public key in group link. +4. Actions altering member roster can be signed by owner key, verified by members. +5. Protocol for restoring connection to group by checking group link for new relays. +6. XMsgNew protocol extension - "message from channel" flag - see [channels forwarding rfc](./2025-08-11-channels-forwarding.md). +7. Redundant delivery by multiple relays, highlighting deduplicated messages differences - see [channels forwarding rfc](./2025-08-11-channels-forwarding.md). + +## Threat model + +**Single compromised chat relay / Colluding chat relays** + +can: +- effectively substitute group bar group ID and signed profile, by sending unsigned content from other group (or any arbitrary content), that doesn't require signature verification, such as regular messages. + - one way this could be further mitigated is requiring owner to sign all messages. + - owner could periodically sign message history as merkle dag. +- selectively drop any content or service messages from owner, including actions altering member roster. +- selectively drop messages for some of members. + +cannot: +- technically, redirect newly joining member to a different group. +- substitute group profile. +- impersonate owner, send any member message that requires signature. + +**Compromised chat relay (in situation where not all relays are compromised/colluding)** + +can: +- in case number of compromised relays is same as number of uncompromised ones, compromised relay(s) can drop messages or send arbitrary unsigned messages, misleading members from identifying which relays are compromised. +- ignore "message from channel" directive from owner, revealing which owner sent message. + - this can be revealed to owner by members out-of-band. +- fabricate new members, possibly inflating counts/costs for owner (depends on implementation). + - it can be identified that these imaginary members don't connect to other relays. + +**Member** + +can: +- infer which owner sent message as "message from channel", if group has a single owner. + - owner client should prohibit this option if group has a single owner. + +**Any client** + +can: +- connect to group unlimited number of times, inflating real counts/costs. + +## TODO list + +- Chat commands for creating group with relays. +- Protocol events processing. +- Recovery for both owner and relay when adding relay to group. +- On each subscription retrieve group link data for all groups, actualize connections for present relay links. +- Agent `prepareConnectionToJoin` api to return link that will be created. +- Asynchronous version of agent `setConnShortLink` api, correlation in chat. +- Agent to support adding relays to link (it has stub `relays :: [ConnShortLink 'CMContact]`). +- New connection entity for secondary member-in-relayed-group connection - priority/messages connections. +- Differentiate connection usage by priority in chat logic (receiving messages vs sending requests to relay). +- Finalize model - statuses, schema. +- UI for relay management (user level, similar to list of servers). +- UI for creating group with relays. +- UI for managing relays in group. +- Relay status updates events on adding relays for UI integration. +- Relay removal. +- Relay periodic checks for monitoring relay link presence. diff --git a/docs/rfcs/2025-10-23-vouchers.md b/docs/rfcs/2025-10-23-vouchers.md new file mode 100644 index 0000000000..9e11827282 --- /dev/null +++ b/docs/rfcs/2025-10-23-vouchers.md @@ -0,0 +1,208 @@ +# SimpleX Vouchers for Unlinkable Payments + +See [this doc](./2024-04-26-commercial-model.md) about commercial model that proposed the approach to making network sustainable and commercially attractive to the server operators. + +This document proposes the cryptographic design for the system of vouchers that can enable these payments. + +Big thank you to [Alain Brenzikofer](https://x.com/brenzi5), co-founder of [Integritee Network](https://x.com/integri_t_e_e), who contributed the draft of this design, which we then evolved collaboratively. + +## High-level diagram + +![Payments diagram](./diagrams/2025-10-23-vouchers-diagram.svg) + +### Coordination Layer (CL) + +Abstract component which allows all involved parties to come to consensus about voucher issuance and redemption + +* can be centralized trusted third party (TTP). +* can be a decentralized ledger with smart contracts, e.g. some L2 Ethereum blockchain with ZK-proofs support. + +### Issuing Operator (IO) + +* must be whitelisted by CL. +* CL defines voucher issuing limit. + +### Accepting Operator (AO) + +* delivers a service and accepts vouchers. + +### User + +* uses a service by an AO. +* seeks anonymity. + +### Voucher + +* token allowing limited number of transfers (0-2) to be redeemed for AO credits. +* comes in few fixed denominations around e.g. 1, 10, 100 operator credits, that would be initially set to USD 1, and adjusted for service costs that are likely to be reduced with scale and inflation. +* expected to be redeemed at low frequency: only every few days per user. + +### AO Credits + +* per-operator tokens for micropayments as-you-go. +* expected to be used to pay fractions of cents for every request to the service. +* balances maintained by the operator. + +Blind signatures to be used with operator issued credits: +- Client generates random token(s): `t[i]` +- Client sends a set of blinded tokens `blind(t[i])` when presenting a voucher. +- Operator's server signs them with operator's key and returns to the client. +- Client de-blinds them so they can be used. + +The signed tokens should include an approximate timestamp, e.g. rounded to a day (or more) - this would allow expiration of credits at the cost of acceptable reduction of anonymity set. + +These tokens would be fungible and would also have multiple denominations - the client would send new random blinded numbers to receive change on the resource provisioning requests. We can use token denominations representing powers of 2. + +When credit is presented it would be validated to prove that it is: + +1) properly signed. +2) not expired. +3) not used. + +The checks 1 and 2 are local, and can be done locally on the server. The check 3 requires verification across all operator's servers. The resource can be provisioned instantly, without waiting for the confirmation. Failed double-spend verification can result in resource cancellation. The "change" can be provided only after verification, as otherwise it may increase the number of issued credits (the provisioned resource can include "pending change" associated with it). + +Another approach would be allocating the registry for spent coins deterministically to different servers, and making these allocations known to the client, so while coins would be accepted by any operator's server, the change would be given faster if it's presented to the server with the coin registry. + +## Abstract Protocol + +Start with the most simple approach, then iterate to improve the anonymity properties. + +### v0.1: Chaumian eCash-style atomic, indivisible vouchers of single denomination + +not yet using ZK, not yet with expiry (see extension) + +``` +# user buys voucher at t1 +s = random(256 bits) +C = hash(S) +B = blind(C) +CoordinationLayer.checkIssuingLimit(issuer=I1) +App(issuer=I1).buyVoucher(ref=B) +# issuer I1 +ensure_payment() +σB = B.sign(K_I1) +# user publishes voucher at t2 +σC = σB.unblind() +CoordinationLayer.publish(σC) +# CoordinationLayer (global, trusted entity) +issuer = verify_signature(σC) +ensure_issuing_limit(issuer) +ensure_is_unknown(C) +store_unspent_voucher(C, issuer=I1) +# user redeems voucher at t3 +proof=encrypt(payload=[C, s], pubkey=CoordinationLayerKey) +ServiceProvider.redeem_voucher(proof) +# ServiceProvider SP1 +CoordinationLayer.redeem(proof, SP1) +# CoordinationLayer +[C, s] = decrypt(proof) +ensure(C=hash(s)) +atomic_invalidate_unspent_voucher(C) +clearing(1 voucher, I1 pays to SP1) +confirm_redemption(SP1) +``` + +### Unlinkability analysis + +* issuer can’t link the purchase to later redemption, not even if colluding with the ServiceProvider (assuming large number of users behaving indistinguishably). +* CoordinationLayer can trivially link timing and IP of publishing (t2) and redeeming C (t3). could collude with issuer to link redemption to purchase correlating timing and IP: + * the user can mask timing with random delays between t1-t2 to make collusion harder. + * the user can hide their IP from the CL if they use the issuer as a proxy through a TLS tunnel. That, in turn, will leak t2 to the issuer unless the user performs indistinguishable dummy requests to mask t2. + +### Adding Voucher Expiry + +Design choices for maximal anonymity set / unlinkability: + +* expiry is the same for all vouchers. +* expiry starts with the publishing step, not with the purchase. + +Extension of v0.1: + +* CoordinationLayer stores publishing date along with C. +* CoordinationLayer enforces expiry upon redemption. +* CoordinationLayer ensures issuers rotate keys every M days (to invalidate vouchers which have been issued but not published within 2xM days). + +*Alternative to allow expiry to start with purchase: blind signature with public metadata. Not trivial if issuer must verify public metadata and bind signature to ensure correctness of expiry*. + +## v0.2: Chaumian eCash-style atomic, Indivisible vouchers of single denomination plus ZK + +Avoid linkability of redemption by using a ZK set membership proof into merkle-mountain range (MMR). + +Change later steps as follows: + + +``` +... same as v0.1 +# CoordinationLayer (global) at ~t2 +... same as in v0.1, adding: +store_unspent_voucher(C, t=now, issuer=I1) +update_unspent_vouchers_mmr() +publish_mmr_root() +return [mmr_path] # to user +# user redeems the voucher at t3 +mmr_root = root of mmr_path # as received from CL upon publishing +N=hash2(s || "redeem") +proof=ZK( + secret_inputs: s, mmr_path + public_inputs: mmr_root, nullifier: N + assertions: hash(s) is leaf of mmr_path with mmr_root && N=hash2(s || "redeem") +) +ServiceProvider.redeem_voucher(proof) +# ServiceProvider SP1 +CoordinationLayer.redeem(proof, SP1) +# CoordinationLayer +ensure_unknown(proof.N) +ensure(age(proof.mmr_root) < EXPIRY) +verify(proof) +store_nullifier(proof.N) +clearing(1 voucher, I1 pays to SP1) +confirm_redemption(SP1) +``` + +(!) If the MMR is public (e.g. if the CL operating on a public ledger), the user can extend voucher expiry arbitrarily by updating their mmr_path to a newer merkle_root. Therefore, expiry can’t rely just on the age of mmr_root. For a mitigation, we need to extend the protocol and rotate MMRs. + +### Adding MMR Rotation + +1. Start a new MMR every T days +2. To mitigate the small anonymity set at the start of each new MMR, let them overlap and let the user choose which one they use. + +![MMR rotation](./diagrams/2025-10-23-vouchers-mmrs.svg) + +Upon publishing: + +* CL returns mmr1_path and mmr_2 path to the user + +Upon redemption: + +* user selects one of the two MMRs to generate the proof. Here, the user can trade off later expiry (mmr2_path, expiry2) against larger anonymity set (mmr1_path, expiry1). + +### Unlinkability Analysis + +* Generating a proof using mmr_root(t2) leaks t2. The CL could therefore still learn the exact time when the redeemed voucher was published + + * this can be mitigated by updated MMR peak-bagging before generating the proof. The user downloads the entire MMR and updates the mmr_path to a later root at e.g. t2' or t2'' (maybe partial download backward to t2 + a masking random bit further back is sufficient). If download size gets too big, reduce MMR duration T. + +* thanks to the ZK proof, now even the CoordinationLayer can’t directly link the publishing of C with the redemption, because the redemption just discloses that “one among all non-expired vouchers shall be redeemed“ (double-spending prevented through tracking nullifiers). +* the Coordination Layer still observes timing and IP address. + * users can wait until anonymity set is big enough for their requirements, but that only masks timing, not networking IP address. + * If we use the ServiceProvider as a proxy to forward the redemption proof, timing and IP leak to SP instead of CL, which is better because the SP learns the IP and timing (user behavior) anyway. trusting the SP with the proof is fine because it doesn’t disclose sensitive information and we trust them to provide their service after redemption anyway. + +### ZK Reasonings + +* to avoid trusted setup we could use STARK, not SNARK, but STARK has heavier proving complexity (expect >30s on mobile. should be evaluated with a PoC). +* we can accept a trusted setup with multiple independent parties contributing to it, with the benefit of much lighter proving. +* STARK friendly hash function: e.g. poseidon2 +* proving time (client-side) is probably still quite heavy for mobile, even if the proposed proof is pretty lean. But redeeming vouchers is only expected to happen infrequently +* verification time (CoordinationLayer side) expected to be light +* Nullifier set is bounded thanks to voucher expiry window M, so it won’t grow indefinitely. Downside: smaller anonymity set. + +Overall, SNARK seems more preferrable. + +### Possible Enhancements + +* Avoid centralized CoordinationLayer SPOF, replace with smart contract on distributed consortial ledger with non-collusion contractor validators: + * or even public permissionless blockchain. + * storing mmr_root and nullifiers onchain helps public auditability. +* publishing σC still leaks publicly observable timing because the CL has to update and publish the MMR. + * possible remedy: use TEE as a random-delay mixer proxy for the user to publish σC. +* optionally delegate heavy ZK proving to TEE for thin clients (s will be exposed to TEE trust assumptions). But then, we need to incentivize TEE-provers as they are service providers in their own right. diff --git a/docs/rfcs/2025-11-17-async-commands-acks.md b/docs/rfcs/2025-11-17-async-commands-acks.md new file mode 100644 index 0000000000..bb95c09717 --- /dev/null +++ b/docs/rfcs/2025-11-17-async-commands-acks.md @@ -0,0 +1,74 @@ +# Acknowledgments for async command responses + +## Problem + +Continuations for asynchronous commands can be forever lost if their execution fails, e.g. due to a crash. This can result in failures in establishing connections, sending post-connection auto-reply, etc. depending on other applications of asynchronous commands. + +## Solution + +An idea is to persist events in agent until chat acknowledges their processing, and replay them on next start of command processing. + +### Agent persistence + +Save response on command before notifying chat (event received by chat via subQ). + +```sql +ALTER TABLE commands ADD COLUMN event BLOB; +``` + +Type is `AEvent`, requires encoding for To and FromField instances. + +Application of chat continuations is very limited, so not all events need to be saved. In fact currently we only need 2 types of events to be recorded - see below. This breaks separation between chat and agent (agent knows which events to record), however that abstraction has long been violated. This can be a contract between agent and chat - which events to keep and acknowledge. + +TBC separate type for storing only necessary events: + +```haskell +data AEventDB where + ... -- only necessary constructors + +-- AEventDB encoding, instances + +toDBEvent :: AEvent -> AEventDB + +fromDBEvent :: AEventBD -> AEvent +``` + +Alternatively, we can save all events and require chat to acknowledge all events. This seems like an overkill and unnecessary work and generalization. + +### Agent event processing + +Currently agent deletes command records after processing. Instead it will: +- keep records until receiving acknowledgement on event; +- delete command record when receiving acknowledgment on event from chat; +- when retrieving next command to process filter out already processed commands (that have event saved); +- replay to chat unacknowledged events on starting async command processing (`resumeAllCommands`?). + +Same correlation id that is used for command can be used for acknowledging event. + +Agent API: + +```haskell +ackCommandEvent :: AgentClient -> UserId -> ACorrId -> AE () +``` + +### Command continuations + +Chat uses command continuations on following events: +- INV in group connection - XGrpMemIntro continuation (send XGrpMemInv with created connection link); +- JOINED in both direct and group (business chat) connections - send auto-reply. + +So it is enough for agent to record only INV and JOINED events, and for chat to acknowledge processing only for these events. However, as agent doesn't discriminate which INVs to save, chat should acknowledge all INVs. Another alternative is for chat to inform agent whether event should be kept when making command, e.g.: + +```haskell +createConnectionAsync :: AgentClient -> ... -> Bool -> ... + +-- Bool is flag whether to keep INV event for this command until acknowledged +``` + +Group relay protocol may add new continuations, for example for owner on adding relay link to group link (new async version of setConnShortLink api). + +Chat continuations should be idempotent. +- More important for INV event, to not repeatedly send XGrpMemIntro. +- For JOINED in worst case auto-reply would be re-sent which is not ideal but not very damaging. +- Chat can track additional state to help identify which part of event processing to replay. +- E.g. for group INV continuation chat can track that XGrpMemIntro was sent on group record. TBC per continuation. diff --git a/docs/rfcs/2025-11-24-member-relations-vector.md b/docs/rfcs/2025-11-24-member-relations-vector.md new file mode 100644 index 0000000000..087541fda3 --- /dev/null +++ b/docs/rfcs/2025-11-24-member-relations-vector.md @@ -0,0 +1,134 @@ +# Member relations vectors + +## Problem + +Maintaining member introduction records takes N^2 space. + +## Solution + +Migrate to member relations byte vector, with per member relation encoded by member index. + +Requires: + +1. Per group member index (Done). +2. Primitives to work with byte vector (Done). +3. Rework forwarding logic to use relations vector. +4. Rework introductions logic to use relations vector (avoid duplicate introductions). +5. Migration from introductions to vector. + +Migration is 2-stage: + +1. Live migration to accommodate large volume of introductions data, with admin client choosing mode of operation based on presence of relation vector for member. +2. Offline migration of remaining introduction records. Drop mode of operation based on introductions. + +### Forwarding + +When new invitee connects (CON) -> host makes introductions: + +1. For this invitee: set member relations to 'MRIntroduced' for respective members. _**(Take member lock)**_ +2. For pre-members: + - Member has vector: Set relation to 'MRIntroducedTo' for invitee member - N updates. _**(Take member locks/take group lock?)**_ + - No vector: Create introduction record (Transitional mode of operation based on introductions). + +When member reports XGrpMemCon ("connected with another member"), for both reporting and referenced members: + +1. Member has vector: Set relation to 'MRConnected'. _**(Take member lock)**_ +2. No vector: Update introduction record status (Transitional). + +When member sends message -> host forwards: + +1. Member has vector: Get recipients based on sender relations vector ('MRIntroduced' + 'MRIntroducedTo' members). +2. No vector: Get recipients based on introduction records (Transitional), set sender's vector. _**(Take member lock)**_ + - Compiled list of recipients to be marked as introduced; differentiate 'MRIntroduced'/'MRIntroducedTo'? (Complication of splitting introduced into 2 relations). + - Additionally get Connected members, currently they are filtered out as not requiring forward. (It is necessary to make a complete computation of vector in one go, as this member will then be skipped in background updates) + +#### Avoid duplicate forwards + +N updates approach allows us to avoid duplicate forwards: + +- Admin only forwards based on introductions embedded into relations vector: 'MRIntroduced', 'MRIntroducedTo'. + +- Admin doesn't forward to 'MRNew' members. + +Following diagram illustrates that in multi-admin scenario only host of "later" invitee (Bob) will forward messages between his and other admin's invitees. + +```mermaid +sequenceDiagram + participant A as Alice + participant B as Bob + participant C as Cath + +note over A, C: Alice invites and introduces Cath +A <<->> C: invite, CON +A ->> B: announce Cath +A ->> C: introduce Bob +note over A, C: Bob invites and introduces Dan +create participant D as Dan +B <<->> D: invite, CON +B ->> A: announce Dan +B ->> C: announce Dan +B ->> D: introduce Alice, Cath +note over A, B: Vectors (only Dan/Cath relation interests us
- we want to avoid duplicate forwards) +note left of A: Alice vectors
For Cath: Dan - MRNew
For Dan: Cath - MRNew +note right of B: Bob vectors
For Cath: Dan - MRIntroduced
For Dan: Cath - MRIntroducedTo +note over A, B: Only Bob forwards between Cath and Dan +C <<->> D: connect +C ->> B: x.grp.mem.con (connected to Dan) +D ->> B: or: x.grp.mem.con (connected to Cath)
(x.grp.mem.con from either is enough) +note right of B: Bob vectors
For Cath: Dan - MRConnected
For Dan: Cath - MRConnected +note over A, B: Bob stops forwarding between Cath and Dan +``` + +### Avoid duplicate introductions + +Scenario 1. Pending member is accepted to group -> avoid repeat introductions to moderators and above. + +Scenario 2. Two invitees connect to host concurrently -> avoid introductions race. + +Both can be solved by excluding already introduced members: +- Member (new invitee) has vector: filter out 'MRIntroduced', 'MRIntroducedTo' members from list of members to introduce. +- No vector: filter out based on introduction records (Transitional; `introduceToRemaining` + restore `checkInverseIntro` logic). + +### Live migration (Stage 1) + +Background process to set members' vectors based on introductions. + +Goes over members with NULL relation vector. Logic to determine relations is same as when setting sender's vector on forwarding. The latter is optimization -> faster migration of hot paths. _**(Take member locks)**_ + +TBC report when done - for directory service. Or we can track remaining member records with NULL vector. + +### Offline migration (Stage 2) + +TBC SQL to set relations vectors based on remaining introductions records. + +### Other considerations + +#### 1. Introductions race - missed introductions + +We may have identified race where some pairs of members may never become introduced to each other. It can occur if 2 hosts concurrently invite (announce) and introduce their respective invitees based to their respective local member lists. + +Consider such timeline: + + 1. Admin 1 invites Invitee 1. + + Invitee 1 connects to Admin 1 (CON). + + Admin 1 announces (x.grp.mem.new) Invitee 1 and introduces him to known members (Admin 1 hasn't seen Invitee 2). + + 2. Admin 2 invites Invitee 2. + + Invitee 2 connects to Admin 2 (CON). + + _Consider following scenario: Admin 2 hasn't received x.grp.mem.new for Invitee 1 from Admin 1._ Admin 2 announces (x.grp.mem.new) Invitee 2 and introduces him to known members (Admin 2 hasn't seen Invitee 1). + + 3. Both admins receive (with delay) opposite x.grp.mem.new -> both admins already made introductions before and consider opposite admin would introduce "new" member to their "older" invitee. + +This is status quo, this work will not improve it. + +We will revert change of admins making decision of introductions lists based purely on member index, which may have made such race more likely. Instead they will determine introductions lists as following: all current members minus already introduced members (see "Avoid duplicate introductions" section). + +#### 2. Double x.grp.mem.con notifications + +As alternative to N updates for introduced members, we considered redundant forwarding in multi-admin scenario and modifying user clients (2-stage release) to send x.grp.mem.con notifications to both own host and host of connected member. + +Not symmetrical: a "later" invitee doesn't know which member is the host of an "earlier" invitee. diff --git a/docs/rfcs/2025-12-10-vouchers-2.md b/docs/rfcs/2025-12-10-vouchers-2.md new file mode 100644 index 0000000000..60e80215dd --- /dev/null +++ b/docs/rfcs/2025-12-10-vouchers-2.md @@ -0,0 +1,98 @@ +# Community Vouchers contract + +This document simplifies [the previous RFC](./2025-10-23-vouchers.md) in two ways: +- only one type of credits (previous design had conversion from community to operator credits). +- vouchers can support any amount and change. +- using a single fixed-size Merkle tree for vouchers. +- proposal for balancing privacy and the size of the tree that the client has to load. + +## Objectives + +- voucher expiration - fixed period per contract. +- make assignment of voucher to redeemer (a community) private, not allowing further transactions/re-assignments. +- vouchers support any amount. +- allow assigning and redeeming part of the voucher, with change. + +## Design proposal + +### General ideas + +- Voucher is issued by the smart contract in exchange for stable-coin deposit, and recorded as blinded commitment added to Merkle tree. +- Voucher is nominated in infrastructure credits with 18 decimals, as all ETH tokens. +- Voucher assignments and redemptions (partial or full) are done via zero-knowledge proof and recorded as nullifier to prevent double spends, with the new commitment for the remaining amount and transaction count (can be 0). +- Commitment includes: + - the amount. + - the whether the voucher is assigned. + - the expiration time. +- Merkle tree of commitments has a fixed depth of say 20-40 levels (for approx 10^6-10^12 voucher capacity). +- Assignments and redemptions should prove consistency between the old and new commitments, without revealing them, and include the nullifier to prevent "double spends". +- Redemptions also include the blockchain address and ID of the operator. +- Release of funds with revenue sharing can be done via call to another contract. + +### Data storage + +Smart contract: +- total deposit amounts bucketed by time ranges (to allow the release of funds to network after expiration). +- redemption amounts bucketed by time ranges with homomorphic encryption (to prevent reduction of anonymity set). +- nullifiers bucketed by creation time (to allow removing old nullifiers). +- the path in the Merkle tree on "the front" of the filled part of the tree. +- multiple last Merkle tree roots: + - to accept the proof after the root changes. + - to frustrate timing correlation by computing proofs in advance (for better usability). + +Transaction event log: +- Merkle tree of commitments are recorded in event log + +### Voucher lifecycle + +1. Issuance: blinded commitment recorded as described above in exchange for stablecoin deposit. + +The contract should be able to verify that: +- commitment amount is the same as deposit amount. +- expiration time is in 12 months from now (possibly rounded up to 1-3 months). +- not assigned to redeemer. + +Initially, the deposit UX could be dApp generating some token to be used in the app to assign voucher to the community via app (to preserve privacy). + +2. Computing zero-knowledge proof for assignment or redemption. + +To compute the proof the client needs to have a path from commitment to tree root. + +As the path to commitment changes when the tree gets filled, we need to find a balance between privacy and usability - to request as little as possible and have as large anonymity set as possible. + + +3. Assignment. + +Includes 2 new commitments (to destination and a "change") and nullifier are submitted. + +ZK proof should prove that: +- the old commitment expiration time is greater than transaction submission time (possibly has to be included in parameters to be part of the proof). +- the total amount of 2 new commitments is the same as the amount of nullified old commitment. +- the new commitments expiration time is the same as the old. +- the old commitment is not assigned to redeemer. +- the new commitments is assigned to redeemer. + +Questions: +1) how would the redeemer know that it received a voucher? Probably, community owners would be notified by chat relays. +2) do we want to conceal from the relays when and to which group donation was made? If yes, community owners can monitor blockchain themselves. +3) can we have view keys to delegate the detection of incoming transactions to relays? +4) should we round expiration time to a month or even 3 months? (to avoid exposing expiration time to the assignment recipient, as it also leaks purchase time) If so, we should also have overlapping rounding ranges to avoid leaking purchase time in case it is assigned straight after purchase, and straight after range change. + + +4. Redemption. + +Includes one new commitment (with the change) and public record of funds released to operator and network. + +The revenues received by the operators are publicly visible. + +ZK proof should prove that: +- the old commitment expiration time is greater than transaction submission time. +- the total amount of the new commitment and of released funds is the same as the amount of nullified commitment. +- the new commitment expiration time is the same as the old. +- the old commitment is assigned to the redeemer that redeems the voucher. +- the new commitment has the same assignment. + + +5. Releasing deposits for expired vouchers to the network. + +That would require tracking purchases in buckets (in the clear) and redemptions in homomorphicly encrypted structure, so an observer cannot correlate redemptions to purchases (as the same structure will change, and it won't be possible to establish which bucket). diff --git a/docs/rfcs/2025-12-17-community-vouchers-faq.md b/docs/rfcs/2025-12-17-community-vouchers-faq.md new file mode 100644 index 0000000000..06a6afd169 --- /dev/null +++ b/docs/rfcs/2025-12-17-community-vouchers-faq.md @@ -0,0 +1,144 @@ +# Community Vouchers FAQ + +This is an unabridged version of FAQ previously published at https://simplex.chat/vouchers. + +- [Why Community Vouchers?](#why-community-vouchers) +- [Free Tier?](#free-tier) +- [How Will Vouchers Work?](#how-will-vouchers-work) +- [Will self-hosted servers still be supported by SimpleX network?](#will-self-hosted-servers-still-be-supported-by-simplex-network) +- [What problems Community Vouchers solve that other payment methods can't?](#what-problems-community-vouchers-solve-that-other-payment-methods-cant) +- [How is it possible to provide privacy on public blockchain?](#how-is-it-possible-to-provide-privacy-on-public-blockchain) +- [Will Community Vouchers be pre-sold via private or public sale?](#will-community-vouchers-be-pre-sold-via-private-or-public-sale) +- [Who will sell vouchers?](#who-will-sell-vouchers) +- [How the server operator revenue share is determined?](#how-the-server-operator-revenue-share-is-determined) +- [Who will control and upgrade smart contracts?](#who-will-control-and-upgrade-smart-contracts) +- [Will I be able to sell or transfer Community Vouchers to other people?](#will-i-be-able-to-sell-or-transfer-community-vouchers-to-other-people) +- [Why Not Existing Crypto?](#why-not-existing-crypto) +- [Why build on Ethereum blockchain?](#why-build-on-ethereum-blockchain) +- [Have you considered other blockchains?](#have-you-considered-other-blockchains) +- [Which token specification do you plan to use?](#which-token-specification-do-you-plan-to-use) +- [If you build on another blockchain, how the NFT will be used to provide access?](#if-you-build-on-another-blockchain-how-the-nft-will-be-used-to-provide-access) + +### Why Community Vouchers? + + + + + +To cover server costs securely and privately. + +With "free" centralized platforms: +- you lose security and privacy, because your data is used for advertising and sold. +- they de-platform inconvenient users, often based on frivolous complaints. +- you don't own all rights to your content. + +Paying for server capacity may be cheaper than "free" platforms. Our estimates based on the current costs are $5-10/month for 5,000 active message receivers (could be up to 50,000 listed community members) with 5-10 GB of files/media archive. These estimates are preliminary and may change. + +### Free Tier? + +It will be determined after testing. Preliminarily, we expect up to 1,000 active message receivers (can be up to 10,000 listed members) and 500 MB storage to be available for free groups. + +"Active message recipient" in this model is a group member who periodically connects to the network, and receives group messages. Members who are listed but don't open the group for some time, for example two weeks, will stop receiving all group messages even when they are connected to the network. This is an evolving design that will balance security for group members and owners, to avoid inflated expenses, and to present realistic membership statistics to the group owners and to prospective members. + +Private messaging with contacts and in private groups within "fair use" limits that we apply today will remain free: +- there can be up to 128 undelivered messages per destination, +- the undelivered messages are stored up to 21 days, +- files up to 1 GB can be sent for free, +- files are available for download for up to 2 days. + +Larger limits may be offered in paid tier, but it is not planned initially — the focus of Community Vouchers is to create a commercial model for communities. + +### How Will Vouchers Work? + +Buy via app (like phone top-ups), with unused capacity shown in the app. An important design goal is to make Community Vouchers available to people who don't use any cryptocurrencies. + +Testnet is likely to use hashed IDs for privacy and on-chain payments, to validate the pricing and economic model. Zero-knowledge proofs and in-app payments will be added by the time production network is launched. + +### Will self-hosted servers still be supported by SimpleX network? + +Yes, absolutely. Not only will the apps continue to support self-hosted servers, but we will improve it. We see network decentralization and server portability as very important, and while we need to develop a robust commercial model for the servers, we still need community-hosted servers to function, with all people using a single network: +- users who use self-hosted servers will be able to join groups that use pre-configured servers or Community Vouchers. +- users who use pre-configured servers will be able to join groups that are run on free community-hosted servers, same as today. +- you will be able to create new server operators (collections of messaging and file servers operated by one entity), so that the features currently available only for preset servers will be available to all servers very soon, and all servers that you and your community members want to use can be added to the app by scanning a QR code. + +### What problems Community Vouchers solve that other payment methods can't? + +Community Vouchers implemented via smart contracts on blockchain solve these problems: +- unlinkability of the voucher purchase and usage - to confirm ownership smart contracts will use zero-knowledge proofs, rather than visible transfers between blockchain addresses. While any blockchain observers may see that a given address purchased a voucher, they will not see how vouchers are used. +- server operators cannot fail to provide infrastructure — the funds will be locked in a smart contract until it is provided. +- codify the agreement about how revenue is shared between server operator and the network, so that it depends not on trust, but on cryptography. + +### How is it possible to provide privacy on public blockchain? + +In the same way it is possible to provide private communications on the public Internet, as SimpleX network does. + +Our commitment to users' privacy and security remains as strong as ever, and we plan to bring the practical expertise of building private communication protocols over the last 5 years to how we develop the technology for the blockchain. + +While specific designs are in early stages, here are some of the principles that we will follow to ensure privacy: +- each voucher purchase will be associated with a new blockchain address. There will be no per-user addresses, as wallets use. So the cornerstone of SimpleX network design - no user profile IDs - will be followed for blockchain development as well. +- all operations on blockchain will be supported by network servers that will run full blockchain nodes. For important requests, such as name resolution, the clients will use 2 or 3 independent servers, to ensure protection from MITM attacks. +- blockchain operations will be proxied, in the same way as it happens with private message routing. + +We will be publishing the whitepaper about this design. It will provide an unprecedented level of security and privacy for blockchain applications, irrespective of which chain we choose to use. + +### Will Community Vouchers be pre-sold via private or public sale? + +There will be no Community Vouchers pre-sold or in any other way made available to the team, or to investors or to the public. + +Any blockchain token that is pre-sold to raise funds to develop technology is not a utility token, regardless of how it's named — it becomes an investment contract that passes [Howey test](https://www.investopedia.com/terms/h/howey-test.asp). + +This is not what we are doing. Community Vouchers are restricted utility tokens, not an investment contract. They will be only issued on demand to people who want to pay for network servers, at a fixed price. + +### Who will sell vouchers? + +Initially, Community Vouchers will be sold via a smart contract in exchange for some other tradeable tokens, most likely stablecoins. We don't plan any token emission, or any public or private pre-sales. And we won't have access to the funds from voucher sales — they will be locked in a smart contract, and only released once servers have provided capacity to the users, with the funds shared between server operators and the SimpleX network, with operators receiving up to 60%, depending on trust evaluation. The SimpleX network funds will be managed by smart contracts, and will be used for governance and development as defined by the contracts. Their price will be fixed based on server costs, with the exact economic model developed during the testing phase. + +### How the server operator revenue share is determined? + +It will be based on the goal that servers must provide both reliability and security to the users. Security in any multi-node network depends on users' ability to choose independent servers that are provided by different entities, and the apps are already programmed not just to use different servers for the message delivery path, but to use servers of different operators. Even though currently there are only 2 preset operators — SimpleX Chat and Flux — and all servers added to the app by the user are considered a third operator, it substantially improves privacy and security. + +In the future, the operators that confirmed their identity to the network will receive a much higher revenue share than anonymous ones. We believe that for users to be private and secure, operators must be known, and must accept legally binding terms of operation, same as preset operators do today. + +The other two factors that will affect "trust evaluation" will be how long the operator was available on the network and servers' availability uptime. Similar to how we monitor the uptime of our servers, the network will monitor the uptime of all servers, and it will affect the revenue share. + +We don't have an exact model for revenue sharing yet; it will be determined during testing and will evolve based on feedback from users and server operators. + +### Who will control and upgrade smart contracts? + +Community Vouchers will require several smart contracts for their functioning. During testing and development, SimpleX Chat will maintain and update all contracts. Once the network is ready for production, some critical contracts (e.g., those that control the funds) will be immutable, requiring a lot of testing and a security audit, and some less critical contracts will still be upgradeable based on a consensus model (e.g., multisig or voting). + +It is always a journey from knowing that something is possible to knowing how exactly it will be done, and we are at the early stage of knowing it is possible. Specific designs would evolve, based on the input from legal and blockchain experts, and from the community — as everything else we develop for SimpleX network. + +### Will I be able to sell or transfer Community Vouchers to other people? + +Possibly, but with limits on the number of transactions and the time of holding. + +Community Vouchers are designed with a single purpose — to facilitate payments for servers' capacity in a way that protects users' security. Smart contracts implementing them will restrict or completely prohibit trading. The specific parameters will be determined during design evolution and testing. + +### Why Not Existing Crypto? + +Existing cryptocurrencies do not allow the implementation of the required model for Community Vouchers. The price of cryptocurrencies is determined speculatively, and not based on costs. The fact that they can be freely traded and transferred exposes existing cryptocurrencies and tokens to financial regulations. + +The existing cryptocurrencies such as XMR, BTC and some others will be accepted as payment for Community Vouchers, via bridges, but they cannot be used in the foundation of the system, because they are not as flexible as smart contracts, and cannot directly support the model we are developing. + +### Why build on Ethereum blockchain? + +Many people dislike Ethereum for its high energy usage and high transaction costs in the past. Also, blockchain transactions cannot provide privacy, can they? Why not use Monero (XMR) instead? + +This was our assessment as well in the past. But the last three years changed it, addressing energy usage and transaction costs, and we've seen the growth of several L2 Ethereum blockchains. What made us decide that EVM-based blockchain is the best choice for the current stage is the planned rollout of zkEVM in 2025 with native support for zero-knowledge proofs. + +[Our early ideas about Community Vouchers](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2024-04-26-commercial-model.md) and [the most recent design](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2025-10-23-vouchers.md) rely on zero-knowledge proofs, and as it will be natively supported, EVM blockchains provide a much better foundation to build Community Vouchers than building them from scratch — there is no need to re-invent solutions to problems that are already solved. + +### Have you considered other blockchains? + +We are actively considering which blockchain to build on. Ethereum ecosystem is the most widely adopted, and has very mature systems and tools, and it appears sufficient, but it has its downsides, as does everything. So we are not yet committed to Ethereum. + +### Which token specification do you plan to use? + +Even though these are not freely tradable tokens, we will likely make them compatible with [ERC20 token specification](https://eips.ethereum.org/EIPS/eip-20). It is very simple, one of the earliest, and the most adopted standard on EVM blockchain. It defines tokens, but they don't have to be freely tradeable — the specification allows any extensions and restrictions implemented on top of it. + +Using this specification would make Community Vouchers partially compatible with wallets and chain explorers, making testing, development, and early adoption easier. + +### If you build on another blockchain, how the NFT will be used to provide access? + +We can take into account the list of addresses that hold NFTs and provide access to testnet on any blockchain via a cryptographic signature. That is the reason the NFT is deployed on Ethereum mainnet and not on some of L2 chains. We don't yet know at this stage which L2 testnet will be used. diff --git a/docs/rfcs/2026-01-08-relays-new-member-connection.md b/docs/rfcs/2026-01-08-relays-new-member-connection.md new file mode 100644 index 0000000000..471f4ed53f --- /dev/null +++ b/docs/rfcs/2026-01-08-relays-new-member-connection.md @@ -0,0 +1,99 @@ +# Connection of new member to chat relays + +## Problem + +Naive implementation of new member connection to chat relays can lead to partial failures (some relays fail to connect), or requires recovery or clean up. + +After group record is prepared from short link, naive flow is as follows (APIConnectPreparedGroup): + +``` +User clicks "Connect" + -> Fetch relay links from group link (sync getConnShortLink) + -> For each relay: + -> Fetch ConnectionRequestUri from relay link (sync getConnShortLink) + -> Join connection (sync joinConnection) +``` + +Orthogonal smaller problem: + +If new member chooses to connect to group incognito, same incognito profile should be sent to all group relays. + +## Solution + +### Join Connection step + +"Join connection" is the main step, let's consider it first. + +#### Option 1: Synchronous approach with catches + recovery + +Keep all relay connections synchronous, catch on failure to continue for remaining relays, recovery for failed relays. All relays failing would mean full command failure, offer user retry. + +For partial failures it would require to track which relays succeeded/failed, then trigger recovery, basically recreating what asynchronous command processing already does. + +#### Option 2: First relay sync, then async + +Connect to first relay synchronously, connect to remaining asynchronously (using joinConnectionAsync). + +Choice of "first" relay is arbitrary and we may be choosing the one with worse network. + +Mixed (double) implementation - for "first" and remaining relays. + +#### Option 3: All relays async + +In this case agent already handles connection reliability, downside is no immediate failure visible to user on temporary network errors for all relays (for example, client is offline). + +UI already handles "connecting..." state, so async path doesn't hurt UX much other than in mentioned case. UI stays in "connecting..." until at least one relay connection succeeds. + +If all relay connections permanently fail, update state for UI - requires permanent error handling for connection creation on continuation (agent responses in Subscriber). Track relay connection states to detect "all failed", possibly on connection status, TBC at implementation. + +Pros: +- Simple flow: loop through relays, start async connections. +- Async agent commands provide recovery. + +### Link fetches + +We considered handling retries for Join step, but no retry mechanism for link fetch. If it's synchronous and fails for a given relay, it would result in permanent failure to connect to relay, without additional recovery logic. + +#### Option 1: Asynchronous command with continuation + +New agent asynchronous command + complexity in chat Subscriber logic. Seems overkill. + +#### Option 2: Per-relay "relay connection" worker + +An additional state machine, possibly based on relay member records as work items. Also overkill. + +#### Option 3: Make all link fetches synchronously before proceeding + +To avoid adding background recovery mechanisms for link fetching per relay, we could fetch all links data synchronously, and only then connect to relays asynchronously. + +In case any relay link fetch fails, user would be given option to retry. (Whole operation fails and is retried) + +Group link fetch is also synchronous (retrieve list of relay links), and also leads to immediate user retry. + +### On the incognito profile issue + +This should be addressed regardless of which approach to connection we choose. The incognito profile should be: + +1. Created once before starting any relay connections; +2. Passed to all relays on connection attempts. + +In case of synchronous approach and re-use of existing logic, it means `connectViaContact` should accept an optional profile (not just flag). + +### Overall proposed connection flow + +``` +User clicks "Connect" + -> Fetch relay links from group link (sync getConnShortLink) + -> For each relay: Fetch ConnectionRequestUri from relay link (sync getConnShortLink) + -> Once all links are resolved, proceed - create incognito profile ONCE for all relays, if needed + -> For each relay: Start async connection attempt (joinConnectionAsync) + -> Agent handles connection retries internally + -> Subscriber handles JOINED events and errors for each relay + - At least one relay JOINED -> group becomes functional + - All relays permanently fail -> show failure to user +``` + +Link fetches being synchronous in conjunction with asynchronous relay connections allows for similar UI reactivity to current single-connection flows: +- Network failures during link fetches require user retry; +- Connection attempts are retried by agent on network failures; +- Link fetches passing ensures client is not offline when starting async connection attempts (unless user goes offline in-between, but window is very small, and connections would be retried anyway). diff --git a/docs/rfcs/2026-01-23-member-keys-plan.md b/docs/rfcs/2026-01-23-member-keys-plan.md new file mode 100644 index 0000000000..b278cf0c32 --- /dev/null +++ b/docs/rfcs/2026-01-23-member-keys-plan.md @@ -0,0 +1,652 @@ +# Implementation Plan: Member Keys and Signatures for Simplex Chat + +## Overview + +Add cryptographic signatures to Simplex Chat messages to prevent relay impersonation and roster manipulation in public groups with chat relays. + +## Design Approach + +Following **RFC Option 2: Multi-stage encoding** (recommended in docs/rfcs/2025-04-14-signing-messages.md): +- Encoded JSON body (non-deterministic key ordering OK) +- Conversation binding (group root key + sender member ID for groups) +- Array of (key reference, signature) tuples + +## Key Files to Modify + +### Core Types +- `src/Simplex/Chat/Types.hs` - Add `MemberKey` type, add `memberKey` to `MemberInfo` +- `src/Simplex/Chat/Protocol.hs` - Add member keys to `XMember`, `XGrpLinkMem`; signed message envelope, encoding/decoding + +### Protocol Handling +- `src/Simplex/Chat/Library/Commands.hs` - Sign messages when sending +- `src/Simplex/Chat/Library/Subscriber.hs` - Verify signatures when receiving +- `src/Simplex/Chat/Library/Internal.hs` - Chat-level signature utilities (working with Member profiles, messages) + +### Agent API (simplexmq repo) - New Functions +- `../simplexmq/src/Simplex/Messaging/Agent.hs`: + - `prepareConnectionLink` - NEW: commits to server, generates link address + root key locally (no network) + - `createConnectionWithPreparedLink` - NEW: accepts server + root key, creates queue (single network call) +- `../simplexmq/src/Simplex/Messaging/Agent/Client.hs` - Implement new functions + +### Database +- New migration: `src/Simplex/Chat/Store/SQLite/Migrations/M20260124_member_keys.hs` +- New migration: `src/Simplex/Chat/Store/Postgres/Migrations/M20260124_member_keys.hs` +- `src/Simplex/Chat/Store/Profiles.hs` - Store/retrieve member keys + +## New Types + +### 1. Member Key Type (Types.hs) + +```haskell +newtype MemberKey = MemberKey C.PublicKeyEd25519 + deriving (Eq, Show) + +-- IMPORTANT: memberKey is NOT in Profile - profiles can be updated independently +-- Member keys are fixed at join time and sent via member announcement messages + +-- Add memberKey to MemberInfo (used in XGrpMemNew, XGrpMemIntro, XGrpMemFwd) +data MemberInfo = MemberInfo + { memberId :: MemberId, + memberRole :: GroupMemberRole, + v :: Maybe ChatVersionRange, + profile :: Profile, + memberKey :: Maybe MemberKey -- NEW: member's signing key + } + deriving (Eq, Show) +``` + +### 2. Protocol Messages with Member Keys (Protocol.hs) + +Member keys are communicated via member identification/announcement messages, NOT profile updates: + +```haskell +-- Member self-identification when joining group +-- newMemberKey is required (not Maybe) - every new member must have a key +XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json + +-- Member joining via group link +XGrpLinkMem :: Profile -> Maybe MemberKey -> ChatMsgEvent 'Json + +-- Member announcements use MemberInfo which now includes memberKey +-- XGrpMemNew, XGrpMemIntro, XGrpMemFwd all use MemberInfo + +-- Profile updates do NOT include memberKey - key is fixed at join time +XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json -- unchanged +``` + +**Key points:** +- `XMember.newMemberKey` is required (not Maybe) - joining member must provide key +- `XGrpLinkMem` has `Maybe MemberKey` for backward compatibility +- `MemberInfo.memberKey` is `Maybe` for backward compatibility with existing members +- Profile updates (`XGrpMemInfo`) don't include key - it's fixed at join time + +### 3. Member Key Storage + +- Private key stored in `groups.member_priv_key` (current user's signing key for this group) +- Public key stored in `group_members.member_pub_key` (for all members) +- NOT stored in profiles table - member keys are per-group, not per-profile + +### 4. Signed Message Types (Protocol.hs) + +Types as implemented in Protocol.hs: + +```haskell +-- Key reference tag — indicates which key to use for verification. +-- KRMember means "use the contextual member's key" (sender or forwarded author). +-- Can be extended to support profile identity keys (e.g., secp256k1 for Nostr). +data KeyRef = KRMember + deriving (Eq, Show) + +-- Conversation binding for signature scope +data ChatBinding + = CBDirect {securityCode :: ByteString} + | CBGroup {groupRootKey :: C.PublicKeyEd25519, senderMemberId :: MemberId} + deriving (Eq, Show) + +-- Signature with key reference +data MsgSignature = MsgSignature KeyRef C.ASignature + deriving (Show) + +-- Signatures with chat binding +data MsgSignatures = MsgSignatures + { chatBinding :: ChatBinding, + signatures :: NonEmpty MsgSignature + } + +-- Field order matches wire format: forward data (> prefix), then sig data (/ prefix), then message ({ prefix) +data ParsedMsg = ParsedMsg (Maybe MsgForwardData) (Maybe MsgSigData) AChatMessage + +data MsgSigData = MsgSigData + { signatures :: MsgSignatures, + signedBody :: ByteString -- exact bytes that were signed + } + +data MsgForwardData = MsgForwardData + { fwdMemberId :: MemberId, + fwdMemberName :: ContactName, -- may be empty + fwdBrokerTs :: UTCTime + } +``` + +**Key insight:** The binary batch format preserves the exact bytes of each element via length-prefix framing, enabling signature verification even after the message has been parsed. This is critical for forwarded messages. + +### 5. Key Resolution and Validation + +```haskell +-- Key resolution: lookup member's public key from GroupMember record +resolveKeyRef :: GroupInfo -> KeyRef -> Either String C.APublicVerifyKey +resolveKeyRef gInfo (KRMember mid) = + case findMemberByMemberId mid gInfo >>= memberKey of + Just (MemberKey k) -> Right $ C.APublicVerifyKey C.SEd25519 k + Nothing -> Left $ "unknown member key: " <> show mid + +-- findMemberByMemberId looks up GroupMember by MemberId in GroupInfo +-- memberKey is stored in GroupMember record (from group_members.member_pub_key) + +-- Owner validation: verify member's key matches OwnerAuth chain +-- Called when processing roster-modifying messages from owners +validateOwnerMember :: GroupInfo -> MemberId -> MemberKey -> Either String () +validateOwnerMember gInfo memberId memberKey = do + case findOwnerAuth memberId (groupOwners gInfo) of + Nothing -> Left "member is not an owner" + Just OwnerAuth {ownerId, ownerKey} -> do + when (ownerId /= memberId) $ + Left "owner ID mismatch" + case memberKey of + MemberKey k | k == ownerKey -> Right () + _ -> Left "owner key doesn't match member key" +``` + +### Owner Verification Strategy (future multi-owner support) + +**Question:** How to validate that a member is a legitimate owner? + +**Option A: Request link data from server** +- Fetch current `UserContactData.owners` from SMP server +- Expensive: network roundtrip for each verification + +**Option B: Store OwnerAuth chain locally, verify via signatures** ✓ +- When joining group: receive OwnerAuth chain (from link data or group info) +- When new owner added: receive signed OwnerAuth (signed by root or existing owner) +- Verify locally using signature chain - no network needed +- Store chain in `group_owners` table + +**Current implementation (single owner):** +- Group creator is sole owner +- OwnerAuth created at group creation, stored in link data +- Members receive owner info when joining +- No multi-owner support yet (deferred) + +### 6. Message Batching Analysis + +Analysis of current batching behavior (determines new format requirements): + +**Q1: Can there be multiple compressed parts in one wire message?** + +**NO** - only ONE compressed block is ever created. +- `compressedBatchMsgBody_` (Protocol.hs:712) creates singleton list: `(L.:| []) . compress1` +- Called only in Internal.hs:1901 (connection info) and Internal.hs:1941 (message body) +- Decoder supports `NonEmpty Compressed` for forward compatibility, but encoding always produces 1 block + +**Q2: Can messages from multiple members be batched together?** + +**YES** - in both relay and non-relay groups: +- Relay groups: Delivery.hs:168-184 - `getNextDeliveryTasks` does NOT filter by sender +- Non-relay groups: `sendHistory` (Internal.hs:1171-1184) batches history items from multiple senders + +**Q3: Can forwarded and non-forwarded messages be batched together?** + +**YES** - in `sendHistory` (Internal.hs:1176-1184): +- `XMsgNew` (welcome/description) appended to `XGrpMsgForward` events +- Both sent together via `sendGroupMemberMessages` + +### 7. Wire Format (Protocol.hs) + +#### Current Format (JSON-based batching) + +```abnf +; Current wire format +wireMessage = compressedMsg / jsonMsg +compressedMsg = %s"X" compressedBlock ; single compressed block +jsonMsg = singleJson / jsonArray +singleJson = %s"{" *OCTET ; single JSON object +jsonArray = %s"[" *OCTET ; JSON array of messages +``` + +JSON array batching uses `[msg1,msg2,...]` format - simple but cannot preserve exact bytes for signatures. + +#### New Format (Binary batching for signatures) + +For relay-based groups where signatures are required, use binary batching that preserves exact message bytes: + +```abnf +; Extended wire format (parser accepts all formats) +wireMessage = compressedMsg / binaryBatch / jsonMsg + +; New binary batch format - preserves exact bytes for signature verification +binaryBatch = %s"=" elementCount *batchElement +elementCount = 1*1 OCTET ; 1-255 elements +batchElement = elementLen elementBody +elementLen = 2*2 OCTET ; 16-bit big-endian length +elementBody = signedElement / forwardElement / plainElement + +; Signed element - signatures followed by JSON body +signedElement = %s"/" msgSignatures jsonBody +jsonBody = *OCTET ; JSON bytes (length from elementLen) + +; Forward element - relay forwarding with preserved bytes (relay groups only) +; originalBytes is a nested element (signed or plain, but NOT another forward) +forwardElement = %s">" forwardMeta originalElement +forwardMeta = senderMemberId senderMemberName brokerTs +brokerTs = 8*8 OCTET ; UTC timestamp, big-endian microseconds +originalElement = signedElement / plainElement + +; Plain message element - starts with '{' (JSON object) +plainElement = jsonBody + +; Signature data (no '/' prefix — the element prefix serves that role) +msgSignatures = chatBinding sigCount *msgSignature +chatBinding = directBinding / groupBinding +directBinding = %s"D" securityCode +securityCode = shortString +groupBinding = %s"G" groupRootKey senderMemberId +groupRootKey = 32*32 OCTET ; Ed25519 public key +senderMemberId = shortString + +sigCount = 1*1 OCTET ; 1-255 signatures +msgSignature = keyRef sigBytes +keyRef = memberKeyRef +memberKeyRef = %s"M" ; use contextual member's key (sender or forwarded author) +sigBytes = 64*64 OCTET ; Ed25519 signature + +shortString = length *OCTET +length = 1*1 OCTET + +; Compressed format unchanged - compression wraps the batch +compressedMsg = %s"X" compressedBlock +; After decompression: binaryBatch / jsonMsg +``` + +**Overhead comparison:** +- JSON array: `[` + `]` + `,` between = n+1 bytes for n elements +- Binary batch: `=` + count + 2-byte length per element = 1 + 1 + 2n = 2 + 2n bytes +- Difference: ~1 extra byte per element - acceptable for signature support + +**Format selection:** +- Relay-based groups: Use binary batch (`=` prefix) - preserves bytes for signatures +- Non-relay groups: Use JSON array (`[...]`) - backward compatible, no signatures needed +- Old groups with old members: Use JSON array - full backward compatibility + +**Parser behavior (`parseChatMessages`):** +- `'='` prefix → binary batch (new format) +- `'{'` prefix → single JSON object +- `'['` prefix → JSON array +- `'X'` prefix → compressed (decompress, then re-parse) +- All formats accepted regardless of version for forward/backward compatibility + +**Batcher behavior (`Messages/Batch.hs`):** +- Accept `BatchMode` parameter: `BMJson` or `BMBinary` +- `BMJson`: Current JSON array encoding +- `BMBinary`: Binary format with length prefixes, preserves exact bytes + +```haskell +data BatchMode = BMJson | BMBinary + +batchMessages :: BatchMode -> Int -> [Either ChatError SndMessage] -> [Either ChatError MsgBatch] +-- batchDeliveryTasks1 hardcodes BMBinary (relay groups only) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) +``` + +**Key insight:** The binary batch format allows: +1. Each element's exact bytes preserved (length-prefixed, not re-encoded) +2. Mixed signed/unsigned elements in same batch +3. Forwarded messages preserve original sender's signature +4. Relay adds no signature - just wraps in forwarding envelope + +**Forwarding in binary batch (relay groups):** + +For relay-based groups, forwarding is NOT via `XGrpMsgForward` ChatMsgEvent (which would re-encode the inner message). Instead, forwarding uses a **binary batch element format** (`forwardElement` in the ABNF above) that preserves exact bytes: + +```abnf +; Forward element details (defined in batchElement above) +forwardElement = %s">" forwardMeta originalBytes +forwardMeta = senderMemberId senderMemberName brokerTs +senderMemberId = shortString +senderMemberName = shortString ; may be empty +brokerTs = 8*8 OCTET ; UTC timestamp, big-endian microseconds +originalBytes = *OCTET ; original signed message bytes (verbatim) +``` + +Forward elements only appear inside binary batches — there is no standalone forward envelope at the wire level. + +**Flow:** + +1. **Sender** creates signed message: + ``` + /<{"event":"x.msg.new",...}> + ``` + +2. **Relay** receives, parses to validate, stores original bytes in `msg_body` + +3. **Relay** forwards as binary batch element(s): + ``` + =( ">" )* + ``` + +4. **Recipient** parses binary batch, extracts `originalBytes` from forward elements, verifies sender's signature + +**Key difference from current approach:** +- Current: `XGrpMsgForward` nests **parsed** `ChatMessage 'Json` → re-encoded on send → bytes change +- New: Forward element contains **original element bytes** (`/` or `{`) → never re-encoded → signature remains valid +- Forward nesting is guarded: `elementP` rejects nested forward elements (`>` inside `>`) + +**Backward compatibility:** +- Old groups (non-relay): Continue using `XGrpMsgForward` ChatMsgEvent (JSON array batching) +- New relay groups: Use binary batch with forward elements (`>` prefix inside `=` batch) +- `XGrpMsgForward` JSON call site passes `Nothing` for `msgSig_` (no signature data available in JSON path) +- Parser accepts both formats + +**Key resolution:** +- `'M'` (member key ref): Use the contextual member's public key from `group_members.member_pub_key` — the sender (direct messages) or forwarded author (forward elements) + +## Messages Requiring Signatures + +### Owner/Admin Signatures (roster changes) +- `XGrpRelayInv` - Owner inviting relay (relay validates) +- `XGrpMemNew` - Adding new member +- `XGrpMemRole` - Changing member role +- `XGrpMemDel` - Removing member +- `XGrpInfo` - Updating group profile +- `XGrpPrefs` - Updating group preferences +- `XGrpDel` - Deleting group + +### Content messages — NOT signed +- `XMsgNew` and other content messages are not signed to preserve deniability. Relay manipulation of content is detectable post-hoc via cross-relay consistency. + +## Database Migration + +```sql +-- SQLite migration M20260124_member_keys.hs + +-- Group-level keys (current user's keys for this group) +ALTER TABLE groups ADD COLUMN shared_group_id BLOB; -- saved in link fixed data as entity ID +ALTER TABLE groups ADD COLUMN root_priv_key BLOB; -- root private key (only if user is the owner and group creator) +ALTER TABLE groups ADD COLUMN root_pub_key BLOB; -- needed for all members of public groups to verify ownership chains +ALTER TABLE groups ADD COLUMN member_priv_key BLOB; -- current user's member private key for this group + +-- Member public keys (for all members, including current user) +-- Public key is sent via MemberInfo/XMember and stored for signature verification +ALTER TABLE group_members ADD COLUMN member_pub_key BLOB; -- public key (all members) + +-- Note: root_priv_key is the root key from group link (immutable group identity), only for owner/creator +-- Note: root_pub_key is needed for all members of public groups to verify ownership chains +-- Note: member_priv_key is the current user's signing key for this group (unique per group) +-- Note: member_pub_key is received via MemberInfo (XGrpMemNew, etc.) or XMember message +``` + +## Root Key Management (Analysis Required) + +Currently, root key is generated in Agent (`ShortLinkCreds.linkPrivSigKey`) and stored in agent schema (`rcv_queues.link_priv_sig_key`). + +For Chat to sign owner messages, we need access to either: +- The root key (for initial owner) +- The owner key (for subsequent owners in chain) + +**Current Problem: Two-Step Group Creation (2 roundtrips)** + +Current flow in Commands.hs: +1. Chat creates connection → server roundtrip → gets link +2. Chat updates group profile to include link +3. Chat updates link data → another server roundtrip + +Problems: +- Double requests increase latency +- Risk of failing halfway (needs recovery management) +- Can't include signed owner key in initial link data + +**Solution: New Agent API with Prepare + Create Pattern** + +Two new Agent functions: + +```haskell +-- Prepared link data returned by prepare step (NO network, NO database) +-- Contains everything needed to: (a) construct the short link, (b) create the connection later +data PreparedConnLink c = PreparedConnLink + { pclServer :: SMPServerWithAuth, -- Committed server from config + pclNonce :: C.CbNonce, -- Nonce (corrId) - determines sender ID + pclRootKeyPair :: C.KeyPairEd25519, -- Root signing key for link + pclE2eKeyPair :: C.KeyPairX25519, -- E2E DH key for queue address + pclFixedLinkData :: FixedLinkData c, -- Contains connReq (with ratchet params for invitations) + pclLinkKey :: LinkKey, -- Derived from FixedLinkData: sha3_256(encoded fixedData) + pclPrivSigKey :: C.PrivateKeyEd25519 -- For signing link data (same as snd of pclRootKeyPair) + } + +-- 1. prepareConnectionLink: Generates all link parameters locally (NO network, NO database) +-- Returns PreparedConnLink + the actual short link that can be used in addresses +prepareConnectionLink :: ConnectionModeI c + => AgentClient -> UserId -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys + -> AM (PreparedConnLink c, ConnShortLink c) +-- Does: +-- - Selects server from config (getSMPServer) +-- - Generates nonce, derives sender ID: sha3_384(corrId)[:24] +-- - Generates root key pair (Ed25519) for signing +-- - Generates e2e DH key pair (X25519) for queue address +-- - For invitations: generates E2E ratchet params +-- - Builds ConnectionRequestUri (contains queue address + ratchet params) +-- - Builds FixedLinkData (contains connReq + rootKey + agentVRange) +-- - Derives linkKey = sha3_256(encoded fixedData) +-- - Constructs ConnShortLink (CSLContact or CSLInvitation) with linkKey +-- Returns (PreparedConnLink, ConnShortLink) - both can be roundtripped, nothing saved + +-- 2. createConnectionWithPreparedLink: Creates connection using prepared link +-- Single network call to create queue with pre-determined sender ID +createConnectionWithPreparedLink :: ConnectionModeI c => + AgentClient -> NetworkRequestMode -> UserId -> Bool -> Bool -> + PreparedConnLink c -> UserConnLinkData c -> SubscriptionMode -> + AM (ConnId, (CreatedConnLink c, Maybe ClientServiceId)) +-- Accepts: +-- - PreparedConnLink from prepare step (contains all crypto material) +-- - UserConnLinkData with signed OwnerAuth array (mutable part) +-- Does: +-- - Uses pclNonce to get deterministic sender ID +-- - Creates connection record (newConnNoQueues) +-- - Creates queue on server with prepared nonce → same sender ID +-- - Encrypts & uploads link data (fixed + user data) +-- Returns same as createConnection +``` + +**Key insights (from RFC 2025-03-16-smp-queues.md):** +- Sender ID = `sha3_384(nonce)[:24]` - derived locally from correlation ID (nonce) +- `FixedLinkData` contains `ConnectionRequestUri` (includes ratchet params for invitations) +- `LinkKey` = `sha3_256(encoded fixedData)` - derived from fixed data hash +- For **contact addresses**: `(link_id, enc_key) = HKDF(link_key, 56)` - fully deterministic +- For **1-time invitations**: `link_id` is server-generated, `enc_key = HKDF(link_key, 32)` +- Public groups use contact mode → short link address fully known at prepare step +- Everything can be roundtripped - no database needed for prepare step + +**New Flow (single roundtrip):** + +```haskell +-- In Chat (Commands.hs) when creating public group: +createPublicGroupWithRelays :: ... -> CM GroupInfo +createPublicGroupWithRelays ... = do + -- 1. Prepare link parameters (NO network, NO database) + -- Returns PreparedConnLink + the short link for use in group address + (preparedLink@PreparedConnLink {pclRootKeyPair = (rootPubKey, rootPrivKey)}, shortLink) <- + prepareConnectionLink c userId SCMContact clientData pqInitKeys + + -- 2. Generate owner's member key pair + (memberPubKey, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair g + + -- 3. Create signed OwnerAuth (Chat signs with root key) + let ownerAuth = OwnerAuth + { ownerId = memberId, + ownerKey = memberPubKey, + authOwnerSig = C.sign' rootPrivKey (memberId <> C.encodePubKey memberPubKey) + } + + -- 4. Create UserConnLinkData with owners array + let userLinkData = UserContactLinkData $ UserContactData { owners = [ownerAuth], direct = True } + + -- 5. Create connection with prepared link (SINGLE network call) + (connId, (createdLink, _)) <- createConnectionWithPreparedLink c NRMNormal userId + enableNtfs checkNotices preparedLink userLinkData SMSubscribe + + -- 6. Store keys in groups table + updateGroupKeys groupId rootPubKey rootPrivKey memberPrivKey + -- groups.root_pub_key = rootPubKey (for all members of public groups) + -- groups.root_priv_key = rootPrivKey (only for owner/creator) + -- groups.member_priv_key = memberPrivKey (current user's signing key) + -- group_members.member_pub_key = memberPubKey (for current user's membership) + + -- Note: shortLink can be used immediately in group profile/address + -- The link address is determined at step 1, not step 5 +``` + +**Key Points:** +- `prepareConnectionLink` generates all link parameters locally (no network, no DB) +- Returns `(PreparedConnLink, ConnShortLink)` - short link address is known immediately +- Sender ID is deterministic: `sha3_384(nonce)[:24]` - derived locally +- `FixedLinkData` contains `ConnectionRequestUri` (includes ratchet params for invitations) +- `LinkKey` derived from `FixedLinkData`, short link address derived from `LinkKey` +- Chat uses root key to sign owner's member key → OwnerAuth +- `createConnectionWithPreparedLink` makes single network roundtrip with complete link data +- `groups` table: `root_priv_key` (owner only), `root_pub_key` (all members), `member_priv_key` (current user) +- `group_members` table: `member_pub_key` (all members) + +## Current Public Group Creation (to be refactored) + +Review `src/Simplex/Chat/Library/Commands.hs` - current two-step process: +1. `APICreateGroup` / `createPreparedGroup` - creates group with connection +2. Server roundtrip to create link +3. Update profile with link +4. Update link data (another roundtrip) + +This needs refactoring to use new Agent API for single-roundtrip creation. + +## Implementation Steps + +### Phase 0: Agent API Changes (simplexmq) +1. Add `prepareConnectionLink` function - commits to server, generates link + root key locally +2. Add `createConnectionWithPreparedLink` function - accepts server + root key, single network call +3. Update Agent store to handle new flow (connection record without queue record) + +### Phase 1: Types and Encoding +1. Add `MemberKey` type and JSON encoding in Types.hs +2. Add `memberKey :: Maybe MemberKey` field to `MemberInfo` type +3. Add `newMemberKey :: MemberKey` to `XMember` message (required, not Maybe) +4. Add `Maybe MemberKey` parameter to `XGrpLinkMem` message +5. Types already added to Protocol.hs: `KeyRef`, `ChatBinding`, `MsgSignature`, `MsgSignatures`, `ParsedMsg`, `MsgSigData`, `MsgForwardData` +6. Encoding instances added: `KeyRef`, `ChatBinding`, `MsgSignature`, `MsgSignatures`, `MsgSigData`, `MsgForwardData` +7. Binary batch element parser (`elementP`) handles `/`/`>`/`{` prefixes with attoparsec +8. Update `parseChatMessages` to accept both JSON array and binary batch formats +9. Add `BatchMode` parameter to batching functions in Messages/Batch.hs + +### Phase 2: Key Generation and Storage +1. Add database migration for `member_pub_key` in group_members, `member_priv_key` in groups +2. Generate Ed25519 key pair when joining/creating group +3. Store private key in groups.member_priv_key (current user's key for this group) +4. Store public key in group_members.member_pub_key (for all members) +5. Include public key in XMember/XGrpLinkMem/MemberInfo when sending + +### Phase 3: Signing Messages +1. Add `signChatMessage` function in Internal.hs +2. Modify `sendGroupMessage` to sign roster-modifying messages +3. Add owner key to group link when creating public group +4. Sign `XGrpRelayInv` with owner key + +### Phase 4: Signature Verification +1. `verifySig` added in Subscriber.hs — verifies against member's stored public key, checks member ID match +2. `processAChatMsg` verifies direct messages; `xGrpMsgForward` verifies forwarded messages after author resolution +3. `xGrpMsgForward` extended with `Maybe GroupChatScopeInfo` and `Maybe MsgSigData` — eliminated `processForward` duplication +4. Bad signature creates `RGEMsgBadSignature` chat item for the user +5. Add relay validation for `XGrpRelayInv` in Subscriber.hs + +### Phase 5: Version Gating +1. Add new chat version (e.g., `memberSignaturesVersion = VersionChat 17`) +2. Gate signature features behind version check +3. Accept unsigned messages from older clients +4. Send signed messages only to clients supporting new version + +## Signature Verification Logic + +Current implementation (`verifySig` in Subscriber.hs) — minimal first step: + +```haskell +verifySig :: GroupMember -> Maybe MsgSigData -> Bool +verifySig GroupMember {memberPubKey = Just pubKey} (Just MsgSigData {signatures = MsgSignatures {signatures}, signedBody}) = + all verifyOne (L.toList signatures) + where + verifyOne (MsgSignature KRMember sig) = + C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig signedBody +verifySig _ _ = True +``` + +Verification is called in two places: +- `processAChatMsg`: verifies direct messages from the sender member +- `xGrpMsgForward`: verifies forwarded messages after resolving the author from `MsgForwardData.fwdMemberId` + +Future full verification should additionally: +1. Validate `ChatBinding` matches group (root key, sender member ID) +2. Reject unsigned messages for message types that require signatures + +## Owner Key Integration with Group Link (Separate Key Model) + +When creating a public group: +1. Generate group root key (Ed25519 key pair) - stored in group link's immutable FixedLinkData +2. Generate owner's member key (Ed25519 key pair) - stored in groups.member_priv_key and group_members.member_pub_key +3. Create OwnerAuth entry: `OwnerAuth { ownerId = memberId, ownerKey = memberKey, authOwnerSig = sig(memberId || memberKey, rootKey) }` +4. Add OwnerAuth to group link's mutable UserContactData.owners list + +This model: +- Root key is immutable (defines group identity) +- Owner key is in OwnerAuth chain (supports ownership transfer) +- Member keys are per-group, stored in groups/group_members tables (NOT in profiles) +- New owners can be added by existing owners signing their authorization + +```haskell +-- When creating public group +createPublicGroup :: ... -> CM GroupInfo +createPublicGroup ... = do + -- 1. Generate root key for group identity + (rootPubKey, rootPrivKey) <- generateKeyPair Ed25519 + + -- 2. Generate owner's member key for this group + (memberPubKey, memberPrivKey) <- generateKeyPair Ed25519 + + -- 3. Create owner authorization signed by root + let ownerAuth = OwnerAuth + { ownerId = memberId membership, + ownerKey = memberPubKey, + authOwnerSig = sign rootPrivKey (memberId <> encodePubKey memberPubKey) + } + + -- 4. Store keys: root_priv_key and member_priv_key in groups table + -- member_pub_key in group_members table + -- 5. Add ownerAuth to link data + ... +``` + +## Testing Considerations + +1. **Unit tests**: Encoding/decoding round-trips for signed messages +2. **Integration tests**: Message signing and verification flow +3. **Compatibility tests**: Old clients receiving signed messages +4. **Relay tests**: Signature validation in relay invitation flow +5. **Key rotation tests**: Profile updates with new member key + +## Backward Compatibility + +- **Hard fail mode**: Messages requiring signatures (roster changes) MUST be signed. Unsigned/invalid = rejected. +- Version-gated: Add `memberSignaturesVersion = VersionChat 17` +- New clients: Send signed roster messages, reject unsigned roster messages from new clients +- Old clients: Cannot send roster messages to new-version groups (version negotiation prevents this) +- Migration path: Existing groups without signatures continue working; new public groups require signatures + +## Design Decisions (Confirmed) + +1. **Message signing scope**: Only roster-modifying messages (XGrpRelayInv, XGrpMemNew, XGrpMemRole, XGrpMemDel, XGrpInfo, XGrpPrefs, XGrpDel). Regular content messages (XMsgNew) are NOT signed — signing them would destroy deniability by creating non-repudiable proof of authorship. Content manipulation by relays is detectable post-hoc via cross-relay consistency, which is sufficient because content delivery is not irreversible. Roster/profile changes are disruptive and irreversible (member removed, role changed, group deleted), so they must be authenticated at processing time before taking effect — post-detection is too late. + +2. **Signature failure handling**: Hard fail for all signed message types. Reject any message that should be signed but isn't or has invalid signature. + +3. **Key model**: Separate keys - root key is fixed in group link, owner is authorized via OwnerAuth chain. Supports ownership transfer without breaking group identity. Matches simplexmq pattern. diff --git a/docs/rfcs/2026-02-10-member-support-voice.md b/docs/rfcs/2026-02-10-member-support-voice.md new file mode 100644 index 0000000000..52285d1514 --- /dev/null +++ b/docs/rfcs/2026-02-10-member-support-voice.md @@ -0,0 +1,212 @@ +# Voice messages in member support scope + +## Table of contents + +1. Executive summary +2. Problem +3. High-level design +4. Detailed implementation plan + +## 1. Executive summary + +Allow voice messages from host/admin during the approval phase (member pending) regardless of group voice settings, gated behind chat protocol version 17. This enables the directory bot to send voice captchas in groups that prohibit voice messages. Old clients that don't support this exemption will receive text/image captchas instead. + +## 2. Problem + +The directory bot sends voice captchas to joining members via the member support scope (`GCSMemberSupport`). However, `prohibitedGroupContent` (Internal.hs:338) blocks voice messages when the group disables voice — with no scope exemption: + +```haskell +| isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +Other content types (files, reports, simplex links) already have `isNothing scopeInfo` guards that exempt them in member support scope. Voice does not. + +This means voice captchas fail in the majority of real groups that prohibit voice messages. The check runs on both sender side (Commands.hs:3856) and recipient side (Subscriber.hs:1738), so both the bot and the joining member reject voice in these groups. + +## 3. High-level design + +1. **Protocol version 17** (`memberSupportVoiceVersion`): gates the `prohibitedGroupContent` exemption for host voice during the approval phase. + +2. **Core library change** (Internal.hs): exempt voice in `prohibitedGroupContent` when sender is admin+ (host) AND the member is in the approval phase (pending status). Voice is NOT generally allowed in member support scope — only during approval, only from host. + +3. **Directory bot change** (Service.hs): check member's protocol version and group voice settings before offering or sending voice captcha. Fall back to text/image captcha for old clients in voice-disabled groups. + +## 4. Detailed implementation plan + +### 4.1. Protocol.hs — add version 17 + +**File:** `src/Simplex/Chat/Protocol.hs` + +Add to version history comment (after line 79): + +``` +-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +``` + +Update `currentChatVersion` (line 85): + +```haskell +currentChatVersion = VersionChat 17 +``` + +Add version constant (after `shortLinkDataVersion`, line 146): + +```haskell +-- support host voice messages during member approval regardless of group voice setting +memberSupportVoiceVersion :: VersionChat +memberSupportVoiceVersion = VersionChat 17 +``` + +### 4.2. Internal.hs — exempt host voice during approval phase + +**File:** `src/Simplex/Chat/Library/Internal.hs` + +Change function header (line 337) to bind sender's role and full membership: + +```haskell +prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m@GroupMember {memberRole = senderRole} scopeInfo mc ft file_ sent +``` + +Change line 338 from: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +to: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice +``` + +Add to the `where` clause: + +```haskell + hostApprovalVoice = senderRole >= GRAdmin && inApprovalPhase + inApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport (Just scopeMem)) -> memberPending scopeMem + Just (GCSIMemberSupport Nothing) -> memberPending mem + Nothing -> False +``` + +Note: `memberPending` returns True for both `GSMemPendingApproval` and `GSMemPendingReview`. The exemption applies to both phases — the member hasn't been fully admitted in either state. + +**Why two cases for `inApprovalPhase`:** + +- **Sender side** (bot sending via Commands.hs:3856): `scopeInfo = GCSIMemberSupport (Just pendingMember)` — the scope contains the pending member being supported. `memberPending pendingMember` checks their status. +- **Receiver side** (member receiving via Subscriber.hs:1738): `scopeInfo = GCSIMemberSupport Nothing` — `Nothing` means the member's own support conversation (constructed by `mkGroupSupportChatInfo` in Internal.hs:1535). `memberPending mem` checks the local user's (receiving member's) status. + +**Behavior matrix:** + +| Scenario | `hostApprovalVoice` | Voice allowed? | +|----------|---------------------|----------------| +| Host → pending member, voice disabled | True | Yes (new) | +| Host → approved member in support, voice disabled | False (`memberPending` = False) | No | +| Pending member → host, voice disabled | False (`senderRole` < GRAdmin) | No | +| Anyone outside support scope, voice disabled | False (`inApprovalPhase` = False) | No | +| Any sender, voice enabled | N/A (`groupFeatureMemberAllowed` = True) | Yes (existing) | + +**Version gating:** Old clients (< v17) don't have this exemption. On the sender side this is handled by the bot (4.3). On the recipient side: + +- Old recipient + voice-disabled group: recipient rejects the voice message (shows "Voice messages: received, prohibited") +- This is why the bot must check the member's version before sending voice + +### 4.3. Service.hs — version-aware voice captcha logic + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +#### 4.3.1. Add import + +Add `memberSupportVoiceVersion` to the `Protocol` import: + +```haskell +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) +``` + +#### 4.3.2. Add helper predicate + +Add a helper in the `directoryService` `where` block (same scope as `sendMemberCaptcha`, `sendVoiceCaptcha`, etc., where `opts` is in scope): + +```haskell +canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool +canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) +``` + +Logic: +- Voice captcha generator must be configured +- AND either the group allows voice for the bot/host (any client version works — old clients accept voice from permitted senders) OR the member's client supports v17 (exemption applies on receive side) + +Note: `groupFeatureUserAllowed` checks if the bot (group owner) is permitted to send voice. This is what the recipient's `prohibitedGroupContent` checks — it validates the *sender's* permission (`m` parameter = sender's GroupMember), not the recipient's. Using `groupFeatureMemberAllowed SGFVoice m gInfo` (joining member) would be wrong: it would incorrectly block voice captcha in groups with role-based voice settings (e.g., "admins only"). + +#### 4.3.3. Update `dePendingMember` hint text (line 572) + +Change from: + +```haskell +<> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" +``` + +to: + +```haskell +<> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" +``` + +This hides the `/audio` hint when voice captcha cannot be delivered. + +#### 4.3.4. Update `dePendingMemberMsg` `/audio` handling (lines 644-649) + +When a member sends `/audio`, check `canSendVoiceCaptcha` before switching mode. If voice captcha is not possible, reply with an upgrade message: + +```haskell +| isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] +``` + +#### 4.3.5. Add message constant + +```haskell +voiceCaptchaUnavailable :: Text +voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." +``` + +#### 4.3.6. Update `dePendingMemberMsg` no-captcha `/audio` path (lines 640-642) + +Same check for the case when no pending captcha exists yet: + +```haskell +Nothing -> + if isAudioCmd && canSendVoiceCaptcha g m + then sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + else if isAudioCmd + then sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(Just ciId, MCText voiceCaptchaUnavailable)] + else let mode = CMText + in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode +``` + +### 4.4. Tests + +**File:** `tests/Bots/DirectoryTests.hs` + +Update existing audio captcha tests to cover: +1. Group with voice enabled + any client version: `/audio` works (existing behavior) +2. Group with voice disabled + member version >= 17: `/audio` works +3. Group with voice disabled + member version < 17: `/audio` shows unavailable message, hint is hidden + +### 4.5. Changes summary + +| File | Change | Lines affected | +|------|--------|----------------| +| `Protocol.hs` | Add v17 constant, bump `currentChatVersion` | ~4 lines added | +| `Internal.hs` | Exempt host voice during approval phase | ~6 lines modified/added | +| `Service.hs` | Version-aware voice captcha logic | ~15 lines modified/added | +| `DirectoryTests.hs` | Test coverage for version gating | TBD | diff --git a/docs/rfcs/2026-03-28-group-identity-binding.md b/docs/rfcs/2026-03-28-group-identity-binding.md new file mode 100644 index 0000000000..afc4ed965c --- /dev/null +++ b/docs/rfcs/2026-03-28-group-identity-binding.md @@ -0,0 +1,68 @@ +# Group identity and signature binding + +## Problem + +Group message signatures bind to a group identity via a prefix: + +``` +signedBytes = smpEncode (CBGroup, groupIdentity, memberId) <> messageBody +``` + +Using `groupRootKey` as identity is unstable: the root key is derived from the link's key pair, so link rotation (relay replacement, key compromise recovery) changes it, breaking existing bindings. + +Using an arbitrary entity ID is stable but not self-authenticating: any owner could copy another group's ID. + +## Design + +Use the **hash of the genesis root key** as group identity: + +``` +groupEntityId = sha256(genesisRootPubKey) +``` + +- Set at creation, never changes. +- Self-authenticating: derived from a key pair only the creator held. +- Stored as `linkEntityId` in the short link, and in the group profile distributed to all members. +- Used in the signature binding prefix instead of root key. + +### Why no validation now + +Current clients do not validate that `linkEntityId == sha256(rootKey)` on join. This is unconventional — normally, an unvalidated binding is pointless. Here it is deliberate forward-compatible design, not deferred work: + +- **Forward compatibility for joiners**: future link rotation will cause `rootKey` and `linkEntityId` to diverge. Current clients don't know how to verify a rotation chain, so they must accept diverged values. If we validated now, current clients could not join future rotated groups. Mobile clients have slow upgrade cycles and we have no mechanism to force upgrades, so we aim for at least 2-3 months backward compatibility for new features (1 year for existing). Validating now would force a breaking change on rotation. + +- **Forward compatibility for groups**: all groups created now have the correct binding (`entityId = sha256(rootKey)`). When a future protocol version introduces rotation and enforces validation, these groups are already compliant. Deferring the entity ID until then would mean some groups have IDs and some don't — a backward-compatibility problem. + +The cloning risk (copied entity ID in a malicious group) is acceptable now: groups are small, invite links come from trusted sources, and history merging on re-join is itself a future feature. By the time channels are large enough for cloning to matter, validation will be enforced. + +### Key hierarchy context + +The root key is a **bootstrap key**: it signs `OwnerAuth` entries to certify owners (see [simplexmq owner chain](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2025-04-04-short-links-for-groups.md#multiple-owners-managing-queue-data)), then need not be used again. Owner keys sign admin messages, group updates, and future rotation statements. This conceals the creator's identity — all owners are indistinguishable. + +Using the genesis root key *hash* as identity aligns with this: after rotation, the root key changes but the identity persists, bridged by owner-signed rotation statements. + +### What IS validated now + +- **Link vs profile consistency**: joiners validate that `linkEntityId` from the link matches `sharedGroupId` in the group profile. This prevents a directory or listing from substituting a different link for a group — the link is bound to the profile. This check remains valid after rotation (both preserve the original entity ID). + +- **Profile update immutability**: `sharedGroupId` in the group profile must not change. Clients reject `XGrpInfo` updates that modify it. + +### What is NOT validated now + +- **`linkEntityId == sha256(rootKey)`**: not checked on join. See "Why no validation now" above. + +## Changes + +### Done + +1. **Agent API** (`simplexmq`): `prepareConnectionLink` takes caller-provided root key pair and entity ID instead of generating the key internally. Caller controls both. + +2. **Link creation** (`Commands.hs`): owner generates root key pair, computes `sharedGroupId = sha256(rootPubKey)`, passes both to `prepareConnectionLink`. The entity ID is baked into signed `FixedLinkData`. + +### Remaining + +3. **Group profile**: add `sharedGroupId` field to `GroupProfile`, set from `linkEntityId` at genesis, immutable. Reject `XGrpInfo` updates that change it. + +4. **Joiner validation**: confirm `linkEntityId` from link matches `sharedGroupId` from group profile. + +5. **Signature binding**: change prefix from `smpEncode (CBGroup, groupRootPubKey, memberId)` to `smpEncode (CBGroup, sharedGroupId, memberId)` in both `groupMsgSigning` (signing) and `withVerifiedMsg` (verification). diff --git a/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg b/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg new file mode 100644 index 0000000000..b182521563 --- /dev/null +++ b/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg @@ -0,0 +1,4 @@ + + + +
Alice (identifiable)
buy voucher
Issuing Operator
Coordination Layer
issue voucher
enforce issuance limit
register voucher
unlink
Accepting Operator
Alice (incognito)
Coordination Layer
redeem voucher with proof
verify proof
issue credits
enforce expiry
prevent double-spend
clearing of voucher value IO->AO
use service
provide service
manage credit balance
transfer credits to Bob*
few $, non-xferrable
fractions of cts, xferrable within same AO
not involved
1
2
3
4
5
6
\ No newline at end of file diff --git a/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg b/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg new file mode 100644 index 0000000000..b1ea917340 --- /dev/null +++ b/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg @@ -0,0 +1,4 @@ + + + +
MMR1
MMR2
MMR3
C1
C2
CN
validity
t2
t
t2'
t2''
CA
expiry1
expiry2
\ No newline at end of file diff --git a/eth/nft/.gitignore b/eth/nft/.gitignore new file mode 100644 index 0000000000..d751a74728 --- /dev/null +++ b/eth/nft/.gitignore @@ -0,0 +1,2 @@ +.deps +artifacts/ diff --git a/eth/nft/.prettierrc.json b/eth/nft/.prettierrc.json new file mode 100644 index 0000000000..b2a56f2371 --- /dev/null +++ b/eth/nft/.prettierrc.json @@ -0,0 +1,38 @@ +{ + "overrides": [ + { + "files": "*.sol", + "options": { + "printWidth": 80, + "tabWidth": 4, + "useTabs": false, + "singleQuote": false, + "bracketSpacing": false + } + }, + { + "files": "*.yml", + "options": {} + }, + { + "files": "*.yaml", + "options": {} + }, + { + "files": "*.toml", + "options": {} + }, + { + "files": "*.json", + "options": {} + }, + { + "files": "*.js", + "options": {} + }, + { + "files": "*.ts", + "options": {} + } + ] +} diff --git a/eth/nft/compiler_config.json b/eth/nft/compiler_config.json new file mode 100644 index 0000000000..9026d3afa9 --- /dev/null +++ b/eth/nft/compiler_config.json @@ -0,0 +1,16 @@ + +{ + "language": "Solidity", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "outputSelection": { + "*": { + "": ["ast"], + "*": ["abi", "metadata", "devdoc", "userdoc", "storageLayout", "evm.legacyAssembly", "evm.bytecode", "evm.deployedBytecode", "evm.methodIdentifiers", "evm.gasEstimates", "evm.assembly"] + } + } + } +} diff --git a/eth/nft/contracts/MultiERC1155.sol b/eth/nft/contracts/MultiERC1155.sol new file mode 100644 index 0000000000..b715daa6dc --- /dev/null +++ b/eth/nft/contracts/MultiERC1155.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts@5.4.0/token/ERC1155/ERC1155.sol"; +import "@openzeppelin/contracts@5.4.0/access/Ownable.sol"; +import "@openzeppelin/contracts@5.4.0/utils/Base64.sol"; +import "@openzeppelin/contracts@5.4.0/utils/Strings.sol"; + +/// @title MultiERC1155 +/// @notice ERC1155 contract with sequential variants for immutable metadata. +/// @dev Non-upgradeable. Admin and minter addresses are settable by owner. Global sequential token IDs. +contract MultiERC1155 is ERC1155, Ownable { + address public admin; // can manage token IDs + address public minter; // can mint tokens for existing IDs + bool public mintingEnabled; + bool public contractLocked; // no more variants can be added, minting cannot be enabled, cannot be unlocked + + // for name and description avoid or escape double quotes, so it can be used in JSON + struct TokenInfo { + string tokenUri; + uint totalSupply; // 0 for unlimited + bool enabled; + } + + struct TokenState { + TokenInfo tokenInfo; + uint currentSupply; + bool exists; + bool locked; + } + + uint private _nextTokenId; // Global sequential token ID (starts at 1) + mapping(uint => TokenState) public tokens; + uint[] public tokenIds; + + function getTokenIds() view external returns(uint[] memory) { + return tokenIds; + } + + event AdminUpdated(address indexed newAdmin); + event MinterUpdated(address indexed newMinter); + event MintingEnabled(bool newEnabled); + event ContractLocked(); + event TokenAdded(uint indexed tokenId); + event TokenRemoved(uint indexed tokenId); + event TokenUpdated(uint indexed tokenId, bool newEnabled, uint newTotalSupply); + event TokenLocked(uint indexed tokenId); + + constructor() ERC1155("") Ownable(msg.sender) { + admin = msg.sender; + minter = msg.sender; + _nextTokenId = 1; + mintingEnabled = true; + } + + /// @notice Updates the minter address. + /// @param newAdmin The new minter. + function setAdmin(address newAdmin) external onlyOwner { + admin = newAdmin; + emit AdminUpdated(newAdmin); + } + + /// @notice Updates the minter address. + /// @param newMinter The new minter. + function setMinter(address newMinter) external onlyOwner { + minter = newMinter; + emit MinterUpdated(newMinter); + } + + /// @notice Enables/disables minting. + /// @param enabled True/false to enable/disable minting. + function toggleMinting(bool enabled) external onlyOwner { + if (mintingEnabled != enabled) { + mintingEnabled = enabled; + emit MintingEnabled(enabled); + } + } + + /// @notice Permanently locks any token changes and minting, irreversible. + function lockContract() external onlyOwner { + contractLocked = true; + mintingEnabled = false; + emit ContractLocked(); + } + + /// @notice Adds a new variant and optionally sets it as current. + /// @param info New token info. + function addToken(TokenInfo memory info) external { + require(msg.sender == admin || msg.sender == owner(), "Only admin and owner can add tokens"); + _addToken(info); + } + + function _addToken(TokenInfo memory info) internal { + require(bytes(info.tokenUri).length > 0, "Token tokenUri required"); + + uint id = _nextTokenId++; + require(!tokens[id].exists, "Contract error: token ID already exists"); + + tokens[id] = TokenState({ + tokenInfo: info, + currentSupply: 0, + exists: true, + locked: false + }); + tokenIds.push(id); + + emit TokenAdded(id); + } + + /// @notice Removes the last variant if unused (currentSupply == 0). + function removeToken(uint id) external { + require(msg.sender == admin || msg.sender == owner(), "Only admin and owner can remove tokens"); + TokenState storage token = tokens[id]; + require(token.exists, "Token ID does not exist"); + require(token.currentSupply == 0, "Tokens already minted for this ID"); + require(tokenIds.length > 1, "Cannot remove the last token ID"); + + delete tokens[id]; + for (uint i = 0; i < tokenIds.length; i++) { + if (tokenIds[i] == id) { + tokenIds[i] = tokenIds[tokenIds.length - 1]; + tokenIds.pop(); + break; + } + } + emit TokenRemoved(id); + } + + + /// @notice Enables/disables minting a specific token. + /// @param id Token ID. + /// @param newEnabled True/false to enable/disable minting. + /// @param newTotalSupply 0 for unlimited + function updateToken(uint id, bool newEnabled, uint newTotalSupply) external { + require(msg.sender == admin || msg.sender == owner(), "Only admin and owner can remove tokens"); + TokenState storage token = tokens[id]; + require(token.exists, "Token ID does not exist"); + require(!token.locked, "Token ID is locked"); + require(newTotalSupply == 0 || token.currentSupply <= newTotalSupply, "New total supply must be greater than existing token count for ID"); + + if (token.tokenInfo.enabled != newEnabled || token.tokenInfo.totalSupply != newTotalSupply) { + token.tokenInfo.enabled = newEnabled; + token.tokenInfo.totalSupply = newTotalSupply; + emit TokenUpdated(id, newEnabled, newTotalSupply); + } + } + + /// @notice permanently lock a specific token from any further changes. + /// @param id Token ID. + function lockToken(uint id) external onlyOwner { + TokenState storage token = tokens[id]; + require(token.exists, "Token ID does not exist"); + require(!token.locked, "Token ID is already locked"); + require(token.currentSupply != 0, "No tokens minted for this ID, use removeToken instead"); + token.locked = true; + token.tokenInfo.enabled = false; + emit TokenLocked(id); + } + + /// @notice Mints a token using the default variant. + /// @param to Recipient. + /// @param id Token ID. + /// @param value Amount to mint. + /// @param data Optional data. + function mint(address to, uint256 id, uint256 value, bytes calldata data) external { + require(!contractLocked, "Contract is permanently locked"); + require(mintingEnabled, "Minting is disabled"); + require(msg.sender == minter || msg.sender == admin || msg.sender == owner(), "Only minter, admin or owner can mint"); + TokenState storage token = tokens[id]; + require(token.exists, "Token ID does not exist"); + require(!token.locked, "Token ID is locked"); + require(token.tokenInfo.enabled, "Token ID is disabled"); + require(token.tokenInfo.totalSupply == 0 || token.tokenInfo.totalSupply >= token.currentSupply + value, "Token supply exceeded for this ID"); + require(value > 0, "Amount must be > 0"); + + _mint(to, id, value, data); + } + + /// @dev Hook to update supplies on transfer/burn. + function _update(address from, address to, uint[] memory ids, uint[] memory values) internal virtual override { + super._update(from, to, ids, values); + + for (uint i = 0; i < ids.length; i++) { + if (from == address(0)) { // mint + tokens[ids[i]].currentSupply += values[i]; + } + if (to == address(0)) { // burn + tokens[ids[i]].currentSupply -= values[i]; + } + } + } + + /// @notice Returns embedded JSON metadata URI. + /// @param id Token ID. + function uri(uint id) public view virtual override returns (string memory) { + TokenState storage token = tokens[id]; + require(token.exists, "Invalid token ID"); + return token.tokenInfo.tokenUri; + } +} diff --git a/eth/nft/contracts/NFTMinter.sol b/eth/nft/contracts/NFTMinter.sol new file mode 100644 index 0000000000..cce14909b9 --- /dev/null +++ b/eth/nft/contracts/NFTMinter.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts@5.4.0/access/Ownable.sol"; +import "@openzeppelin/contracts@5.4.0/utils/Pausable.sol"; +import "./NFTNumbered.sol"; + +contract NFTMinter is Ownable, Pausable { + NFTNumbered public nft; + uint public mintStartTime; + uint public mintEndTime; + uint public mintCount; + + /// @param _mintEndTime Unix timestamp when minting ends, 0 to mint without time limit. + constructor(address _nftAddress, uint _mintStartTime, uint _mintEndTime, bool _paused) Ownable(msg.sender) { + _setNFT(_nftAddress); + require(_mintEndTime == 0 || _mintEndTime > _mintStartTime, "Mint end time is before start time"); + require(_mintEndTime == 0 || _mintEndTime > block.timestamp, "Mint end time is in the past"); + mintStartTime = _mintStartTime; + mintEndTime = _mintEndTime; + if (_paused) _pause(); + } + + event NFTUpdated(address indexed newNFT); + event Minted(address indexed to, uint count); + event MintStartUpdated(uint newTime); + event MintEndUpdated(uint newTime); + event MintCountReset(uint oldCount); + + /// @notice Allows anyone to mint NFT for free (gas only). Any mint restrictions other than not paused or mint time must be in NFT contract + function mint() external whenNotPaused { + require(mintEndTime == 0 || mintEndTime > block.timestamp, "Minting ended"); + require(mintStartTime <= block.timestamp, "Minting not started"); + nft.mint(msg.sender); + mintCount++; + emit Minted(msg.sender, mintCount); + } + + /// @notice Update the target NFT contract. + /// @param newNFT The new NFT contract address. + function setNFT(address newNFT) external onlyOwner { + _setNFT(newNFT); + emit NFTUpdated(newNFT); + } + + function _setNFT(address _nft) internal { + require(_nft != address(0), "NFT contract address is 0"); + uint codeSize; + assembly { codeSize := extcodesize(_nft) } + require(codeSize > 0, "Not a contract address"); + nft = NFTNumbered(_nft); + } + + /// @notice Reset mint counter. + function resetMintCount() external onlyOwner { + uint old = mintCount; + mintCount = 0; + emit MintCountReset(old); + } + + /// @notice Update the mint start timestamp. + /// @param newTime The new Unix timestamp when minting ends. + function setMintStartTime(uint256 newTime) external onlyOwner { + require(mintEndTime == 0 || newTime < mintEndTime, "Mint end time is before start time"); + mintStartTime = newTime; + emit MintStartUpdated(newTime); + } + + /// @notice Update the mint end timestamp. + /// @param newTime The new Unix timestamp when minting ends, 0 to mint without time limit. + function setMintEndTime(uint256 newTime) external onlyOwner { + require(newTime == 0 || newTime > mintStartTime, "Mint end time is before start time"); + require(newTime == 0 || newTime > block.timestamp, "Mint end time is in the past"); + mintEndTime = newTime; + emit MintEndUpdated(newTime); + } + + /// @notice Pause minting. + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause minting. + function unpause() external onlyOwner { + _unpause(); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + payable(owner()).transfer(address(this).balance); + } +} diff --git a/eth/nft/contracts/NFTMinter_flattened.sol b/eth/nft/contracts/NFTMinter_flattened.sol new file mode 100644 index 0000000000..01b65c761d --- /dev/null +++ b/eth/nft/contracts/NFTMinter_flattened.sol @@ -0,0 +1,4100 @@ +// SPDX-License-Identifier: MIT + +// File: @openzeppelin/contracts@5.4.0/utils/Context.sol + + +// OpenZeppelin Contracts (last updated v5.0.1) (utils/Context.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Provides information about the current execution context, including the + * sender of the transaction and its data. While these are generally available + * via msg.sender and msg.data, they should not be accessed in such a direct + * manner, since when dealing with meta-transactions the account sending and + * paying for execution may not be the actual sender (as far as an application + * is concerned). + * + * This contract is only required for intermediate, library-like contracts. + */ +abstract contract Context { + function _msgSender() internal view virtual returns (address) { + return msg.sender; + } + + function _msgData() internal view virtual returns (bytes calldata) { + return msg.data; + } + + function _contextSuffixLength() internal view virtual returns (uint256) { + return 0; + } +} + +// File: @openzeppelin/contracts@5.4.0/access/Ownable.sol + + +// OpenZeppelin Contracts (last updated v5.0.0) (access/Ownable.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Contract module which provides a basic access control mechanism, where + * there is an account (an owner) that can be granted exclusive access to + * specific functions. + * + * The initial owner is set to the address provided by the deployer. This can + * later be changed with {transferOwnership}. + * + * This module is used through inheritance. It will make available the modifier + * `onlyOwner`, which can be applied to your functions to restrict their use to + * the owner. + */ +abstract contract Ownable is Context { + address private _owner; + + /** + * @dev The caller account is not authorized to perform an operation. + */ + error OwnableUnauthorizedAccount(address account); + + /** + * @dev The owner is not a valid owner account. (eg. `address(0)`) + */ + error OwnableInvalidOwner(address owner); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @dev Initializes the contract setting the address provided by the deployer as the initial owner. + */ + constructor(address initialOwner) { + if (initialOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(initialOwner); + } + + /** + * @dev Throws if called by any account other than the owner. + */ + modifier onlyOwner() { + _checkOwner(); + _; + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view virtual returns (address) { + return _owner; + } + + /** + * @dev Throws if the sender is not the owner. + */ + function _checkOwner() internal view virtual { + if (owner() != _msgSender()) { + revert OwnableUnauthorizedAccount(_msgSender()); + } + } + + /** + * @dev Leaves the contract without owner. It will not be possible to call + * `onlyOwner` functions. Can only be called by the current owner. + * + * NOTE: Renouncing ownership will leave the contract without an owner, + * thereby disabling any functionality that is only available to the owner. + */ + function renounceOwnership() public virtual onlyOwner { + _transferOwnership(address(0)); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Can only be called by the current owner. + */ + function transferOwnership(address newOwner) public virtual onlyOwner { + if (newOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(newOwner); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Internal function without access restriction. + */ + function _transferOwnership(address newOwner) internal virtual { + address oldOwner = _owner; + _owner = newOwner; + emit OwnershipTransferred(oldOwner, newOwner); + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Pausable.sol + + +// OpenZeppelin Contracts (last updated v5.3.0) (utils/Pausable.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Contract module which allows children to implement an emergency stop + * mechanism that can be triggered by an authorized account. + * + * This module is used through inheritance. It will make available the + * modifiers `whenNotPaused` and `whenPaused`, which can be applied to + * the functions of your contract. Note that they will not be pausable by + * simply including this module, only once the modifiers are put in place. + */ +abstract contract Pausable is Context { + bool private _paused; + + /** + * @dev Emitted when the pause is triggered by `account`. + */ + event Paused(address account); + + /** + * @dev Emitted when the pause is lifted by `account`. + */ + event Unpaused(address account); + + /** + * @dev The operation failed because the contract is paused. + */ + error EnforcedPause(); + + /** + * @dev The operation failed because the contract is not paused. + */ + error ExpectedPause(); + + /** + * @dev Modifier to make a function callable only when the contract is not paused. + * + * Requirements: + * + * - The contract must not be paused. + */ + modifier whenNotPaused() { + _requireNotPaused(); + _; + } + + /** + * @dev Modifier to make a function callable only when the contract is paused. + * + * Requirements: + * + * - The contract must be paused. + */ + modifier whenPaused() { + _requirePaused(); + _; + } + + /** + * @dev Returns true if the contract is paused, and false otherwise. + */ + function paused() public view virtual returns (bool) { + return _paused; + } + + /** + * @dev Throws if the contract is paused. + */ + function _requireNotPaused() internal view virtual { + if (paused()) { + revert EnforcedPause(); + } + } + + /** + * @dev Throws if the contract is not paused. + */ + function _requirePaused() internal view virtual { + if (!paused()) { + revert ExpectedPause(); + } + } + + /** + * @dev Triggers stopped state. + * + * Requirements: + * + * - The contract must not be paused. + */ + function _pause() internal virtual whenNotPaused { + _paused = true; + emit Paused(_msgSender()); + } + + /** + * @dev Returns to normal state. + * + * Requirements: + * + * - The contract must be paused. + */ + function _unpause() internal virtual whenPaused { + _paused = false; + emit Unpaused(_msgSender()); + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/introspection/IERC165.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/IERC165.sol) + +pragma solidity >=0.4.16; + +/** + * @dev Interface of the ERC-165 standard, as defined in the + * https://eips.ethereum.org/EIPS/eip-165[ERC]. + * + * Implementers can declare support of contract interfaces, which can then be + * queried by others ({ERC165Checker}). + * + * For an implementation, see {ERC165}. + */ +interface IERC165 { + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section] + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/IERC721.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/IERC721.sol) + +pragma solidity >=0.6.2; + + +/** + * @dev Required interface of an ERC-721 compliant contract. + */ +interface IERC721 is IERC165 { + /** + * @dev Emitted when `tokenId` token is transferred from `from` to `to`. + */ + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables `approved` to manage the `tokenId` token. + */ + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables or disables (`approved`) `operator` to manage all of its assets. + */ + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + /** + * @dev Returns the number of tokens in ``owner``'s account. + */ + function balanceOf(address owner) external view returns (uint256 balance); + + /** + * @dev Returns the owner of the `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function ownerOf(uint256 tokenId) external view returns (address owner); + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon + * a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking first that contract recipients + * are aware of the ERC-721 protocol to prevent tokens from being forever locked. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must have been allowed to move this token by either {approve} or + * {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon + * a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Transfers `tokenId` token from `from` to `to`. + * + * WARNING: Note that the caller is responsible to confirm that the recipient is capable of receiving ERC-721 + * or else they may be permanently lost. Usage of {safeTransferFrom} prevents loss, though the caller must + * understand this adds an external call which potentially creates a reentrancy vulnerability. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the zero address clears previous approvals. + * + * Requirements: + * + * - The caller must own the token or be an approved operator. + * - `tokenId` must exist. + * + * Emits an {Approval} event. + */ + function approve(address to, uint256 tokenId) external; + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} for any token owned by the caller. + * + * Requirements: + * + * - The `operator` cannot be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool approved) external; + + /** + * @dev Returns the account approved for `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function getApproved(uint256 tokenId) external view returns (address operator); + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll} + */ + function isApprovedForAll(address owner, address operator) external view returns (bool); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/IERC721Metadata.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/IERC721Metadata.sol) + +pragma solidity >=0.6.2; + + +/** + * @title ERC-721 Non-Fungible Token Standard, optional metadata extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + */ +interface IERC721Metadata is IERC721 { + /** + * @dev Returns the token collection name. + */ + function name() external view returns (string memory); + + /** + * @dev Returns the token collection symbol. + */ + function symbol() external view returns (string memory); + + /** + * @dev Returns the Uniform Resource Identifier (URI) for `tokenId` token. + */ + function tokenURI(uint256 tokenId) external view returns (string memory); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/IERC721Receiver.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/IERC721Receiver.sol) + +pragma solidity >=0.5.0; + +/** + * @title ERC-721 token receiver interface + * @dev Interface for any contract that wants to support safeTransfers + * from ERC-721 asset contracts. + */ +interface IERC721Receiver { + /** + * @dev Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} + * by `operator` from `from`, this function is called. + * + * It must return its Solidity selector to confirm the token transfer. + * If any other value is returned or the interface is not implemented by the recipient, the transfer will be + * reverted. + * + * The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); +} + +// File: @openzeppelin/contracts@5.4.0/interfaces/draft-IERC6093.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (interfaces/draft-IERC6093.sol) +pragma solidity >=0.8.4; + +/** + * @dev Standard ERC-20 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-20 tokens. + */ +interface IERC20Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC20InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC20InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `spender`’s `allowance`. Used in transfers. + * @param spender Address that may be allowed to operate on tokens without being their owner. + * @param allowance Amount of tokens a `spender` is allowed to operate with. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC20InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `spender` to be approved. Used in approvals. + * @param spender Address that may be allowed to operate on tokens without being their owner. + */ + error ERC20InvalidSpender(address spender); +} + +/** + * @dev Standard ERC-721 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-721 tokens. + */ +interface IERC721Errors { + /** + * @dev Indicates that an address can't be an owner. For example, `address(0)` is a forbidden owner in ERC-20. + * Used in balance queries. + * @param owner Address of the current owner of a token. + */ + error ERC721InvalidOwner(address owner); + + /** + * @dev Indicates a `tokenId` whose `owner` is the zero address. + * @param tokenId Identifier number of a token. + */ + error ERC721NonexistentToken(uint256 tokenId); + + /** + * @dev Indicates an error related to the ownership over a particular token. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param tokenId Identifier number of a token. + * @param owner Address of the current owner of a token. + */ + error ERC721IncorrectOwner(address sender, uint256 tokenId, address owner); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC721InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC721InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param tokenId Identifier number of a token. + */ + error ERC721InsufficientApproval(address operator, uint256 tokenId); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC721InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC721InvalidOperator(address operator); +} + +/** + * @dev Standard ERC-1155 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-1155 tokens. + */ +interface IERC1155Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + * @param tokenId Identifier number of a token. + */ + error ERC1155InsufficientBalance(address sender, uint256 balance, uint256 needed, uint256 tokenId); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC1155InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC1155InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param owner Address of the current owner of a token. + */ + error ERC1155MissingApprovalForAll(address operator, address owner); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC1155InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC1155InvalidOperator(address operator); + + /** + * @dev Indicates an array length mismatch between ids and values in a safeBatchTransferFrom operation. + * Used in batch transfers. + * @param idsLength Length of the array of token identifiers + * @param valuesLength Length of the array of token amounts + */ + error ERC1155InvalidArrayLength(uint256 idsLength, uint256 valuesLength); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/utils/ERC721Utils.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/utils/ERC721Utils.sol) + +pragma solidity ^0.8.20; + + + +/** + * @dev Library that provide common ERC-721 utility functions. + * + * See https://eips.ethereum.org/EIPS/eip-721[ERC-721]. + * + * _Available since v5.1._ + */ +library ERC721Utils { + /** + * @dev Performs an acceptance check for the provided `operator` by calling {IERC721Receiver-onERC721Received} + * on the `to` address. The `operator` is generally the address that initiated the token transfer (i.e. `msg.sender`). + * + * The acceptance call is not executed and treated as a no-op if the target address doesn't contain code (i.e. an EOA). + * Otherwise, the recipient must implement {IERC721Receiver-onERC721Received} and return the acceptance magic value to accept + * the transfer. + */ + function checkOnERC721Received( + address operator, + address from, + address to, + uint256 tokenId, + bytes memory data + ) internal { + if (to.code.length > 0) { + try IERC721Receiver(to).onERC721Received(operator, from, tokenId, data) returns (bytes4 retval) { + if (retval != IERC721Receiver.onERC721Received.selector) { + // Token rejected + revert IERC721Errors.ERC721InvalidReceiver(to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + // non-IERC721Receiver implementer + revert IERC721Errors.ERC721InvalidReceiver(to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Panic.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/Panic.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Helper library for emitting standardized panic codes. + * + * ```solidity + * contract Example { + * using Panic for uint256; + * + * // Use any of the declared internal constants + * function foo() { Panic.GENERIC.panic(); } + * + * // Alternatively + * function foo() { Panic.panic(Panic.GENERIC); } + * } + * ``` + * + * Follows the list from https://github.com/ethereum/solidity/blob/v0.8.24/libsolutil/ErrorCodes.h[libsolutil]. + * + * _Available since v5.1._ + */ +// slither-disable-next-line unused-state +library Panic { + /// @dev generic / unspecified error + uint256 internal constant GENERIC = 0x00; + /// @dev used by the assert() builtin + uint256 internal constant ASSERT = 0x01; + /// @dev arithmetic underflow or overflow + uint256 internal constant UNDER_OVERFLOW = 0x11; + /// @dev division or modulo by zero + uint256 internal constant DIVISION_BY_ZERO = 0x12; + /// @dev enum conversion error + uint256 internal constant ENUM_CONVERSION_ERROR = 0x21; + /// @dev invalid encoding in storage + uint256 internal constant STORAGE_ENCODING_ERROR = 0x22; + /// @dev empty array pop + uint256 internal constant EMPTY_ARRAY_POP = 0x31; + /// @dev array out of bounds access + uint256 internal constant ARRAY_OUT_OF_BOUNDS = 0x32; + /// @dev resource error (too large allocation or too large array) + uint256 internal constant RESOURCE_ERROR = 0x41; + /// @dev calling invalid internal function + uint256 internal constant INVALID_INTERNAL_FUNCTION = 0x51; + + /// @dev Reverts with a panic code. Recommended to use with + /// the internal constants with predefined codes. + function panic(uint256 code) internal pure { + assembly ("memory-safe") { + mstore(0x00, 0x4e487b71) + mstore(0x20, code) + revert(0x1c, 0x24) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/SafeCast.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/math/SafeCast.sol) +// This file was procedurally generated from scripts/generate/templates/SafeCast.js. + +pragma solidity ^0.8.20; + +/** + * @dev Wrappers over Solidity's uintXX/intXX/bool casting operators with added overflow + * checks. + * + * Downcasting from uint256/int256 in Solidity does not revert on overflow. This can + * easily result in undesired exploitation or bugs, since developers usually + * assume that overflows raise errors. `SafeCast` restores this intuition by + * reverting the transaction when such an operation overflows. + * + * Using this library instead of the unchecked operations eliminates an entire + * class of bugs, so it's recommended to use it always. + */ +library SafeCast { + /** + * @dev Value doesn't fit in an uint of `bits` size. + */ + error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value); + + /** + * @dev An int value doesn't fit in an uint of `bits` size. + */ + error SafeCastOverflowedIntToUint(int256 value); + + /** + * @dev Value doesn't fit in an int of `bits` size. + */ + error SafeCastOverflowedIntDowncast(uint8 bits, int256 value); + + /** + * @dev An uint value doesn't fit in an int of `bits` size. + */ + error SafeCastOverflowedUintToInt(uint256 value); + + /** + * @dev Returns the downcasted uint248 from uint256, reverting on + * overflow (when the input is greater than largest uint248). + * + * Counterpart to Solidity's `uint248` operator. + * + * Requirements: + * + * - input must fit into 248 bits + */ + function toUint248(uint256 value) internal pure returns (uint248) { + if (value > type(uint248).max) { + revert SafeCastOverflowedUintDowncast(248, value); + } + return uint248(value); + } + + /** + * @dev Returns the downcasted uint240 from uint256, reverting on + * overflow (when the input is greater than largest uint240). + * + * Counterpart to Solidity's `uint240` operator. + * + * Requirements: + * + * - input must fit into 240 bits + */ + function toUint240(uint256 value) internal pure returns (uint240) { + if (value > type(uint240).max) { + revert SafeCastOverflowedUintDowncast(240, value); + } + return uint240(value); + } + + /** + * @dev Returns the downcasted uint232 from uint256, reverting on + * overflow (when the input is greater than largest uint232). + * + * Counterpart to Solidity's `uint232` operator. + * + * Requirements: + * + * - input must fit into 232 bits + */ + function toUint232(uint256 value) internal pure returns (uint232) { + if (value > type(uint232).max) { + revert SafeCastOverflowedUintDowncast(232, value); + } + return uint232(value); + } + + /** + * @dev Returns the downcasted uint224 from uint256, reverting on + * overflow (when the input is greater than largest uint224). + * + * Counterpart to Solidity's `uint224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toUint224(uint256 value) internal pure returns (uint224) { + if (value > type(uint224).max) { + revert SafeCastOverflowedUintDowncast(224, value); + } + return uint224(value); + } + + /** + * @dev Returns the downcasted uint216 from uint256, reverting on + * overflow (when the input is greater than largest uint216). + * + * Counterpart to Solidity's `uint216` operator. + * + * Requirements: + * + * - input must fit into 216 bits + */ + function toUint216(uint256 value) internal pure returns (uint216) { + if (value > type(uint216).max) { + revert SafeCastOverflowedUintDowncast(216, value); + } + return uint216(value); + } + + /** + * @dev Returns the downcasted uint208 from uint256, reverting on + * overflow (when the input is greater than largest uint208). + * + * Counterpart to Solidity's `uint208` operator. + * + * Requirements: + * + * - input must fit into 208 bits + */ + function toUint208(uint256 value) internal pure returns (uint208) { + if (value > type(uint208).max) { + revert SafeCastOverflowedUintDowncast(208, value); + } + return uint208(value); + } + + /** + * @dev Returns the downcasted uint200 from uint256, reverting on + * overflow (when the input is greater than largest uint200). + * + * Counterpart to Solidity's `uint200` operator. + * + * Requirements: + * + * - input must fit into 200 bits + */ + function toUint200(uint256 value) internal pure returns (uint200) { + if (value > type(uint200).max) { + revert SafeCastOverflowedUintDowncast(200, value); + } + return uint200(value); + } + + /** + * @dev Returns the downcasted uint192 from uint256, reverting on + * overflow (when the input is greater than largest uint192). + * + * Counterpart to Solidity's `uint192` operator. + * + * Requirements: + * + * - input must fit into 192 bits + */ + function toUint192(uint256 value) internal pure returns (uint192) { + if (value > type(uint192).max) { + revert SafeCastOverflowedUintDowncast(192, value); + } + return uint192(value); + } + + /** + * @dev Returns the downcasted uint184 from uint256, reverting on + * overflow (when the input is greater than largest uint184). + * + * Counterpart to Solidity's `uint184` operator. + * + * Requirements: + * + * - input must fit into 184 bits + */ + function toUint184(uint256 value) internal pure returns (uint184) { + if (value > type(uint184).max) { + revert SafeCastOverflowedUintDowncast(184, value); + } + return uint184(value); + } + + /** + * @dev Returns the downcasted uint176 from uint256, reverting on + * overflow (when the input is greater than largest uint176). + * + * Counterpart to Solidity's `uint176` operator. + * + * Requirements: + * + * - input must fit into 176 bits + */ + function toUint176(uint256 value) internal pure returns (uint176) { + if (value > type(uint176).max) { + revert SafeCastOverflowedUintDowncast(176, value); + } + return uint176(value); + } + + /** + * @dev Returns the downcasted uint168 from uint256, reverting on + * overflow (when the input is greater than largest uint168). + * + * Counterpart to Solidity's `uint168` operator. + * + * Requirements: + * + * - input must fit into 168 bits + */ + function toUint168(uint256 value) internal pure returns (uint168) { + if (value > type(uint168).max) { + revert SafeCastOverflowedUintDowncast(168, value); + } + return uint168(value); + } + + /** + * @dev Returns the downcasted uint160 from uint256, reverting on + * overflow (when the input is greater than largest uint160). + * + * Counterpart to Solidity's `uint160` operator. + * + * Requirements: + * + * - input must fit into 160 bits + */ + function toUint160(uint256 value) internal pure returns (uint160) { + if (value > type(uint160).max) { + revert SafeCastOverflowedUintDowncast(160, value); + } + return uint160(value); + } + + /** + * @dev Returns the downcasted uint152 from uint256, reverting on + * overflow (when the input is greater than largest uint152). + * + * Counterpart to Solidity's `uint152` operator. + * + * Requirements: + * + * - input must fit into 152 bits + */ + function toUint152(uint256 value) internal pure returns (uint152) { + if (value > type(uint152).max) { + revert SafeCastOverflowedUintDowncast(152, value); + } + return uint152(value); + } + + /** + * @dev Returns the downcasted uint144 from uint256, reverting on + * overflow (when the input is greater than largest uint144). + * + * Counterpart to Solidity's `uint144` operator. + * + * Requirements: + * + * - input must fit into 144 bits + */ + function toUint144(uint256 value) internal pure returns (uint144) { + if (value > type(uint144).max) { + revert SafeCastOverflowedUintDowncast(144, value); + } + return uint144(value); + } + + /** + * @dev Returns the downcasted uint136 from uint256, reverting on + * overflow (when the input is greater than largest uint136). + * + * Counterpart to Solidity's `uint136` operator. + * + * Requirements: + * + * - input must fit into 136 bits + */ + function toUint136(uint256 value) internal pure returns (uint136) { + if (value > type(uint136).max) { + revert SafeCastOverflowedUintDowncast(136, value); + } + return uint136(value); + } + + /** + * @dev Returns the downcasted uint128 from uint256, reverting on + * overflow (when the input is greater than largest uint128). + * + * Counterpart to Solidity's `uint128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toUint128(uint256 value) internal pure returns (uint128) { + if (value > type(uint128).max) { + revert SafeCastOverflowedUintDowncast(128, value); + } + return uint128(value); + } + + /** + * @dev Returns the downcasted uint120 from uint256, reverting on + * overflow (when the input is greater than largest uint120). + * + * Counterpart to Solidity's `uint120` operator. + * + * Requirements: + * + * - input must fit into 120 bits + */ + function toUint120(uint256 value) internal pure returns (uint120) { + if (value > type(uint120).max) { + revert SafeCastOverflowedUintDowncast(120, value); + } + return uint120(value); + } + + /** + * @dev Returns the downcasted uint112 from uint256, reverting on + * overflow (when the input is greater than largest uint112). + * + * Counterpart to Solidity's `uint112` operator. + * + * Requirements: + * + * - input must fit into 112 bits + */ + function toUint112(uint256 value) internal pure returns (uint112) { + if (value > type(uint112).max) { + revert SafeCastOverflowedUintDowncast(112, value); + } + return uint112(value); + } + + /** + * @dev Returns the downcasted uint104 from uint256, reverting on + * overflow (when the input is greater than largest uint104). + * + * Counterpart to Solidity's `uint104` operator. + * + * Requirements: + * + * - input must fit into 104 bits + */ + function toUint104(uint256 value) internal pure returns (uint104) { + if (value > type(uint104).max) { + revert SafeCastOverflowedUintDowncast(104, value); + } + return uint104(value); + } + + /** + * @dev Returns the downcasted uint96 from uint256, reverting on + * overflow (when the input is greater than largest uint96). + * + * Counterpart to Solidity's `uint96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toUint96(uint256 value) internal pure returns (uint96) { + if (value > type(uint96).max) { + revert SafeCastOverflowedUintDowncast(96, value); + } + return uint96(value); + } + + /** + * @dev Returns the downcasted uint88 from uint256, reverting on + * overflow (when the input is greater than largest uint88). + * + * Counterpart to Solidity's `uint88` operator. + * + * Requirements: + * + * - input must fit into 88 bits + */ + function toUint88(uint256 value) internal pure returns (uint88) { + if (value > type(uint88).max) { + revert SafeCastOverflowedUintDowncast(88, value); + } + return uint88(value); + } + + /** + * @dev Returns the downcasted uint80 from uint256, reverting on + * overflow (when the input is greater than largest uint80). + * + * Counterpart to Solidity's `uint80` operator. + * + * Requirements: + * + * - input must fit into 80 bits + */ + function toUint80(uint256 value) internal pure returns (uint80) { + if (value > type(uint80).max) { + revert SafeCastOverflowedUintDowncast(80, value); + } + return uint80(value); + } + + /** + * @dev Returns the downcasted uint72 from uint256, reverting on + * overflow (when the input is greater than largest uint72). + * + * Counterpart to Solidity's `uint72` operator. + * + * Requirements: + * + * - input must fit into 72 bits + */ + function toUint72(uint256 value) internal pure returns (uint72) { + if (value > type(uint72).max) { + revert SafeCastOverflowedUintDowncast(72, value); + } + return uint72(value); + } + + /** + * @dev Returns the downcasted uint64 from uint256, reverting on + * overflow (when the input is greater than largest uint64). + * + * Counterpart to Solidity's `uint64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toUint64(uint256 value) internal pure returns (uint64) { + if (value > type(uint64).max) { + revert SafeCastOverflowedUintDowncast(64, value); + } + return uint64(value); + } + + /** + * @dev Returns the downcasted uint56 from uint256, reverting on + * overflow (when the input is greater than largest uint56). + * + * Counterpart to Solidity's `uint56` operator. + * + * Requirements: + * + * - input must fit into 56 bits + */ + function toUint56(uint256 value) internal pure returns (uint56) { + if (value > type(uint56).max) { + revert SafeCastOverflowedUintDowncast(56, value); + } + return uint56(value); + } + + /** + * @dev Returns the downcasted uint48 from uint256, reverting on + * overflow (when the input is greater than largest uint48). + * + * Counterpart to Solidity's `uint48` operator. + * + * Requirements: + * + * - input must fit into 48 bits + */ + function toUint48(uint256 value) internal pure returns (uint48) { + if (value > type(uint48).max) { + revert SafeCastOverflowedUintDowncast(48, value); + } + return uint48(value); + } + + /** + * @dev Returns the downcasted uint40 from uint256, reverting on + * overflow (when the input is greater than largest uint40). + * + * Counterpart to Solidity's `uint40` operator. + * + * Requirements: + * + * - input must fit into 40 bits + */ + function toUint40(uint256 value) internal pure returns (uint40) { + if (value > type(uint40).max) { + revert SafeCastOverflowedUintDowncast(40, value); + } + return uint40(value); + } + + /** + * @dev Returns the downcasted uint32 from uint256, reverting on + * overflow (when the input is greater than largest uint32). + * + * Counterpart to Solidity's `uint32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toUint32(uint256 value) internal pure returns (uint32) { + if (value > type(uint32).max) { + revert SafeCastOverflowedUintDowncast(32, value); + } + return uint32(value); + } + + /** + * @dev Returns the downcasted uint24 from uint256, reverting on + * overflow (when the input is greater than largest uint24). + * + * Counterpart to Solidity's `uint24` operator. + * + * Requirements: + * + * - input must fit into 24 bits + */ + function toUint24(uint256 value) internal pure returns (uint24) { + if (value > type(uint24).max) { + revert SafeCastOverflowedUintDowncast(24, value); + } + return uint24(value); + } + + /** + * @dev Returns the downcasted uint16 from uint256, reverting on + * overflow (when the input is greater than largest uint16). + * + * Counterpart to Solidity's `uint16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toUint16(uint256 value) internal pure returns (uint16) { + if (value > type(uint16).max) { + revert SafeCastOverflowedUintDowncast(16, value); + } + return uint16(value); + } + + /** + * @dev Returns the downcasted uint8 from uint256, reverting on + * overflow (when the input is greater than largest uint8). + * + * Counterpart to Solidity's `uint8` operator. + * + * Requirements: + * + * - input must fit into 8 bits + */ + function toUint8(uint256 value) internal pure returns (uint8) { + if (value > type(uint8).max) { + revert SafeCastOverflowedUintDowncast(8, value); + } + return uint8(value); + } + + /** + * @dev Converts a signed int256 into an unsigned uint256. + * + * Requirements: + * + * - input must be greater than or equal to 0. + */ + function toUint256(int256 value) internal pure returns (uint256) { + if (value < 0) { + revert SafeCastOverflowedIntToUint(value); + } + return uint256(value); + } + + /** + * @dev Returns the downcasted int248 from int256, reverting on + * overflow (when the input is less than smallest int248 or + * greater than largest int248). + * + * Counterpart to Solidity's `int248` operator. + * + * Requirements: + * + * - input must fit into 248 bits + */ + function toInt248(int256 value) internal pure returns (int248 downcasted) { + downcasted = int248(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(248, value); + } + } + + /** + * @dev Returns the downcasted int240 from int256, reverting on + * overflow (when the input is less than smallest int240 or + * greater than largest int240). + * + * Counterpart to Solidity's `int240` operator. + * + * Requirements: + * + * - input must fit into 240 bits + */ + function toInt240(int256 value) internal pure returns (int240 downcasted) { + downcasted = int240(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(240, value); + } + } + + /** + * @dev Returns the downcasted int232 from int256, reverting on + * overflow (when the input is less than smallest int232 or + * greater than largest int232). + * + * Counterpart to Solidity's `int232` operator. + * + * Requirements: + * + * - input must fit into 232 bits + */ + function toInt232(int256 value) internal pure returns (int232 downcasted) { + downcasted = int232(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(232, value); + } + } + + /** + * @dev Returns the downcasted int224 from int256, reverting on + * overflow (when the input is less than smallest int224 or + * greater than largest int224). + * + * Counterpart to Solidity's `int224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toInt224(int256 value) internal pure returns (int224 downcasted) { + downcasted = int224(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(224, value); + } + } + + /** + * @dev Returns the downcasted int216 from int256, reverting on + * overflow (when the input is less than smallest int216 or + * greater than largest int216). + * + * Counterpart to Solidity's `int216` operator. + * + * Requirements: + * + * - input must fit into 216 bits + */ + function toInt216(int256 value) internal pure returns (int216 downcasted) { + downcasted = int216(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(216, value); + } + } + + /** + * @dev Returns the downcasted int208 from int256, reverting on + * overflow (when the input is less than smallest int208 or + * greater than largest int208). + * + * Counterpart to Solidity's `int208` operator. + * + * Requirements: + * + * - input must fit into 208 bits + */ + function toInt208(int256 value) internal pure returns (int208 downcasted) { + downcasted = int208(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(208, value); + } + } + + /** + * @dev Returns the downcasted int200 from int256, reverting on + * overflow (when the input is less than smallest int200 or + * greater than largest int200). + * + * Counterpart to Solidity's `int200` operator. + * + * Requirements: + * + * - input must fit into 200 bits + */ + function toInt200(int256 value) internal pure returns (int200 downcasted) { + downcasted = int200(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(200, value); + } + } + + /** + * @dev Returns the downcasted int192 from int256, reverting on + * overflow (when the input is less than smallest int192 or + * greater than largest int192). + * + * Counterpart to Solidity's `int192` operator. + * + * Requirements: + * + * - input must fit into 192 bits + */ + function toInt192(int256 value) internal pure returns (int192 downcasted) { + downcasted = int192(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(192, value); + } + } + + /** + * @dev Returns the downcasted int184 from int256, reverting on + * overflow (when the input is less than smallest int184 or + * greater than largest int184). + * + * Counterpart to Solidity's `int184` operator. + * + * Requirements: + * + * - input must fit into 184 bits + */ + function toInt184(int256 value) internal pure returns (int184 downcasted) { + downcasted = int184(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(184, value); + } + } + + /** + * @dev Returns the downcasted int176 from int256, reverting on + * overflow (when the input is less than smallest int176 or + * greater than largest int176). + * + * Counterpart to Solidity's `int176` operator. + * + * Requirements: + * + * - input must fit into 176 bits + */ + function toInt176(int256 value) internal pure returns (int176 downcasted) { + downcasted = int176(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(176, value); + } + } + + /** + * @dev Returns the downcasted int168 from int256, reverting on + * overflow (when the input is less than smallest int168 or + * greater than largest int168). + * + * Counterpart to Solidity's `int168` operator. + * + * Requirements: + * + * - input must fit into 168 bits + */ + function toInt168(int256 value) internal pure returns (int168 downcasted) { + downcasted = int168(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(168, value); + } + } + + /** + * @dev Returns the downcasted int160 from int256, reverting on + * overflow (when the input is less than smallest int160 or + * greater than largest int160). + * + * Counterpart to Solidity's `int160` operator. + * + * Requirements: + * + * - input must fit into 160 bits + */ + function toInt160(int256 value) internal pure returns (int160 downcasted) { + downcasted = int160(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(160, value); + } + } + + /** + * @dev Returns the downcasted int152 from int256, reverting on + * overflow (when the input is less than smallest int152 or + * greater than largest int152). + * + * Counterpart to Solidity's `int152` operator. + * + * Requirements: + * + * - input must fit into 152 bits + */ + function toInt152(int256 value) internal pure returns (int152 downcasted) { + downcasted = int152(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(152, value); + } + } + + /** + * @dev Returns the downcasted int144 from int256, reverting on + * overflow (when the input is less than smallest int144 or + * greater than largest int144). + * + * Counterpart to Solidity's `int144` operator. + * + * Requirements: + * + * - input must fit into 144 bits + */ + function toInt144(int256 value) internal pure returns (int144 downcasted) { + downcasted = int144(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(144, value); + } + } + + /** + * @dev Returns the downcasted int136 from int256, reverting on + * overflow (when the input is less than smallest int136 or + * greater than largest int136). + * + * Counterpart to Solidity's `int136` operator. + * + * Requirements: + * + * - input must fit into 136 bits + */ + function toInt136(int256 value) internal pure returns (int136 downcasted) { + downcasted = int136(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(136, value); + } + } + + /** + * @dev Returns the downcasted int128 from int256, reverting on + * overflow (when the input is less than smallest int128 or + * greater than largest int128). + * + * Counterpart to Solidity's `int128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toInt128(int256 value) internal pure returns (int128 downcasted) { + downcasted = int128(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(128, value); + } + } + + /** + * @dev Returns the downcasted int120 from int256, reverting on + * overflow (when the input is less than smallest int120 or + * greater than largest int120). + * + * Counterpart to Solidity's `int120` operator. + * + * Requirements: + * + * - input must fit into 120 bits + */ + function toInt120(int256 value) internal pure returns (int120 downcasted) { + downcasted = int120(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(120, value); + } + } + + /** + * @dev Returns the downcasted int112 from int256, reverting on + * overflow (when the input is less than smallest int112 or + * greater than largest int112). + * + * Counterpart to Solidity's `int112` operator. + * + * Requirements: + * + * - input must fit into 112 bits + */ + function toInt112(int256 value) internal pure returns (int112 downcasted) { + downcasted = int112(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(112, value); + } + } + + /** + * @dev Returns the downcasted int104 from int256, reverting on + * overflow (when the input is less than smallest int104 or + * greater than largest int104). + * + * Counterpart to Solidity's `int104` operator. + * + * Requirements: + * + * - input must fit into 104 bits + */ + function toInt104(int256 value) internal pure returns (int104 downcasted) { + downcasted = int104(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(104, value); + } + } + + /** + * @dev Returns the downcasted int96 from int256, reverting on + * overflow (when the input is less than smallest int96 or + * greater than largest int96). + * + * Counterpart to Solidity's `int96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toInt96(int256 value) internal pure returns (int96 downcasted) { + downcasted = int96(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(96, value); + } + } + + /** + * @dev Returns the downcasted int88 from int256, reverting on + * overflow (when the input is less than smallest int88 or + * greater than largest int88). + * + * Counterpart to Solidity's `int88` operator. + * + * Requirements: + * + * - input must fit into 88 bits + */ + function toInt88(int256 value) internal pure returns (int88 downcasted) { + downcasted = int88(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(88, value); + } + } + + /** + * @dev Returns the downcasted int80 from int256, reverting on + * overflow (when the input is less than smallest int80 or + * greater than largest int80). + * + * Counterpart to Solidity's `int80` operator. + * + * Requirements: + * + * - input must fit into 80 bits + */ + function toInt80(int256 value) internal pure returns (int80 downcasted) { + downcasted = int80(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(80, value); + } + } + + /** + * @dev Returns the downcasted int72 from int256, reverting on + * overflow (when the input is less than smallest int72 or + * greater than largest int72). + * + * Counterpart to Solidity's `int72` operator. + * + * Requirements: + * + * - input must fit into 72 bits + */ + function toInt72(int256 value) internal pure returns (int72 downcasted) { + downcasted = int72(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(72, value); + } + } + + /** + * @dev Returns the downcasted int64 from int256, reverting on + * overflow (when the input is less than smallest int64 or + * greater than largest int64). + * + * Counterpart to Solidity's `int64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toInt64(int256 value) internal pure returns (int64 downcasted) { + downcasted = int64(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(64, value); + } + } + + /** + * @dev Returns the downcasted int56 from int256, reverting on + * overflow (when the input is less than smallest int56 or + * greater than largest int56). + * + * Counterpart to Solidity's `int56` operator. + * + * Requirements: + * + * - input must fit into 56 bits + */ + function toInt56(int256 value) internal pure returns (int56 downcasted) { + downcasted = int56(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(56, value); + } + } + + /** + * @dev Returns the downcasted int48 from int256, reverting on + * overflow (when the input is less than smallest int48 or + * greater than largest int48). + * + * Counterpart to Solidity's `int48` operator. + * + * Requirements: + * + * - input must fit into 48 bits + */ + function toInt48(int256 value) internal pure returns (int48 downcasted) { + downcasted = int48(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(48, value); + } + } + + /** + * @dev Returns the downcasted int40 from int256, reverting on + * overflow (when the input is less than smallest int40 or + * greater than largest int40). + * + * Counterpart to Solidity's `int40` operator. + * + * Requirements: + * + * - input must fit into 40 bits + */ + function toInt40(int256 value) internal pure returns (int40 downcasted) { + downcasted = int40(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(40, value); + } + } + + /** + * @dev Returns the downcasted int32 from int256, reverting on + * overflow (when the input is less than smallest int32 or + * greater than largest int32). + * + * Counterpart to Solidity's `int32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toInt32(int256 value) internal pure returns (int32 downcasted) { + downcasted = int32(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(32, value); + } + } + + /** + * @dev Returns the downcasted int24 from int256, reverting on + * overflow (when the input is less than smallest int24 or + * greater than largest int24). + * + * Counterpart to Solidity's `int24` operator. + * + * Requirements: + * + * - input must fit into 24 bits + */ + function toInt24(int256 value) internal pure returns (int24 downcasted) { + downcasted = int24(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(24, value); + } + } + + /** + * @dev Returns the downcasted int16 from int256, reverting on + * overflow (when the input is less than smallest int16 or + * greater than largest int16). + * + * Counterpart to Solidity's `int16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toInt16(int256 value) internal pure returns (int16 downcasted) { + downcasted = int16(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(16, value); + } + } + + /** + * @dev Returns the downcasted int8 from int256, reverting on + * overflow (when the input is less than smallest int8 or + * greater than largest int8). + * + * Counterpart to Solidity's `int8` operator. + * + * Requirements: + * + * - input must fit into 8 bits + */ + function toInt8(int256 value) internal pure returns (int8 downcasted) { + downcasted = int8(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(8, value); + } + } + + /** + * @dev Converts an unsigned uint256 into a signed int256. + * + * Requirements: + * + * - input must be less than or equal to maxInt256. + */ + function toInt256(uint256 value) internal pure returns (int256) { + // Note: Unsafe cast below is okay because `type(int256).max` is guaranteed to be positive + if (value > uint256(type(int256).max)) { + revert SafeCastOverflowedUintToInt(value); + } + return int256(value); + } + + /** + * @dev Cast a boolean (false or true) to a uint256 (0 or 1) with no jump. + */ + function toUint(bool b) internal pure returns (uint256 u) { + assembly ("memory-safe") { + u := iszero(iszero(b)) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/Math.sol + + +// OpenZeppelin Contracts (last updated v5.3.0) (utils/math/Math.sol) + +pragma solidity ^0.8.20; + + + +/** + * @dev Standard math utilities missing in the Solidity language. + */ +library Math { + enum Rounding { + Floor, // Toward negative infinity + Ceil, // Toward positive infinity + Trunc, // Toward zero + Expand // Away from zero + } + + /** + * @dev Return the 512-bit addition of two uint256. + * + * The result is stored in two 256 variables such that sum = high * 2²⁵⁶ + low. + */ + function add512(uint256 a, uint256 b) internal pure returns (uint256 high, uint256 low) { + assembly ("memory-safe") { + low := add(a, b) + high := lt(low, a) + } + } + + /** + * @dev Return the 512-bit multiplication of two uint256. + * + * The result is stored in two 256 variables such that product = high * 2²⁵⁶ + low. + */ + function mul512(uint256 a, uint256 b) internal pure returns (uint256 high, uint256 low) { + // 512-bit multiply [high low] = x * y. Compute the product mod 2²⁵⁶ and mod 2²⁵⁶ - 1, then use + // the Chinese Remainder Theorem to reconstruct the 512 bit result. The result is stored in two 256 + // variables such that product = high * 2²⁵⁶ + low. + assembly ("memory-safe") { + let mm := mulmod(a, b, not(0)) + low := mul(a, b) + high := sub(sub(mm, low), lt(mm, low)) + } + } + + /** + * @dev Returns the addition of two unsigned integers, with a success flag (no overflow). + */ + function tryAdd(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a + b; + success = c >= a; + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the subtraction of two unsigned integers, with a success flag (no overflow). + */ + function trySub(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a - b; + success = c <= a; + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the multiplication of two unsigned integers, with a success flag (no overflow). + */ + function tryMul(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a * b; + assembly ("memory-safe") { + // Only true when the multiplication doesn't overflow + // (c / a == b) || (a == 0) + success := or(eq(div(c, a), b), iszero(a)) + } + // equivalent to: success ? c : 0 + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the division of two unsigned integers, with a success flag (no division by zero). + */ + function tryDiv(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + success = b > 0; + assembly ("memory-safe") { + // The `DIV` opcode returns zero when the denominator is 0. + result := div(a, b) + } + } + } + + /** + * @dev Returns the remainder of dividing two unsigned integers, with a success flag (no division by zero). + */ + function tryMod(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + success = b > 0; + assembly ("memory-safe") { + // The `MOD` opcode returns zero when the denominator is 0. + result := mod(a, b) + } + } + } + + /** + * @dev Unsigned saturating addition, bounds to `2²⁵⁶ - 1` instead of overflowing. + */ + function saturatingAdd(uint256 a, uint256 b) internal pure returns (uint256) { + (bool success, uint256 result) = tryAdd(a, b); + return ternary(success, result, type(uint256).max); + } + + /** + * @dev Unsigned saturating subtraction, bounds to zero instead of overflowing. + */ + function saturatingSub(uint256 a, uint256 b) internal pure returns (uint256) { + (, uint256 result) = trySub(a, b); + return result; + } + + /** + * @dev Unsigned saturating multiplication, bounds to `2²⁵⁶ - 1` instead of overflowing. + */ + function saturatingMul(uint256 a, uint256 b) internal pure returns (uint256) { + (bool success, uint256 result) = tryMul(a, b); + return ternary(success, result, type(uint256).max); + } + + /** + * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * + * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. + * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * one branch when needed, making this function more expensive. + */ + function ternary(bool condition, uint256 a, uint256 b) internal pure returns (uint256) { + unchecked { + // branchless ternary works because: + // b ^ (a ^ b) == a + // b ^ 0 == b + return b ^ ((a ^ b) * SafeCast.toUint(condition)); + } + } + + /** + * @dev Returns the largest of two numbers. + */ + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return ternary(a > b, a, b); + } + + /** + * @dev Returns the smallest of two numbers. + */ + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return ternary(a < b, a, b); + } + + /** + * @dev Returns the average of two numbers. The result is rounded towards + * zero. + */ + function average(uint256 a, uint256 b) internal pure returns (uint256) { + // (a + b) / 2 can overflow. + return (a & b) + (a ^ b) / 2; + } + + /** + * @dev Returns the ceiling of the division of two numbers. + * + * This differs from standard division with `/` in that it rounds towards infinity instead + * of rounding towards zero. + */ + function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) { + if (b == 0) { + // Guarantee the same behavior as in a regular Solidity division. + Panic.panic(Panic.DIVISION_BY_ZERO); + } + + // The following calculation ensures accurate ceiling division without overflow. + // Since a is non-zero, (a - 1) / b will not overflow. + // The largest possible result occurs when (a - 1) / b is type(uint256).max, + // but the largest value we can obtain is type(uint256).max - 1, which happens + // when a = type(uint256).max and b = 1. + unchecked { + return SafeCast.toUint(a > 0) * ((a - 1) / b + 1); + } + } + + /** + * @dev Calculates floor(x * y / denominator) with full precision. Throws if result overflows a uint256 or + * denominator == 0. + * + * Original credit to Remco Bloemen under MIT license (https://xn--2-umb.com/21/muldiv) with further edits by + * Uniswap Labs also under MIT license. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { + unchecked { + (uint256 high, uint256 low) = mul512(x, y); + + // Handle non-overflow cases, 256 by 256 division. + if (high == 0) { + // Solidity will revert if denominator == 0, unlike the div opcode on its own. + // The surrounding unchecked block does not change this fact. + // See https://docs.soliditylang.org/en/latest/control-structures.html#checked-or-unchecked-arithmetic. + return low / denominator; + } + + // Make sure the result is less than 2²⁵⁶. Also prevents denominator == 0. + if (denominator <= high) { + Panic.panic(ternary(denominator == 0, Panic.DIVISION_BY_ZERO, Panic.UNDER_OVERFLOW)); + } + + /////////////////////////////////////////////// + // 512 by 256 division. + /////////////////////////////////////////////// + + // Make division exact by subtracting the remainder from [high low]. + uint256 remainder; + assembly ("memory-safe") { + // Compute remainder using mulmod. + remainder := mulmod(x, y, denominator) + + // Subtract 256 bit number from 512 bit number. + high := sub(high, gt(remainder, low)) + low := sub(low, remainder) + } + + // Factor powers of two out of denominator and compute largest power of two divisor of denominator. + // Always >= 1. See https://cs.stackexchange.com/q/138556/92363. + + uint256 twos = denominator & (0 - denominator); + assembly ("memory-safe") { + // Divide denominator by twos. + denominator := div(denominator, twos) + + // Divide [high low] by twos. + low := div(low, twos) + + // Flip twos such that it is 2²⁵⁶ / twos. If twos is zero, then it becomes one. + twos := add(div(sub(0, twos), twos), 1) + } + + // Shift in bits from high into low. + low |= high * twos; + + // Invert denominator mod 2²⁵⁶. Now that denominator is an odd number, it has an inverse modulo 2²⁵⁶ such + // that denominator * inv ≡ 1 mod 2²⁵⁶. Compute the inverse by starting with a seed that is correct for + // four bits. That is, denominator * inv ≡ 1 mod 2⁴. + uint256 inverse = (3 * denominator) ^ 2; + + // Use the Newton-Raphson iteration to improve the precision. Thanks to Hensel's lifting lemma, this also + // works in modular arithmetic, doubling the correct bits in each step. + inverse *= 2 - denominator * inverse; // inverse mod 2⁸ + inverse *= 2 - denominator * inverse; // inverse mod 2¹⁶ + inverse *= 2 - denominator * inverse; // inverse mod 2³² + inverse *= 2 - denominator * inverse; // inverse mod 2⁶⁴ + inverse *= 2 - denominator * inverse; // inverse mod 2¹²⁸ + inverse *= 2 - denominator * inverse; // inverse mod 2²⁵⁶ + + // Because the division is now exact we can divide by multiplying with the modular inverse of denominator. + // This will give us the correct result modulo 2²⁵⁶. Since the preconditions guarantee that the outcome is + // less than 2²⁵⁶, this is the final result. We don't need to compute the high bits of the result and high + // is no longer required. + result = low * inverse; + return result; + } + } + + /** + * @dev Calculates x * y / denominator with full precision, following the selected rounding direction. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator, Rounding rounding) internal pure returns (uint256) { + return mulDiv(x, y, denominator) + SafeCast.toUint(unsignedRoundsUp(rounding) && mulmod(x, y, denominator) > 0); + } + + /** + * @dev Calculates floor(x * y >> n) with full precision. Throws if result overflows a uint256. + */ + function mulShr(uint256 x, uint256 y, uint8 n) internal pure returns (uint256 result) { + unchecked { + (uint256 high, uint256 low) = mul512(x, y); + if (high >= 1 << n) { + Panic.panic(Panic.UNDER_OVERFLOW); + } + return (high << (256 - n)) | (low >> n); + } + } + + /** + * @dev Calculates x * y >> n with full precision, following the selected rounding direction. + */ + function mulShr(uint256 x, uint256 y, uint8 n, Rounding rounding) internal pure returns (uint256) { + return mulShr(x, y, n) + SafeCast.toUint(unsignedRoundsUp(rounding) && mulmod(x, y, 1 << n) > 0); + } + + /** + * @dev Calculate the modular multiplicative inverse of a number in Z/nZ. + * + * If n is a prime, then Z/nZ is a field. In that case all elements are inversible, except 0. + * If n is not a prime, then Z/nZ is not a field, and some elements might not be inversible. + * + * If the input value is not inversible, 0 is returned. + * + * NOTE: If you know for sure that n is (big) a prime, it may be cheaper to use Fermat's little theorem and get the + * inverse using `Math.modExp(a, n - 2, n)`. See {invModPrime}. + */ + function invMod(uint256 a, uint256 n) internal pure returns (uint256) { + unchecked { + if (n == 0) return 0; + + // The inverse modulo is calculated using the Extended Euclidean Algorithm (iterative version) + // Used to compute integers x and y such that: ax + ny = gcd(a, n). + // When the gcd is 1, then the inverse of a modulo n exists and it's x. + // ax + ny = 1 + // ax = 1 + (-y)n + // ax ≡ 1 (mod n) # x is the inverse of a modulo n + + // If the remainder is 0 the gcd is n right away. + uint256 remainder = a % n; + uint256 gcd = n; + + // Therefore the initial coefficients are: + // ax + ny = gcd(a, n) = n + // 0a + 1n = n + int256 x = 0; + int256 y = 1; + + while (remainder != 0) { + uint256 quotient = gcd / remainder; + + (gcd, remainder) = ( + // The old remainder is the next gcd to try. + remainder, + // Compute the next remainder. + // Can't overflow given that (a % gcd) * (gcd // (a % gcd)) <= gcd + // where gcd is at most n (capped to type(uint256).max) + gcd - remainder * quotient + ); + + (x, y) = ( + // Increment the coefficient of a. + y, + // Decrement the coefficient of n. + // Can overflow, but the result is casted to uint256 so that the + // next value of y is "wrapped around" to a value between 0 and n - 1. + x - y * int256(quotient) + ); + } + + if (gcd != 1) return 0; // No inverse exists. + return ternary(x < 0, n - uint256(-x), uint256(x)); // Wrap the result if it's negative. + } + } + + /** + * @dev Variant of {invMod}. More efficient, but only works if `p` is known to be a prime greater than `2`. + * + * From https://en.wikipedia.org/wiki/Fermat%27s_little_theorem[Fermat's little theorem], we know that if p is + * prime, then `a**(p-1) ≡ 1 mod p`. As a consequence, we have `a * a**(p-2) ≡ 1 mod p`, which means that + * `a**(p-2)` is the modular multiplicative inverse of a in Fp. + * + * NOTE: this function does NOT check that `p` is a prime greater than `2`. + */ + function invModPrime(uint256 a, uint256 p) internal view returns (uint256) { + unchecked { + return Math.modExp(a, p - 2, p); + } + } + + /** + * @dev Returns the modular exponentiation of the specified base, exponent and modulus (b ** e % m) + * + * Requirements: + * - modulus can't be zero + * - underlying staticcall to precompile must succeed + * + * IMPORTANT: The result is only valid if the underlying call succeeds. When using this function, make + * sure the chain you're using it on supports the precompiled contract for modular exponentiation + * at address 0x05 as specified in https://eips.ethereum.org/EIPS/eip-198[EIP-198]. Otherwise, + * the underlying function will succeed given the lack of a revert, but the result may be incorrectly + * interpreted as 0. + */ + function modExp(uint256 b, uint256 e, uint256 m) internal view returns (uint256) { + (bool success, uint256 result) = tryModExp(b, e, m); + if (!success) { + Panic.panic(Panic.DIVISION_BY_ZERO); + } + return result; + } + + /** + * @dev Returns the modular exponentiation of the specified base, exponent and modulus (b ** e % m). + * It includes a success flag indicating if the operation succeeded. Operation will be marked as failed if trying + * to operate modulo 0 or if the underlying precompile reverted. + * + * IMPORTANT: The result is only valid if the success flag is true. When using this function, make sure the chain + * you're using it on supports the precompiled contract for modular exponentiation at address 0x05 as specified in + * https://eips.ethereum.org/EIPS/eip-198[EIP-198]. Otherwise, the underlying function will succeed given the lack + * of a revert, but the result may be incorrectly interpreted as 0. + */ + function tryModExp(uint256 b, uint256 e, uint256 m) internal view returns (bool success, uint256 result) { + if (m == 0) return (false, 0); + assembly ("memory-safe") { + let ptr := mload(0x40) + // | Offset | Content | Content (Hex) | + // |-----------|------------|--------------------------------------------------------------------| + // | 0x00:0x1f | size of b | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x20:0x3f | size of e | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x40:0x5f | size of m | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x60:0x7f | value of b | 0x<.............................................................b> | + // | 0x80:0x9f | value of e | 0x<.............................................................e> | + // | 0xa0:0xbf | value of m | 0x<.............................................................m> | + mstore(ptr, 0x20) + mstore(add(ptr, 0x20), 0x20) + mstore(add(ptr, 0x40), 0x20) + mstore(add(ptr, 0x60), b) + mstore(add(ptr, 0x80), e) + mstore(add(ptr, 0xa0), m) + + // Given the result < m, it's guaranteed to fit in 32 bytes, + // so we can use the memory scratch space located at offset 0. + success := staticcall(gas(), 0x05, ptr, 0xc0, 0x00, 0x20) + result := mload(0x00) + } + } + + /** + * @dev Variant of {modExp} that supports inputs of arbitrary length. + */ + function modExp(bytes memory b, bytes memory e, bytes memory m) internal view returns (bytes memory) { + (bool success, bytes memory result) = tryModExp(b, e, m); + if (!success) { + Panic.panic(Panic.DIVISION_BY_ZERO); + } + return result; + } + + /** + * @dev Variant of {tryModExp} that supports inputs of arbitrary length. + */ + function tryModExp( + bytes memory b, + bytes memory e, + bytes memory m + ) internal view returns (bool success, bytes memory result) { + if (_zeroBytes(m)) return (false, new bytes(0)); + + uint256 mLen = m.length; + + // Encode call args in result and move the free memory pointer + result = abi.encodePacked(b.length, e.length, mLen, b, e, m); + + assembly ("memory-safe") { + let dataPtr := add(result, 0x20) + // Write result on top of args to avoid allocating extra memory. + success := staticcall(gas(), 0x05, dataPtr, mload(result), dataPtr, mLen) + // Overwrite the length. + // result.length > returndatasize() is guaranteed because returndatasize() == m.length + mstore(result, mLen) + // Set the memory pointer after the returned data. + mstore(0x40, add(dataPtr, mLen)) + } + } + + /** + * @dev Returns whether the provided byte array is zero. + */ + function _zeroBytes(bytes memory byteArray) private pure returns (bool) { + for (uint256 i = 0; i < byteArray.length; ++i) { + if (byteArray[i] != 0) { + return false; + } + } + return true; + } + + /** + * @dev Returns the square root of a number. If the number is not a perfect square, the value is rounded + * towards zero. + * + * This method is based on Newton's method for computing square roots; the algorithm is restricted to only + * using integer operations. + */ + function sqrt(uint256 a) internal pure returns (uint256) { + unchecked { + // Take care of easy edge cases when a == 0 or a == 1 + if (a <= 1) { + return a; + } + + // In this function, we use Newton's method to get a root of `f(x) := x² - a`. It involves building a + // sequence x_n that converges toward sqrt(a). For each iteration x_n, we also define the error between + // the current value as `ε_n = | x_n - sqrt(a) |`. + // + // For our first estimation, we consider `e` the smallest power of 2 which is bigger than the square root + // of the target. (i.e. `2**(e-1) ≤ sqrt(a) < 2**e`). We know that `e ≤ 128` because `(2¹²⁸)² = 2²⁵⁶` is + // bigger than any uint256. + // + // By noticing that + // `2**(e-1) ≤ sqrt(a) < 2**e → (2**(e-1))² ≤ a < (2**e)² → 2**(2*e-2) ≤ a < 2**(2*e)` + // we can deduce that `e - 1` is `log2(a) / 2`. We can thus compute `x_n = 2**(e-1)` using a method similar + // to the msb function. + uint256 aa = a; + uint256 xn = 1; + + if (aa >= (1 << 128)) { + aa >>= 128; + xn <<= 64; + } + if (aa >= (1 << 64)) { + aa >>= 64; + xn <<= 32; + } + if (aa >= (1 << 32)) { + aa >>= 32; + xn <<= 16; + } + if (aa >= (1 << 16)) { + aa >>= 16; + xn <<= 8; + } + if (aa >= (1 << 8)) { + aa >>= 8; + xn <<= 4; + } + if (aa >= (1 << 4)) { + aa >>= 4; + xn <<= 2; + } + if (aa >= (1 << 2)) { + xn <<= 1; + } + + // We now have x_n such that `x_n = 2**(e-1) ≤ sqrt(a) < 2**e = 2 * x_n`. This implies ε_n ≤ 2**(e-1). + // + // We can refine our estimation by noticing that the middle of that interval minimizes the error. + // If we move x_n to equal 2**(e-1) + 2**(e-2), then we reduce the error to ε_n ≤ 2**(e-2). + // This is going to be our x_0 (and ε_0) + xn = (3 * xn) >> 1; // ε_0 := | x_0 - sqrt(a) | ≤ 2**(e-2) + + // From here, Newton's method give us: + // x_{n+1} = (x_n + a / x_n) / 2 + // + // One should note that: + // x_{n+1}² - a = ((x_n + a / x_n) / 2)² - a + // = ((x_n² + a) / (2 * x_n))² - a + // = (x_n⁴ + 2 * a * x_n² + a²) / (4 * x_n²) - a + // = (x_n⁴ + 2 * a * x_n² + a² - 4 * a * x_n²) / (4 * x_n²) + // = (x_n⁴ - 2 * a * x_n² + a²) / (4 * x_n²) + // = (x_n² - a)² / (2 * x_n)² + // = ((x_n² - a) / (2 * x_n))² + // ≥ 0 + // Which proves that for all n ≥ 1, sqrt(a) ≤ x_n + // + // This gives us the proof of quadratic convergence of the sequence: + // ε_{n+1} = | x_{n+1} - sqrt(a) | + // = | (x_n + a / x_n) / 2 - sqrt(a) | + // = | (x_n² + a - 2*x_n*sqrt(a)) / (2 * x_n) | + // = | (x_n - sqrt(a))² / (2 * x_n) | + // = | ε_n² / (2 * x_n) | + // = ε_n² / | (2 * x_n) | + // + // For the first iteration, we have a special case where x_0 is known: + // ε_1 = ε_0² / | (2 * x_0) | + // ≤ (2**(e-2))² / (2 * (2**(e-1) + 2**(e-2))) + // ≤ 2**(2*e-4) / (3 * 2**(e-1)) + // ≤ 2**(e-3) / 3 + // ≤ 2**(e-3-log2(3)) + // ≤ 2**(e-4.5) + // + // For the following iterations, we use the fact that, 2**(e-1) ≤ sqrt(a) ≤ x_n: + // ε_{n+1} = ε_n² / | (2 * x_n) | + // ≤ (2**(e-k))² / (2 * 2**(e-1)) + // ≤ 2**(2*e-2*k) / 2**e + // ≤ 2**(e-2*k) + xn = (xn + a / xn) >> 1; // ε_1 := | x_1 - sqrt(a) | ≤ 2**(e-4.5) -- special case, see above + xn = (xn + a / xn) >> 1; // ε_2 := | x_2 - sqrt(a) | ≤ 2**(e-9) -- general case with k = 4.5 + xn = (xn + a / xn) >> 1; // ε_3 := | x_3 - sqrt(a) | ≤ 2**(e-18) -- general case with k = 9 + xn = (xn + a / xn) >> 1; // ε_4 := | x_4 - sqrt(a) | ≤ 2**(e-36) -- general case with k = 18 + xn = (xn + a / xn) >> 1; // ε_5 := | x_5 - sqrt(a) | ≤ 2**(e-72) -- general case with k = 36 + xn = (xn + a / xn) >> 1; // ε_6 := | x_6 - sqrt(a) | ≤ 2**(e-144) -- general case with k = 72 + + // Because e ≤ 128 (as discussed during the first estimation phase), we know have reached a precision + // ε_6 ≤ 2**(e-144) < 1. Given we're operating on integers, then we can ensure that xn is now either + // sqrt(a) or sqrt(a) + 1. + return xn - SafeCast.toUint(xn > a / xn); + } + } + + /** + * @dev Calculates sqrt(a), following the selected rounding direction. + */ + function sqrt(uint256 a, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = sqrt(a); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && result * result < a); + } + } + + /** + * @dev Return the log in base 2 of a positive value rounded towards zero. + * Returns 0 if given 0. + */ + function log2(uint256 x) internal pure returns (uint256 r) { + // If value has upper 128 bits set, log2 result is at least 128 + r = SafeCast.toUint(x > 0xffffffffffffffffffffffffffffffff) << 7; + // If upper 64 bits of 128-bit half set, add 64 to result + r |= SafeCast.toUint((x >> r) > 0xffffffffffffffff) << 6; + // If upper 32 bits of 64-bit half set, add 32 to result + r |= SafeCast.toUint((x >> r) > 0xffffffff) << 5; + // If upper 16 bits of 32-bit half set, add 16 to result + r |= SafeCast.toUint((x >> r) > 0xffff) << 4; + // If upper 8 bits of 16-bit half set, add 8 to result + r |= SafeCast.toUint((x >> r) > 0xff) << 3; + // If upper 4 bits of 8-bit half set, add 4 to result + r |= SafeCast.toUint((x >> r) > 0xf) << 2; + + // Shifts value right by the current result and use it as an index into this lookup table: + // + // | x (4 bits) | index | table[index] = MSB position | + // |------------|---------|-----------------------------| + // | 0000 | 0 | table[0] = 0 | + // | 0001 | 1 | table[1] = 0 | + // | 0010 | 2 | table[2] = 1 | + // | 0011 | 3 | table[3] = 1 | + // | 0100 | 4 | table[4] = 2 | + // | 0101 | 5 | table[5] = 2 | + // | 0110 | 6 | table[6] = 2 | + // | 0111 | 7 | table[7] = 2 | + // | 1000 | 8 | table[8] = 3 | + // | 1001 | 9 | table[9] = 3 | + // | 1010 | 10 | table[10] = 3 | + // | 1011 | 11 | table[11] = 3 | + // | 1100 | 12 | table[12] = 3 | + // | 1101 | 13 | table[13] = 3 | + // | 1110 | 14 | table[14] = 3 | + // | 1111 | 15 | table[15] = 3 | + // + // The lookup table is represented as a 32-byte value with the MSB positions for 0-15 in the last 16 bytes. + assembly ("memory-safe") { + r := or(r, byte(shr(r, x), 0x0000010102020202030303030303030300000000000000000000000000000000)) + } + } + + /** + * @dev Return the log in base 2, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log2(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log2(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 1 << result < value); + } + } + + /** + * @dev Return the log in base 10 of a positive value rounded towards zero. + * Returns 0 if given 0. + */ + function log10(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >= 10 ** 64) { + value /= 10 ** 64; + result += 64; + } + if (value >= 10 ** 32) { + value /= 10 ** 32; + result += 32; + } + if (value >= 10 ** 16) { + value /= 10 ** 16; + result += 16; + } + if (value >= 10 ** 8) { + value /= 10 ** 8; + result += 8; + } + if (value >= 10 ** 4) { + value /= 10 ** 4; + result += 4; + } + if (value >= 10 ** 2) { + value /= 10 ** 2; + result += 2; + } + if (value >= 10 ** 1) { + result += 1; + } + } + return result; + } + + /** + * @dev Return the log in base 10, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log10(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log10(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 10 ** result < value); + } + } + + /** + * @dev Return the log in base 256 of a positive value rounded towards zero. + * Returns 0 if given 0. + * + * Adding one to the result gives the number of pairs of hex symbols needed to represent `value` as a hex string. + */ + function log256(uint256 x) internal pure returns (uint256 r) { + // If value has upper 128 bits set, log2 result is at least 128 + r = SafeCast.toUint(x > 0xffffffffffffffffffffffffffffffff) << 7; + // If upper 64 bits of 128-bit half set, add 64 to result + r |= SafeCast.toUint((x >> r) > 0xffffffffffffffff) << 6; + // If upper 32 bits of 64-bit half set, add 32 to result + r |= SafeCast.toUint((x >> r) > 0xffffffff) << 5; + // If upper 16 bits of 32-bit half set, add 16 to result + r |= SafeCast.toUint((x >> r) > 0xffff) << 4; + // Add 1 if upper 8 bits of 16-bit half set, and divide accumulated result by 8 + return (r >> 3) | SafeCast.toUint((x >> r) > 0xff); + } + + /** + * @dev Return the log in base 256, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log256(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log256(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 1 << (result << 3) < value); + } + } + + /** + * @dev Returns whether a provided rounding mode is considered rounding up for unsigned integers. + */ + function unsignedRoundsUp(Rounding rounding) internal pure returns (bool) { + return uint8(rounding) % 2 == 1; + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/SignedMath.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/math/SignedMath.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Standard signed math utilities missing in the Solidity language. + */ +library SignedMath { + /** + * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * + * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. + * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * one branch when needed, making this function more expensive. + */ + function ternary(bool condition, int256 a, int256 b) internal pure returns (int256) { + unchecked { + // branchless ternary works because: + // b ^ (a ^ b) == a + // b ^ 0 == b + return b ^ ((a ^ b) * int256(SafeCast.toUint(condition))); + } + } + + /** + * @dev Returns the largest of two signed numbers. + */ + function max(int256 a, int256 b) internal pure returns (int256) { + return ternary(a > b, a, b); + } + + /** + * @dev Returns the smallest of two signed numbers. + */ + function min(int256 a, int256 b) internal pure returns (int256) { + return ternary(a < b, a, b); + } + + /** + * @dev Returns the average of two signed numbers without overflow. + * The result is rounded towards zero. + */ + function average(int256 a, int256 b) internal pure returns (int256) { + // Formula from the book "Hacker's Delight" + int256 x = (a & b) + ((a ^ b) >> 1); + return x + (int256(uint256(x) >> 255) & (a ^ b)); + } + + /** + * @dev Returns the absolute unsigned value of a signed value. + */ + function abs(int256 n) internal pure returns (uint256) { + unchecked { + // Formula from the "Bit Twiddling Hacks" by Sean Eron Anderson. + // Since `n` is a signed integer, the generated bytecode will use the SAR opcode to perform the right shift, + // taking advantage of the most significant (or "sign" bit) in two's complement representation. + // This opcode adds new most significant bits set to the value of the previous most significant bit. As a result, + // the mask will either be `bytes32(0)` (if n is positive) or `~bytes32(0)` (if n is negative). + int256 mask = n >> 255; + + // A `bytes32(0)` mask leaves the input unchanged, while a `~bytes32(0)` mask complements it. + return uint256((n + mask) ^ mask); + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Strings.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/Strings.sol) + +pragma solidity ^0.8.20; + + + + +/** + * @dev String operations. + */ +library Strings { + using SafeCast for *; + + bytes16 private constant HEX_DIGITS = "0123456789abcdef"; + uint8 private constant ADDRESS_LENGTH = 20; + uint256 private constant SPECIAL_CHARS_LOOKUP = + (1 << 0x08) | // backspace + (1 << 0x09) | // tab + (1 << 0x0a) | // newline + (1 << 0x0c) | // form feed + (1 << 0x0d) | // carriage return + (1 << 0x22) | // double quote + (1 << 0x5c); // backslash + + /** + * @dev The `value` string doesn't fit in the specified `length`. + */ + error StringsInsufficientHexLength(uint256 value, uint256 length); + + /** + * @dev The string being parsed contains characters that are not in scope of the given base. + */ + error StringsInvalidChar(); + + /** + * @dev The string being parsed is not a properly formatted address. + */ + error StringsInvalidAddressFormat(); + + /** + * @dev Converts a `uint256` to its ASCII `string` decimal representation. + */ + function toString(uint256 value) internal pure returns (string memory) { + unchecked { + uint256 length = Math.log10(value) + 1; + string memory buffer = new string(length); + uint256 ptr; + assembly ("memory-safe") { + ptr := add(add(buffer, 0x20), length) + } + while (true) { + ptr--; + assembly ("memory-safe") { + mstore8(ptr, byte(mod(value, 10), HEX_DIGITS)) + } + value /= 10; + if (value == 0) break; + } + return buffer; + } + } + + /** + * @dev Converts a `int256` to its ASCII `string` decimal representation. + */ + function toStringSigned(int256 value) internal pure returns (string memory) { + return string.concat(value < 0 ? "-" : "", toString(SignedMath.abs(value))); + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation. + */ + function toHexString(uint256 value) internal pure returns (string memory) { + unchecked { + return toHexString(value, Math.log256(value) + 1); + } + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. + */ + function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { + uint256 localValue = value; + bytes memory buffer = new bytes(2 * length + 2); + buffer[0] = "0"; + buffer[1] = "x"; + for (uint256 i = 2 * length + 1; i > 1; --i) { + buffer[i] = HEX_DIGITS[localValue & 0xf]; + localValue >>= 4; + } + if (localValue != 0) { + revert StringsInsufficientHexLength(value, length); + } + return string(buffer); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its not checksummed ASCII `string` hexadecimal + * representation. + */ + function toHexString(address addr) internal pure returns (string memory) { + return toHexString(uint256(uint160(addr)), ADDRESS_LENGTH); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its checksummed ASCII `string` hexadecimal + * representation, according to EIP-55. + */ + function toChecksumHexString(address addr) internal pure returns (string memory) { + bytes memory buffer = bytes(toHexString(addr)); + + // hash the hex part of buffer (skip length + 2 bytes, length 40) + uint256 hashValue; + assembly ("memory-safe") { + hashValue := shr(96, keccak256(add(buffer, 0x22), 40)) + } + + for (uint256 i = 41; i > 1; --i) { + // possible values for buffer[i] are 48 (0) to 57 (9) and 97 (a) to 102 (f) + if (hashValue & 0xf > 7 && uint8(buffer[i]) > 96) { + // case shift by xoring with 0x20 + buffer[i] ^= 0x20; + } + hashValue >>= 4; + } + return string(buffer); + } + + /** + * @dev Returns true if the two strings are equal. + */ + function equal(string memory a, string memory b) internal pure returns (bool) { + return bytes(a).length == bytes(b).length && keccak256(bytes(a)) == keccak256(bytes(b)); + } + + /** + * @dev Parse a decimal string and returns the value as a `uint256`. + * + * Requirements: + * - The string must be formatted as `[0-9]*` + * - The result must fit into an `uint256` type + */ + function parseUint(string memory input) internal pure returns (uint256) { + return parseUint(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseUint-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `[0-9]*` + * - The result must fit into an `uint256` type + */ + function parseUint(string memory input, uint256 begin, uint256 end) internal pure returns (uint256) { + (bool success, uint256 value) = tryParseUint(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseUint-string} that returns false if the parsing fails because of an invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseUint(string memory input) internal pure returns (bool success, uint256 value) { + return _tryParseUintUncheckedBounds(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseUint-string-uint256-uint256} that returns false if the parsing fails because of an invalid + * character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseUint( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, uint256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseUintUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseUint-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseUintUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, uint256 value) { + bytes memory buffer = bytes(input); + + uint256 result = 0; + for (uint256 i = begin; i < end; ++i) { + uint8 chr = _tryParseChr(bytes1(_unsafeReadBytesOffset(buffer, i))); + if (chr > 9) return (false, 0); + result *= 10; + result += chr; + } + return (true, result); + } + + /** + * @dev Parse a decimal string and returns the value as a `int256`. + * + * Requirements: + * - The string must be formatted as `[-+]?[0-9]*` + * - The result must fit in an `int256` type. + */ + function parseInt(string memory input) internal pure returns (int256) { + return parseInt(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseInt-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `[-+]?[0-9]*` + * - The result must fit in an `int256` type. + */ + function parseInt(string memory input, uint256 begin, uint256 end) internal pure returns (int256) { + (bool success, int256 value) = tryParseInt(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseInt-string} that returns false if the parsing fails because of an invalid character or if + * the result does not fit in a `int256`. + * + * NOTE: This function will revert if the absolute value of the result does not fit in a `uint256`. + */ + function tryParseInt(string memory input) internal pure returns (bool success, int256 value) { + return _tryParseIntUncheckedBounds(input, 0, bytes(input).length); + } + + uint256 private constant ABS_MIN_INT256 = 2 ** 255; + + /** + * @dev Variant of {parseInt-string-uint256-uint256} that returns false if the parsing fails because of an invalid + * character or if the result does not fit in a `int256`. + * + * NOTE: This function will revert if the absolute value of the result does not fit in a `uint256`. + */ + function tryParseInt( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, int256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseIntUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseInt-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseIntUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, int256 value) { + bytes memory buffer = bytes(input); + + // Check presence of a negative sign. + bytes1 sign = begin == end ? bytes1(0) : bytes1(_unsafeReadBytesOffset(buffer, begin)); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + bool positiveSign = sign == bytes1("+"); + bool negativeSign = sign == bytes1("-"); + uint256 offset = (positiveSign || negativeSign).toUint(); + + (bool absSuccess, uint256 absValue) = tryParseUint(input, begin + offset, end); + + if (absSuccess && absValue < ABS_MIN_INT256) { + return (true, negativeSign ? -int256(absValue) : int256(absValue)); + } else if (absSuccess && negativeSign && absValue == ABS_MIN_INT256) { + return (true, type(int256).min); + } else return (false, 0); + } + + /** + * @dev Parse a hexadecimal string (with or without "0x" prefix), and returns the value as a `uint256`. + * + * Requirements: + * - The string must be formatted as `(0x)?[0-9a-fA-F]*` + * - The result must fit in an `uint256` type. + */ + function parseHexUint(string memory input) internal pure returns (uint256) { + return parseHexUint(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseHexUint-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `(0x)?[0-9a-fA-F]*` + * - The result must fit in an `uint256` type. + */ + function parseHexUint(string memory input, uint256 begin, uint256 end) internal pure returns (uint256) { + (bool success, uint256 value) = tryParseHexUint(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseHexUint-string} that returns false if the parsing fails because of an invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseHexUint(string memory input) internal pure returns (bool success, uint256 value) { + return _tryParseHexUintUncheckedBounds(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseHexUint-string-uint256-uint256} that returns false if the parsing fails because of an + * invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseHexUint( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, uint256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseHexUintUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseHexUint-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseHexUintUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, uint256 value) { + bytes memory buffer = bytes(input); + + // skip 0x prefix if present + bool hasPrefix = (end > begin + 1) && bytes2(_unsafeReadBytesOffset(buffer, begin)) == bytes2("0x"); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + uint256 offset = hasPrefix.toUint() * 2; + + uint256 result = 0; + for (uint256 i = begin + offset; i < end; ++i) { + uint8 chr = _tryParseChr(bytes1(_unsafeReadBytesOffset(buffer, i))); + if (chr > 15) return (false, 0); + result *= 16; + unchecked { + // Multiplying by 16 is equivalent to a shift of 4 bits (with additional overflow check). + // This guarantees that adding a value < 16 will not cause an overflow, hence the unchecked. + result += chr; + } + } + return (true, result); + } + + /** + * @dev Parse a hexadecimal string (with or without "0x" prefix), and returns the value as an `address`. + * + * Requirements: + * - The string must be formatted as `(0x)?[0-9a-fA-F]{40}` + */ + function parseAddress(string memory input) internal pure returns (address) { + return parseAddress(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseAddress-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `(0x)?[0-9a-fA-F]{40}` + */ + function parseAddress(string memory input, uint256 begin, uint256 end) internal pure returns (address) { + (bool success, address value) = tryParseAddress(input, begin, end); + if (!success) revert StringsInvalidAddressFormat(); + return value; + } + + /** + * @dev Variant of {parseAddress-string} that returns false if the parsing fails because the input is not a properly + * formatted address. See {parseAddress-string} requirements. + */ + function tryParseAddress(string memory input) internal pure returns (bool success, address value) { + return tryParseAddress(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseAddress-string-uint256-uint256} that returns false if the parsing fails because input is not a properly + * formatted address. See {parseAddress-string-uint256-uint256} requirements. + */ + function tryParseAddress( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, address value) { + if (end > bytes(input).length || begin > end) return (false, address(0)); + + bool hasPrefix = (end > begin + 1) && bytes2(_unsafeReadBytesOffset(bytes(input), begin)) == bytes2("0x"); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + uint256 expectedLength = 40 + hasPrefix.toUint() * 2; + + // check that input is the correct length + if (end - begin == expectedLength) { + // length guarantees that this does not overflow, and value is at most type(uint160).max + (bool s, uint256 v) = _tryParseHexUintUncheckedBounds(input, begin, end); + return (s, address(uint160(v))); + } else { + return (false, address(0)); + } + } + + function _tryParseChr(bytes1 chr) private pure returns (uint8) { + uint8 value = uint8(chr); + + // Try to parse `chr`: + // - Case 1: [0-9] + // - Case 2: [a-f] + // - Case 3: [A-F] + // - otherwise not supported + unchecked { + if (value > 47 && value < 58) value -= 48; + else if (value > 96 && value < 103) value -= 87; + else if (value > 64 && value < 71) value -= 55; + else return type(uint8).max; + } + + return value; + } + + /** + * @dev Escape special characters in JSON strings. This can be useful to prevent JSON injection in NFT metadata. + * + * WARNING: This function should only be used in double quoted JSON strings. Single quotes are not escaped. + * + * NOTE: This function escapes all unicode characters, and not just the ones in ranges defined in section 2.5 of + * RFC-4627 (U+0000 to U+001F, U+0022 and U+005C). ECMAScript's `JSON.parse` does recover escaped unicode + * characters that are not in this range, but other tooling may provide different results. + */ + function escapeJSON(string memory input) internal pure returns (string memory) { + bytes memory buffer = bytes(input); + bytes memory output = new bytes(2 * buffer.length); // worst case scenario + uint256 outputLength = 0; + + for (uint256 i; i < buffer.length; ++i) { + bytes1 char = bytes1(_unsafeReadBytesOffset(buffer, i)); + if (((SPECIAL_CHARS_LOOKUP & (1 << uint8(char))) != 0)) { + output[outputLength++] = "\\"; + if (char == 0x08) output[outputLength++] = "b"; + else if (char == 0x09) output[outputLength++] = "t"; + else if (char == 0x0a) output[outputLength++] = "n"; + else if (char == 0x0c) output[outputLength++] = "f"; + else if (char == 0x0d) output[outputLength++] = "r"; + else if (char == 0x5c) output[outputLength++] = "\\"; + else if (char == 0x22) { + // solhint-disable-next-line quotes + output[outputLength++] = '"'; + } + } else { + output[outputLength++] = char; + } + } + // write the actual length and deallocate unused memory + assembly ("memory-safe") { + mstore(output, outputLength) + mstore(0x40, add(output, shl(5, shr(5, add(outputLength, 63))))) + } + + return string(output); + } + + /** + * @dev Reads a bytes32 from a bytes array without bounds checking. + * + * NOTE: making this function internal would mean it could be used with memory unsafe offset, and marking the + * assembly block as such would prevent some optimizations. + */ + function _unsafeReadBytesOffset(bytes memory buffer, uint256 offset) private pure returns (bytes32 value) { + // This is not memory safe in the general case, but all calls to this private function are within bounds. + assembly ("memory-safe") { + value := mload(add(add(buffer, 0x20), offset)) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/introspection/ERC165.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/ERC165.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Implementation of the {IERC165} interface. + * + * Contracts that want to implement ERC-165 should inherit from this contract and override {supportsInterface} to check + * for the additional interface id that will be supported. For example: + * + * ```solidity + * function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + * return interfaceId == type(MyInterface).interfaceId || super.supportsInterface(interfaceId); + * } + * ``` + */ +abstract contract ERC165 is IERC165 { + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IERC165).interfaceId; + } +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/ERC721.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/ERC721.sol) + +pragma solidity ^0.8.20; + + + + + + + + +/** + * @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC-721] Non-Fungible Token Standard, including + * the Metadata extension, but not including the Enumerable extension, which is available separately as + * {ERC721Enumerable}. + */ +abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Errors { + using Strings for uint256; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + mapping(uint256 tokenId => address) private _owners; + + mapping(address owner => uint256) private _balances; + + mapping(uint256 tokenId => address) private _tokenApprovals; + + mapping(address owner => mapping(address operator => bool)) private _operatorApprovals; + + /** + * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection. + */ + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC721Metadata).interfaceId || + super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721 + function balanceOf(address owner) public view virtual returns (uint256) { + if (owner == address(0)) { + revert ERC721InvalidOwner(address(0)); + } + return _balances[owner]; + } + + /// @inheritdoc IERC721 + function ownerOf(uint256 tokenId) public view virtual returns (address) { + return _requireOwned(tokenId); + } + + /// @inheritdoc IERC721Metadata + function name() public view virtual returns (string memory) { + return _name; + } + + /// @inheritdoc IERC721Metadata + function symbol() public view virtual returns (string memory) { + return _symbol; + } + + /// @inheritdoc IERC721Metadata + function tokenURI(uint256 tokenId) public view virtual returns (string memory) { + _requireOwned(tokenId); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length > 0 ? string.concat(baseURI, tokenId.toString()) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, can be overridden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + /// @inheritdoc IERC721 + function approve(address to, uint256 tokenId) public virtual { + _approve(to, tokenId, _msgSender()); + } + + /// @inheritdoc IERC721 + function getApproved(uint256 tokenId) public view virtual returns (address) { + _requireOwned(tokenId); + + return _getApproved(tokenId); + } + + /// @inheritdoc IERC721 + function setApprovalForAll(address operator, bool approved) public virtual { + _setApprovalForAll(_msgSender(), operator, approved); + } + + /// @inheritdoc IERC721 + function isApprovedForAll(address owner, address operator) public view virtual returns (bool) { + return _operatorApprovals[owner][operator]; + } + + /// @inheritdoc IERC721 + function transferFrom(address from, address to, uint256 tokenId) public virtual { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + // Setting an "auth" arguments enables the `_isAuthorized` check which verifies that the token exists + // (from != 0). Therefore, it is not needed to verify that the return value is not 0 here. + address previousOwner = _update(to, tokenId, _msgSender()); + if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /// @inheritdoc IERC721 + function safeTransferFrom(address from, address to, uint256 tokenId) public { + safeTransferFrom(from, to, tokenId, ""); + } + + /// @inheritdoc IERC721 + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual { + transferFrom(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } + + /** + * @dev Returns the owner of the `tokenId`. Does NOT revert if token doesn't exist + * + * IMPORTANT: Any overrides to this function that add ownership of tokens not tracked by the + * core ERC-721 logic MUST be matched with the use of {_increaseBalance} to keep balances + * consistent with ownership. The invariant to preserve is that for any address `a` the value returned by + * `balanceOf(a)` must be equal to the number of tokens such that `_ownerOf(tokenId)` is `a`. + */ + function _ownerOf(uint256 tokenId) internal view virtual returns (address) { + return _owners[tokenId]; + } + + /** + * @dev Returns the approved address for `tokenId`. Returns 0 if `tokenId` is not minted. + */ + function _getApproved(uint256 tokenId) internal view virtual returns (address) { + return _tokenApprovals[tokenId]; + } + + /** + * @dev Returns whether `spender` is allowed to manage `owner`'s tokens, or `tokenId` in + * particular (ignoring whether it is owned by `owner`). + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + */ + function _isAuthorized(address owner, address spender, uint256 tokenId) internal view virtual returns (bool) { + return + spender != address(0) && + (owner == spender || isApprovedForAll(owner, spender) || _getApproved(tokenId) == spender); + } + + /** + * @dev Checks if `spender` can operate on `tokenId`, assuming the provided `owner` is the actual owner. + * Reverts if: + * - `spender` does not have approval from `owner` for `tokenId`. + * - `spender` does not have approval to manage all of `owner`'s assets. + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + */ + function _checkAuthorized(address owner, address spender, uint256 tokenId) internal view virtual { + if (!_isAuthorized(owner, spender, tokenId)) { + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else { + revert ERC721InsufficientApproval(spender, tokenId); + } + } + } + + /** + * @dev Unsafe write access to the balances, used by extensions that "mint" tokens using an {ownerOf} override. + * + * NOTE: the value is limited to type(uint128).max. This protect against _balance overflow. It is unrealistic that + * a uint256 would ever overflow from increments when these increments are bounded to uint128 values. + * + * WARNING: Increasing an account's balance using this function tends to be paired with an override of the + * {_ownerOf} function to resolve the ownership of the corresponding tokens so that balances and ownership + * remain consistent with one another. + */ + function _increaseBalance(address account, uint128 value) internal virtual { + unchecked { + _balances[account] += value; + } + } + + /** + * @dev Transfers `tokenId` from its current owner to `to`, or alternatively mints (or burns) if the current owner + * (or `to`) is the zero address. Returns the owner of the `tokenId` before the update. + * + * The `auth` argument is optional. If the value passed is non 0, then this function will check that + * `auth` is either the owner of the token, or approved to operate on the token (by the owner). + * + * Emits a {Transfer} event. + * + * NOTE: If overriding this function in a way that tracks balances, see also {_increaseBalance}. + */ + function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) { + address from = _ownerOf(tokenId); + + // Perform (optional) operator check + if (auth != address(0)) { + _checkAuthorized(from, auth, tokenId); + } + + // Execute the update + if (from != address(0)) { + // Clear approval. No need to re-authorize or emit the Approval event + _approve(address(0), tokenId, address(0), false); + + unchecked { + _balances[from] -= 1; + } + } + + if (to != address(0)) { + unchecked { + _balances[to] += 1; + } + } + + _owners[tokenId] = to; + + emit Transfer(from, to, tokenId); + + return from; + } + + /** + * @dev Mints `tokenId` and transfers it to `to`. + * + * WARNING: Usage of this method is discouraged, use {_safeMint} whenever possible + * + * Requirements: + * + * - `tokenId` must not exist. + * - `to` cannot be the zero address. + * + * Emits a {Transfer} event. + */ + function _mint(address to, uint256 tokenId) internal { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address previousOwner = _update(to, tokenId, address(0)); + if (previousOwner != address(0)) { + revert ERC721InvalidSender(address(0)); + } + } + + /** + * @dev Mints `tokenId`, transfers it to `to` and checks for `to` acceptance. + * + * Requirements: + * + * - `tokenId` must not exist. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function _safeMint(address to, uint256 tokenId) internal { + _safeMint(to, tokenId, ""); + } + + /** + * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is + * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. + */ + function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual { + _mint(to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), address(0), to, tokenId, data); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * This is an internal function that does not check if the sender is authorized to operate on the token. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId) internal { + address previousOwner = _update(address(0), tokenId, address(0)); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * As opposed to {transferFrom}, this imposes no restrictions on msg.sender. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * + * Emits a {Transfer} event. + */ + function _transfer(address from, address to, uint256 tokenId) internal { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address previousOwner = _update(to, tokenId, address(0)); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking that contract recipients + * are aware of the ERC-721 standard to prevent tokens from being forever locked. + * + * `data` is additional data, it has no specified format and it is sent in call to `to`. + * + * This internal function is like {safeTransferFrom} in the sense that it invokes + * {IERC721Receiver-onERC721Received} on the receiver, and can be used to e.g. + * implement alternative mechanisms to perform token transfer, such as signature-based. + * + * Requirements: + * + * - `tokenId` token must exist and be owned by `from`. + * - `to` cannot be the zero address. + * - `from` cannot be the zero address. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function _safeTransfer(address from, address to, uint256 tokenId) internal { + _safeTransfer(from, to, tokenId, ""); + } + + /** + * @dev Same as {xref-ERC721-_safeTransfer-address-address-uint256-}[`_safeTransfer`], with an additional `data` parameter which is + * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. + */ + function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual { + _transfer(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } + + /** + * @dev Approve `to` to operate on `tokenId` + * + * The `auth` argument is optional. If the value passed is non 0, then this function will check that `auth` is + * either the owner of the token, or approved to operate on all tokens held by this owner. + * + * Emits an {Approval} event. + * + * Overrides to this logic should be done to the variant with an additional `bool emitEvent` argument. + */ + function _approve(address to, uint256 tokenId, address auth) internal { + _approve(to, tokenId, auth, true); + } + + /** + * @dev Variant of `_approve` with an optional flag to enable or disable the {Approval} event. The event is not + * emitted in the context of transfers. + */ + function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal virtual { + // Avoid reading the owner unless necessary + if (emitEvent || auth != address(0)) { + address owner = _requireOwned(tokenId); + + // We do not use _isAuthorized because single-token approvals should not be able to call approve + if (auth != address(0) && owner != auth && !isApprovedForAll(owner, auth)) { + revert ERC721InvalidApprover(auth); + } + + if (emitEvent) { + emit Approval(owner, to, tokenId); + } + } + + _tokenApprovals[tokenId] = to; + } + + /** + * @dev Approve `operator` to operate on all of `owner` tokens + * + * Requirements: + * - operator can't be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function _setApprovalForAll(address owner, address operator, bool approved) internal virtual { + if (operator == address(0)) { + revert ERC721InvalidOperator(operator); + } + _operatorApprovals[owner][operator] = approved; + emit ApprovalForAll(owner, operator, approved); + } + + /** + * @dev Reverts if the `tokenId` doesn't have a current owner (it hasn't been minted, or it has been burned). + * Returns the owner. + * + * Overrides to ownership logic should be done to {_ownerOf}. + */ + function _requireOwned(uint256 tokenId) internal view returns (address) { + address owner = _ownerOf(tokenId); + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + return owner; + } +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/IERC721Enumerable.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/IERC721Enumerable.sol) + +pragma solidity >=0.6.2; + + +/** + * @title ERC-721 Non-Fungible Token Standard, optional enumeration extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + */ +interface IERC721Enumerable is IERC721 { + /** + * @dev Returns the total amount of tokens stored by the contract. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns a token ID owned by `owner` at a given `index` of its token list. + * Use along with {balanceOf} to enumerate all of ``owner``'s tokens. + */ + function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256); + + /** + * @dev Returns a token ID at a given `index` of all the tokens stored by the contract. + * Use along with {totalSupply} to enumerate all tokens. + */ + function tokenByIndex(uint256 index) external view returns (uint256); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/ERC721Enumerable.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/ERC721Enumerable.sol) + +pragma solidity ^0.8.20; + + + + +/** + * @dev This implements an optional extension of {ERC721} defined in the ERC that adds enumerability + * of all the token ids in the contract as well as all token ids owned by each account. + * + * CAUTION: {ERC721} extensions that implement custom `balanceOf` logic, such as {ERC721Consecutive}, + * interfere with enumerability and should not be used together with {ERC721Enumerable}. + */ +abstract contract ERC721Enumerable is ERC721, IERC721Enumerable { + mapping(address owner => mapping(uint256 index => uint256)) private _ownedTokens; + mapping(uint256 tokenId => uint256) private _ownedTokensIndex; + + uint256[] private _allTokens; + mapping(uint256 tokenId => uint256) private _allTokensIndex; + + /** + * @dev An `owner`'s token query was out of bounds for `index`. + * + * NOTE: The owner being `address(0)` indicates a global out of bounds index. + */ + error ERC721OutOfBoundsIndex(address owner, uint256 index); + + /** + * @dev Batch mint is not allowed. + */ + error ERC721EnumerableForbiddenBatchMint(); + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC721) returns (bool) { + return interfaceId == type(IERC721Enumerable).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721Enumerable + function tokenOfOwnerByIndex(address owner, uint256 index) public view virtual returns (uint256) { + if (index >= balanceOf(owner)) { + revert ERC721OutOfBoundsIndex(owner, index); + } + return _ownedTokens[owner][index]; + } + + /// @inheritdoc IERC721Enumerable + function totalSupply() public view virtual returns (uint256) { + return _allTokens.length; + } + + /// @inheritdoc IERC721Enumerable + function tokenByIndex(uint256 index) public view virtual returns (uint256) { + if (index >= totalSupply()) { + revert ERC721OutOfBoundsIndex(address(0), index); + } + return _allTokens[index]; + } + + /// @inheritdoc ERC721 + function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) { + address previousOwner = super._update(to, tokenId, auth); + + if (previousOwner == address(0)) { + _addTokenToAllTokensEnumeration(tokenId); + } else if (previousOwner != to) { + _removeTokenFromOwnerEnumeration(previousOwner, tokenId); + } + if (to == address(0)) { + _removeTokenFromAllTokensEnumeration(tokenId); + } else if (previousOwner != to) { + _addTokenToOwnerEnumeration(to, tokenId); + } + + return previousOwner; + } + + /** + * @dev Private function to add a token to this extension's ownership-tracking data structures. + * @param to address representing the new owner of the given token ID + * @param tokenId uint256 ID of the token to be added to the tokens list of the given address + */ + function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private { + uint256 length = balanceOf(to) - 1; + _ownedTokens[to][length] = tokenId; + _ownedTokensIndex[tokenId] = length; + } + + /** + * @dev Private function to add a token to this extension's token tracking data structures. + * @param tokenId uint256 ID of the token to be added to the tokens list + */ + function _addTokenToAllTokensEnumeration(uint256 tokenId) private { + _allTokensIndex[tokenId] = _allTokens.length; + _allTokens.push(tokenId); + } + + /** + * @dev Private function to remove a token from this extension's ownership-tracking data structures. Note that + * while the token is not assigned a new owner, the `_ownedTokensIndex` mapping is _not_ updated: this allows for + * gas optimizations e.g. when performing a transfer operation (avoiding double writes). + * This has O(1) time complexity, but alters the order of the _ownedTokens array. + * @param from address representing the previous owner of the given token ID + * @param tokenId uint256 ID of the token to be removed from the tokens list of the given address + */ + function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private { + // To prevent a gap in from's tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + uint256 lastTokenIndex = balanceOf(from); + uint256 tokenIndex = _ownedTokensIndex[tokenId]; + + mapping(uint256 index => uint256) storage _ownedTokensByOwner = _ownedTokens[from]; + + // When the token to delete is the last token, the swap operation is unnecessary + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = _ownedTokensByOwner[lastTokenIndex]; + + _ownedTokensByOwner[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + } + + // This also deletes the contents at the last position of the array + delete _ownedTokensIndex[tokenId]; + delete _ownedTokensByOwner[lastTokenIndex]; + } + + /** + * @dev Private function to remove a token from this extension's token tracking data structures. + * This has O(1) time complexity, but alters the order of the _allTokens array. + * @param tokenId uint256 ID of the token to be removed from the tokens list + */ + function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private { + // To prevent a gap in the tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + uint256 lastTokenIndex = _allTokens.length - 1; + uint256 tokenIndex = _allTokensIndex[tokenId]; + + // When the token to delete is the last token, the swap operation is unnecessary. However, since this occurs so + // rarely (when the last minted token is burnt) that we still do the swap here to avoid the gas cost of adding + // an 'if' statement (like in _removeTokenFromOwnerEnumeration) + uint256 lastTokenId = _allTokens[lastTokenIndex]; + + _allTokens[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _allTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + + // This also deletes the contents at the last position of the array + delete _allTokensIndex[tokenId]; + _allTokens.pop(); + } + + /** + * See {ERC721-_increaseBalance}. We need that to account tokens that were minted in batch + */ + function _increaseBalance(address account, uint128 amount) internal virtual override { + if (amount > 0) { + revert ERC721EnumerableForbiddenBatchMint(); + } + super._increaseBalance(account, amount); + } +} + +// File: contracts/NFTNumbered.sol + + +pragma solidity ^0.8.27; + + + + +/// @title NFTNumbered +/// @notice ERC721 contract with sequential variants for immutable metadata for minted tokens, allowing to change metadata for future tokens. +/// @dev Non-upgradeable. Minter address is settable by owner. Global sequential token ID. +contract NFTNumbered is ERC721Enumerable, Ownable { + address public minter; + bool public mintingLocked; + TokenInfo[] private tokenInfos; + uint public nextTokenId = 1; + + struct TokenInfo { + uint tokenId; + string tokenUri; + } + + constructor( + string memory _name, + string memory _symbol, + string memory _tokenUri + ) ERC721(_name, _symbol) Ownable(msg.sender) { + minter = msg.sender; + mintingLocked = false; + setNextTokenURI(_tokenUri); + } + + event MinterUpdated(address indexed newMinter); + event MintingLocked(); + event NextTokenURI(uint nextTokenId, string nextTokenUri); + + /// @notice Updates the minter address. + function setMinter(address newMinter) external onlyOwner whenNotLocked { + minter = newMinter; + emit MinterUpdated(newMinter); + } + + /// @notice Permanently disable minting and changes. + function lockMintingPermanently() external onlyOwner { + mintingLocked = true; + emit MintingLocked(); + } + + modifier whenNotLocked() { + require(!mintingLocked, "Contract permanently locked for changes and minting"); + _; + } + + /// @notice Sets tokenUri for future mints only. + function setNextTokenURI(string memory newTokenUri) public onlyOwner whenNotLocked { + uint len = tokenInfos.length; + TokenInfo memory newInfo = TokenInfo({tokenId: nextTokenId, tokenUri: newTokenUri}); + if (len == 0 || tokenInfos[len - 1].tokenId < nextTokenId) { // add + tokenInfos.push(newInfo); + } else { // replace + tokenInfos[len - 1] = newInfo; + } + emit NextTokenURI(nextTokenId, newTokenUri); + } + + /// @notice metadata URI of the token that will be minted next + function nextTokenURI() external view returns (string memory) { + return tokenInfos[tokenInfos.length - 1].tokenUri; + } + + /// @notice Mints a new token. + /// @param to The recipient of the token. + function mint(address to) external whenNotLocked { + require(msg.sender == owner() || msg.sender == minter, "Caller must be the owner or minter"); + require(to != address(0), "Cannot mint to zero address"); + uint tokenId = nextTokenId++; + _safeMint(to, tokenId); + } + + /// @notice Burn owned token. + function burn(uint tokenId) external { + _burn(tokenId); + } + + /// @notice prohibit approvals + // address to, uint256 tokenId + function approve(address, uint256) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice prohibit approvals + // address operator, bool approved + function setApprovalForAll(address, bool) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice Limits ownership to 1 token. + function _update(address to, uint tokenId, address auth) internal virtual override returns (address) { + require(to == address(0) || balanceOf(to) == 0, "Soulbound token: only 1 per address"); + require(to == address(0) || _ownerOf(tokenId) == address(0), "Soulbound token: transfers prohibited"); + return super._update(to, tokenId, auth); + } + + /// @notice Returns embedded JSON metadata URI. + /// @param id Token ID. + function tokenURI(uint id) public view virtual override returns (string memory) { + _requireOwned(id); + uint len = tokenInfos.length; + for (uint i = len; i > 0; ) { + unchecked { i--; } + if (tokenInfos[i].tokenId > id) continue; + return tokenInfos[i].tokenUri; + } + revert("Unknown token ID"); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + (bool success, ) = payable(owner()).call{value: address(this).balance}(""); + require(success, "Withdraw failed"); + } +} + +// File: contracts/NFTMinter.sol + + +pragma solidity ^0.8.27; + + + + +contract NFTMinter is Ownable, Pausable { + NFTNumbered public nft; + uint public mintStartTime; + uint public mintEndTime; + uint public mintCount; + + /// @param _mintEndTime Unix timestamp when minting ends, 0 to mint without time limit. + constructor(address _nftAddress, uint _mintStartTime, uint _mintEndTime, bool _paused) Ownable(msg.sender) { + _setNFT(_nftAddress); + require(_mintEndTime == 0 || _mintEndTime > _mintStartTime, "Mint end time is before start time"); + require(_mintEndTime == 0 || _mintEndTime > block.timestamp, "Mint end time is in the past"); + mintStartTime = _mintStartTime; + mintEndTime = _mintEndTime; + if (_paused) _pause(); + } + + event NFTUpdated(address indexed newNFT); + event Minted(address indexed to, uint count); + event MintStartUpdated(uint newTime); + event MintEndUpdated(uint newTime); + event MintCountReset(uint oldCount); + + /// @notice Allows anyone to mint NFT for free (gas only). Any mint restrictions other than not paused or mint time must be in NFT contract + function mint() external whenNotPaused { + require(mintEndTime == 0 || mintEndTime > block.timestamp, "Minting ended"); + require(mintStartTime <= block.timestamp, "Minting not started"); + nft.mint(msg.sender); + mintCount++; + emit Minted(msg.sender, mintCount); + } + + /// @notice Update the target NFT contract. + /// @param newNFT The new NFT contract address. + function setNFT(address newNFT) external onlyOwner { + _setNFT(newNFT); + emit NFTUpdated(newNFT); + } + + function _setNFT(address _nft) internal { + require(_nft != address(0), "NFT contract address is 0"); + uint codeSize; + assembly { codeSize := extcodesize(_nft) } + require(codeSize > 0, "Not a contract address"); + nft = NFTNumbered(_nft); + } + + /// @notice Reset mint counter. + function resetMintCount() external onlyOwner { + uint old = mintCount; + mintCount = 0; + emit MintCountReset(old); + } + + /// @notice Update the mint start timestamp. + /// @param newTime The new Unix timestamp when minting ends. + function setMintStartTime(uint256 newTime) external onlyOwner { + require(mintEndTime == 0 || newTime < mintEndTime, "Mint end time is before start time"); + mintStartTime = newTime; + emit MintStartUpdated(newTime); + } + + /// @notice Update the mint end timestamp. + /// @param newTime The new Unix timestamp when minting ends, 0 to mint without time limit. + function setMintEndTime(uint256 newTime) external onlyOwner { + require(newTime == 0 || newTime > mintStartTime, "Mint end time is before start time"); + require(newTime == 0 || newTime > block.timestamp, "Mint end time is in the past"); + mintEndTime = newTime; + emit MintEndUpdated(newTime); + } + + /// @notice Pause minting. + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause minting. + function unpause() external onlyOwner { + _unpause(); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + payable(owner()).transfer(address(this).balance); + } +} diff --git a/eth/nft/contracts/NFTNumbered.sol b/eth/nft/contracts/NFTNumbered.sol new file mode 100644 index 0000000000..7635d776b2 --- /dev/null +++ b/eth/nft/contracts/NFTNumbered.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts@5.4.0/token/ERC721/extensions/ERC721Enumerable.sol"; +import {IERC721} from "@openzeppelin/contracts@5.4.0/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts@5.4.0/access/Ownable.sol"; + +/// @title NFTNumbered +/// @notice ERC721 contract with sequential variants for immutable metadata for minted tokens, allowing to change metadata for future tokens. +/// @dev Non-upgradeable. Minter address is settable by owner. Global sequential token ID. +contract NFTNumbered is ERC721Enumerable, Ownable { + address public minter; + bool public mintingLocked; + TokenInfo[] private tokenInfos; + uint public nextTokenId = 1; + + struct TokenInfo { + uint tokenId; + string tokenUri; + } + + constructor( + string memory _name, + string memory _symbol, + string memory _tokenUri + ) ERC721(_name, _symbol) Ownable(msg.sender) { + minter = msg.sender; + mintingLocked = false; + setNextTokenURI(_tokenUri); + } + + event MinterUpdated(address indexed newMinter); + event MintingLocked(); + event NextTokenURI(uint nextTokenId, string nextTokenUri); + + /// @notice Updates the minter address. + function setMinter(address newMinter) external onlyOwner whenNotLocked { + minter = newMinter; + emit MinterUpdated(newMinter); + } + + /// @notice Permanently disable minting and changes. + function lockMintingPermanently() external onlyOwner { + mintingLocked = true; + emit MintingLocked(); + } + + modifier whenNotLocked() { + require(!mintingLocked, "Contract permanently locked for changes and minting"); + _; + } + + /// @notice Sets tokenUri for future mints only. + function setNextTokenURI(string memory newTokenUri) public onlyOwner whenNotLocked { + uint len = tokenInfos.length; + TokenInfo memory newInfo = TokenInfo({tokenId: nextTokenId, tokenUri: newTokenUri}); + if (len == 0 || tokenInfos[len - 1].tokenId < nextTokenId) { // add + tokenInfos.push(newInfo); + } else { // replace + tokenInfos[len - 1] = newInfo; + } + emit NextTokenURI(nextTokenId, newTokenUri); + } + + /// @notice metadata URI of the token that will be minted next + function nextTokenURI() external view returns (string memory) { + return tokenInfos[tokenInfos.length - 1].tokenUri; + } + + /// @notice Mints a new token. + /// @param to The recipient of the token. + function mint(address to) external whenNotLocked { + require(msg.sender == owner() || msg.sender == minter, "Caller must be the owner or minter"); + require(to != address(0), "Cannot mint to zero address"); + uint tokenId = nextTokenId++; + _safeMint(to, tokenId); + } + + /// @notice Burn owned token. + function burn(uint tokenId) external { + _burn(tokenId); + } + + /// @notice prohibit approvals + // address to, uint256 tokenId + function approve(address, uint256) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice prohibit approvals + // address operator, bool approved + function setApprovalForAll(address, bool) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice Limits ownership to 1 token. + function _update(address to, uint tokenId, address auth) internal virtual override returns (address) { + require(to == address(0) || balanceOf(to) == 0, "Soulbound token: only 1 per address"); + require(to == address(0) || _ownerOf(tokenId) == address(0), "Soulbound token: transfers prohibited"); + return super._update(to, tokenId, auth); + } + + /// @notice Returns embedded JSON metadata URI. + /// @param id Token ID. + function tokenURI(uint id) public view virtual override returns (string memory) { + _requireOwned(id); + uint len = tokenInfos.length; + for (uint i = len; i > 0; ) { + unchecked { i--; } + if (tokenInfos[i].tokenId > id) continue; + return tokenInfos[i].tokenUri; + } + revert("Unknown token ID"); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + (bool success, ) = payable(owner()).call{value: address(this).balance}(""); + require(success, "Withdraw failed"); + } +} diff --git a/eth/nft/contracts/NFTNumbered_flattened.sol b/eth/nft/contracts/NFTNumbered_flattened.sol new file mode 100644 index 0000000000..9a2ac8b0ac --- /dev/null +++ b/eth/nft/contracts/NFTNumbered_flattened.sol @@ -0,0 +1,3892 @@ +// SPDX-License-Identifier: MIT + +// File: @openzeppelin/contracts@5.4.0/utils/introspection/IERC165.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/IERC165.sol) + +pragma solidity >=0.4.16; + +/** + * @dev Interface of the ERC-165 standard, as defined in the + * https://eips.ethereum.org/EIPS/eip-165[ERC]. + * + * Implementers can declare support of contract interfaces, which can then be + * queried by others ({ERC165Checker}). + * + * For an implementation, see {ERC165}. + */ +interface IERC165 { + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section] + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/IERC721.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/IERC721.sol) + +pragma solidity >=0.6.2; + + +/** + * @dev Required interface of an ERC-721 compliant contract. + */ +interface IERC721 is IERC165 { + /** + * @dev Emitted when `tokenId` token is transferred from `from` to `to`. + */ + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables `approved` to manage the `tokenId` token. + */ + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables or disables (`approved`) `operator` to manage all of its assets. + */ + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + /** + * @dev Returns the number of tokens in ``owner``'s account. + */ + function balanceOf(address owner) external view returns (uint256 balance); + + /** + * @dev Returns the owner of the `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function ownerOf(uint256 tokenId) external view returns (address owner); + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon + * a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking first that contract recipients + * are aware of the ERC-721 protocol to prevent tokens from being forever locked. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must have been allowed to move this token by either {approve} or + * {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon + * a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Transfers `tokenId` token from `from` to `to`. + * + * WARNING: Note that the caller is responsible to confirm that the recipient is capable of receiving ERC-721 + * or else they may be permanently lost. Usage of {safeTransferFrom} prevents loss, though the caller must + * understand this adds an external call which potentially creates a reentrancy vulnerability. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the zero address clears previous approvals. + * + * Requirements: + * + * - The caller must own the token or be an approved operator. + * - `tokenId` must exist. + * + * Emits an {Approval} event. + */ + function approve(address to, uint256 tokenId) external; + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} for any token owned by the caller. + * + * Requirements: + * + * - The `operator` cannot be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool approved) external; + + /** + * @dev Returns the account approved for `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function getApproved(uint256 tokenId) external view returns (address operator); + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll} + */ + function isApprovedForAll(address owner, address operator) external view returns (bool); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/IERC721Metadata.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/IERC721Metadata.sol) + +pragma solidity >=0.6.2; + + +/** + * @title ERC-721 Non-Fungible Token Standard, optional metadata extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + */ +interface IERC721Metadata is IERC721 { + /** + * @dev Returns the token collection name. + */ + function name() external view returns (string memory); + + /** + * @dev Returns the token collection symbol. + */ + function symbol() external view returns (string memory); + + /** + * @dev Returns the Uniform Resource Identifier (URI) for `tokenId` token. + */ + function tokenURI(uint256 tokenId) external view returns (string memory); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/IERC721Receiver.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/IERC721Receiver.sol) + +pragma solidity >=0.5.0; + +/** + * @title ERC-721 token receiver interface + * @dev Interface for any contract that wants to support safeTransfers + * from ERC-721 asset contracts. + */ +interface IERC721Receiver { + /** + * @dev Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} + * by `operator` from `from`, this function is called. + * + * It must return its Solidity selector to confirm the token transfer. + * If any other value is returned or the interface is not implemented by the recipient, the transfer will be + * reverted. + * + * The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); +} + +// File: @openzeppelin/contracts@5.4.0/interfaces/draft-IERC6093.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (interfaces/draft-IERC6093.sol) +pragma solidity >=0.8.4; + +/** + * @dev Standard ERC-20 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-20 tokens. + */ +interface IERC20Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC20InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC20InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `spender`’s `allowance`. Used in transfers. + * @param spender Address that may be allowed to operate on tokens without being their owner. + * @param allowance Amount of tokens a `spender` is allowed to operate with. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC20InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `spender` to be approved. Used in approvals. + * @param spender Address that may be allowed to operate on tokens without being their owner. + */ + error ERC20InvalidSpender(address spender); +} + +/** + * @dev Standard ERC-721 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-721 tokens. + */ +interface IERC721Errors { + /** + * @dev Indicates that an address can't be an owner. For example, `address(0)` is a forbidden owner in ERC-20. + * Used in balance queries. + * @param owner Address of the current owner of a token. + */ + error ERC721InvalidOwner(address owner); + + /** + * @dev Indicates a `tokenId` whose `owner` is the zero address. + * @param tokenId Identifier number of a token. + */ + error ERC721NonexistentToken(uint256 tokenId); + + /** + * @dev Indicates an error related to the ownership over a particular token. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param tokenId Identifier number of a token. + * @param owner Address of the current owner of a token. + */ + error ERC721IncorrectOwner(address sender, uint256 tokenId, address owner); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC721InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC721InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param tokenId Identifier number of a token. + */ + error ERC721InsufficientApproval(address operator, uint256 tokenId); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC721InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC721InvalidOperator(address operator); +} + +/** + * @dev Standard ERC-1155 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-1155 tokens. + */ +interface IERC1155Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + * @param tokenId Identifier number of a token. + */ + error ERC1155InsufficientBalance(address sender, uint256 balance, uint256 needed, uint256 tokenId); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC1155InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC1155InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param owner Address of the current owner of a token. + */ + error ERC1155MissingApprovalForAll(address operator, address owner); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC1155InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC1155InvalidOperator(address operator); + + /** + * @dev Indicates an array length mismatch between ids and values in a safeBatchTransferFrom operation. + * Used in batch transfers. + * @param idsLength Length of the array of token identifiers + * @param valuesLength Length of the array of token amounts + */ + error ERC1155InvalidArrayLength(uint256 idsLength, uint256 valuesLength); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/utils/ERC721Utils.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/utils/ERC721Utils.sol) + +pragma solidity ^0.8.20; + + + +/** + * @dev Library that provide common ERC-721 utility functions. + * + * See https://eips.ethereum.org/EIPS/eip-721[ERC-721]. + * + * _Available since v5.1._ + */ +library ERC721Utils { + /** + * @dev Performs an acceptance check for the provided `operator` by calling {IERC721Receiver-onERC721Received} + * on the `to` address. The `operator` is generally the address that initiated the token transfer (i.e. `msg.sender`). + * + * The acceptance call is not executed and treated as a no-op if the target address doesn't contain code (i.e. an EOA). + * Otherwise, the recipient must implement {IERC721Receiver-onERC721Received} and return the acceptance magic value to accept + * the transfer. + */ + function checkOnERC721Received( + address operator, + address from, + address to, + uint256 tokenId, + bytes memory data + ) internal { + if (to.code.length > 0) { + try IERC721Receiver(to).onERC721Received(operator, from, tokenId, data) returns (bytes4 retval) { + if (retval != IERC721Receiver.onERC721Received.selector) { + // Token rejected + revert IERC721Errors.ERC721InvalidReceiver(to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + // non-IERC721Receiver implementer + revert IERC721Errors.ERC721InvalidReceiver(to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Context.sol + + +// OpenZeppelin Contracts (last updated v5.0.1) (utils/Context.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Provides information about the current execution context, including the + * sender of the transaction and its data. While these are generally available + * via msg.sender and msg.data, they should not be accessed in such a direct + * manner, since when dealing with meta-transactions the account sending and + * paying for execution may not be the actual sender (as far as an application + * is concerned). + * + * This contract is only required for intermediate, library-like contracts. + */ +abstract contract Context { + function _msgSender() internal view virtual returns (address) { + return msg.sender; + } + + function _msgData() internal view virtual returns (bytes calldata) { + return msg.data; + } + + function _contextSuffixLength() internal view virtual returns (uint256) { + return 0; + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Panic.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/Panic.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Helper library for emitting standardized panic codes. + * + * ```solidity + * contract Example { + * using Panic for uint256; + * + * // Use any of the declared internal constants + * function foo() { Panic.GENERIC.panic(); } + * + * // Alternatively + * function foo() { Panic.panic(Panic.GENERIC); } + * } + * ``` + * + * Follows the list from https://github.com/ethereum/solidity/blob/v0.8.24/libsolutil/ErrorCodes.h[libsolutil]. + * + * _Available since v5.1._ + */ +// slither-disable-next-line unused-state +library Panic { + /// @dev generic / unspecified error + uint256 internal constant GENERIC = 0x00; + /// @dev used by the assert() builtin + uint256 internal constant ASSERT = 0x01; + /// @dev arithmetic underflow or overflow + uint256 internal constant UNDER_OVERFLOW = 0x11; + /// @dev division or modulo by zero + uint256 internal constant DIVISION_BY_ZERO = 0x12; + /// @dev enum conversion error + uint256 internal constant ENUM_CONVERSION_ERROR = 0x21; + /// @dev invalid encoding in storage + uint256 internal constant STORAGE_ENCODING_ERROR = 0x22; + /// @dev empty array pop + uint256 internal constant EMPTY_ARRAY_POP = 0x31; + /// @dev array out of bounds access + uint256 internal constant ARRAY_OUT_OF_BOUNDS = 0x32; + /// @dev resource error (too large allocation or too large array) + uint256 internal constant RESOURCE_ERROR = 0x41; + /// @dev calling invalid internal function + uint256 internal constant INVALID_INTERNAL_FUNCTION = 0x51; + + /// @dev Reverts with a panic code. Recommended to use with + /// the internal constants with predefined codes. + function panic(uint256 code) internal pure { + assembly ("memory-safe") { + mstore(0x00, 0x4e487b71) + mstore(0x20, code) + revert(0x1c, 0x24) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/SafeCast.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/math/SafeCast.sol) +// This file was procedurally generated from scripts/generate/templates/SafeCast.js. + +pragma solidity ^0.8.20; + +/** + * @dev Wrappers over Solidity's uintXX/intXX/bool casting operators with added overflow + * checks. + * + * Downcasting from uint256/int256 in Solidity does not revert on overflow. This can + * easily result in undesired exploitation or bugs, since developers usually + * assume that overflows raise errors. `SafeCast` restores this intuition by + * reverting the transaction when such an operation overflows. + * + * Using this library instead of the unchecked operations eliminates an entire + * class of bugs, so it's recommended to use it always. + */ +library SafeCast { + /** + * @dev Value doesn't fit in an uint of `bits` size. + */ + error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value); + + /** + * @dev An int value doesn't fit in an uint of `bits` size. + */ + error SafeCastOverflowedIntToUint(int256 value); + + /** + * @dev Value doesn't fit in an int of `bits` size. + */ + error SafeCastOverflowedIntDowncast(uint8 bits, int256 value); + + /** + * @dev An uint value doesn't fit in an int of `bits` size. + */ + error SafeCastOverflowedUintToInt(uint256 value); + + /** + * @dev Returns the downcasted uint248 from uint256, reverting on + * overflow (when the input is greater than largest uint248). + * + * Counterpart to Solidity's `uint248` operator. + * + * Requirements: + * + * - input must fit into 248 bits + */ + function toUint248(uint256 value) internal pure returns (uint248) { + if (value > type(uint248).max) { + revert SafeCastOverflowedUintDowncast(248, value); + } + return uint248(value); + } + + /** + * @dev Returns the downcasted uint240 from uint256, reverting on + * overflow (when the input is greater than largest uint240). + * + * Counterpart to Solidity's `uint240` operator. + * + * Requirements: + * + * - input must fit into 240 bits + */ + function toUint240(uint256 value) internal pure returns (uint240) { + if (value > type(uint240).max) { + revert SafeCastOverflowedUintDowncast(240, value); + } + return uint240(value); + } + + /** + * @dev Returns the downcasted uint232 from uint256, reverting on + * overflow (when the input is greater than largest uint232). + * + * Counterpart to Solidity's `uint232` operator. + * + * Requirements: + * + * - input must fit into 232 bits + */ + function toUint232(uint256 value) internal pure returns (uint232) { + if (value > type(uint232).max) { + revert SafeCastOverflowedUintDowncast(232, value); + } + return uint232(value); + } + + /** + * @dev Returns the downcasted uint224 from uint256, reverting on + * overflow (when the input is greater than largest uint224). + * + * Counterpart to Solidity's `uint224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toUint224(uint256 value) internal pure returns (uint224) { + if (value > type(uint224).max) { + revert SafeCastOverflowedUintDowncast(224, value); + } + return uint224(value); + } + + /** + * @dev Returns the downcasted uint216 from uint256, reverting on + * overflow (when the input is greater than largest uint216). + * + * Counterpart to Solidity's `uint216` operator. + * + * Requirements: + * + * - input must fit into 216 bits + */ + function toUint216(uint256 value) internal pure returns (uint216) { + if (value > type(uint216).max) { + revert SafeCastOverflowedUintDowncast(216, value); + } + return uint216(value); + } + + /** + * @dev Returns the downcasted uint208 from uint256, reverting on + * overflow (when the input is greater than largest uint208). + * + * Counterpart to Solidity's `uint208` operator. + * + * Requirements: + * + * - input must fit into 208 bits + */ + function toUint208(uint256 value) internal pure returns (uint208) { + if (value > type(uint208).max) { + revert SafeCastOverflowedUintDowncast(208, value); + } + return uint208(value); + } + + /** + * @dev Returns the downcasted uint200 from uint256, reverting on + * overflow (when the input is greater than largest uint200). + * + * Counterpart to Solidity's `uint200` operator. + * + * Requirements: + * + * - input must fit into 200 bits + */ + function toUint200(uint256 value) internal pure returns (uint200) { + if (value > type(uint200).max) { + revert SafeCastOverflowedUintDowncast(200, value); + } + return uint200(value); + } + + /** + * @dev Returns the downcasted uint192 from uint256, reverting on + * overflow (when the input is greater than largest uint192). + * + * Counterpart to Solidity's `uint192` operator. + * + * Requirements: + * + * - input must fit into 192 bits + */ + function toUint192(uint256 value) internal pure returns (uint192) { + if (value > type(uint192).max) { + revert SafeCastOverflowedUintDowncast(192, value); + } + return uint192(value); + } + + /** + * @dev Returns the downcasted uint184 from uint256, reverting on + * overflow (when the input is greater than largest uint184). + * + * Counterpart to Solidity's `uint184` operator. + * + * Requirements: + * + * - input must fit into 184 bits + */ + function toUint184(uint256 value) internal pure returns (uint184) { + if (value > type(uint184).max) { + revert SafeCastOverflowedUintDowncast(184, value); + } + return uint184(value); + } + + /** + * @dev Returns the downcasted uint176 from uint256, reverting on + * overflow (when the input is greater than largest uint176). + * + * Counterpart to Solidity's `uint176` operator. + * + * Requirements: + * + * - input must fit into 176 bits + */ + function toUint176(uint256 value) internal pure returns (uint176) { + if (value > type(uint176).max) { + revert SafeCastOverflowedUintDowncast(176, value); + } + return uint176(value); + } + + /** + * @dev Returns the downcasted uint168 from uint256, reverting on + * overflow (when the input is greater than largest uint168). + * + * Counterpart to Solidity's `uint168` operator. + * + * Requirements: + * + * - input must fit into 168 bits + */ + function toUint168(uint256 value) internal pure returns (uint168) { + if (value > type(uint168).max) { + revert SafeCastOverflowedUintDowncast(168, value); + } + return uint168(value); + } + + /** + * @dev Returns the downcasted uint160 from uint256, reverting on + * overflow (when the input is greater than largest uint160). + * + * Counterpart to Solidity's `uint160` operator. + * + * Requirements: + * + * - input must fit into 160 bits + */ + function toUint160(uint256 value) internal pure returns (uint160) { + if (value > type(uint160).max) { + revert SafeCastOverflowedUintDowncast(160, value); + } + return uint160(value); + } + + /** + * @dev Returns the downcasted uint152 from uint256, reverting on + * overflow (when the input is greater than largest uint152). + * + * Counterpart to Solidity's `uint152` operator. + * + * Requirements: + * + * - input must fit into 152 bits + */ + function toUint152(uint256 value) internal pure returns (uint152) { + if (value > type(uint152).max) { + revert SafeCastOverflowedUintDowncast(152, value); + } + return uint152(value); + } + + /** + * @dev Returns the downcasted uint144 from uint256, reverting on + * overflow (when the input is greater than largest uint144). + * + * Counterpart to Solidity's `uint144` operator. + * + * Requirements: + * + * - input must fit into 144 bits + */ + function toUint144(uint256 value) internal pure returns (uint144) { + if (value > type(uint144).max) { + revert SafeCastOverflowedUintDowncast(144, value); + } + return uint144(value); + } + + /** + * @dev Returns the downcasted uint136 from uint256, reverting on + * overflow (when the input is greater than largest uint136). + * + * Counterpart to Solidity's `uint136` operator. + * + * Requirements: + * + * - input must fit into 136 bits + */ + function toUint136(uint256 value) internal pure returns (uint136) { + if (value > type(uint136).max) { + revert SafeCastOverflowedUintDowncast(136, value); + } + return uint136(value); + } + + /** + * @dev Returns the downcasted uint128 from uint256, reverting on + * overflow (when the input is greater than largest uint128). + * + * Counterpart to Solidity's `uint128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toUint128(uint256 value) internal pure returns (uint128) { + if (value > type(uint128).max) { + revert SafeCastOverflowedUintDowncast(128, value); + } + return uint128(value); + } + + /** + * @dev Returns the downcasted uint120 from uint256, reverting on + * overflow (when the input is greater than largest uint120). + * + * Counterpart to Solidity's `uint120` operator. + * + * Requirements: + * + * - input must fit into 120 bits + */ + function toUint120(uint256 value) internal pure returns (uint120) { + if (value > type(uint120).max) { + revert SafeCastOverflowedUintDowncast(120, value); + } + return uint120(value); + } + + /** + * @dev Returns the downcasted uint112 from uint256, reverting on + * overflow (when the input is greater than largest uint112). + * + * Counterpart to Solidity's `uint112` operator. + * + * Requirements: + * + * - input must fit into 112 bits + */ + function toUint112(uint256 value) internal pure returns (uint112) { + if (value > type(uint112).max) { + revert SafeCastOverflowedUintDowncast(112, value); + } + return uint112(value); + } + + /** + * @dev Returns the downcasted uint104 from uint256, reverting on + * overflow (when the input is greater than largest uint104). + * + * Counterpart to Solidity's `uint104` operator. + * + * Requirements: + * + * - input must fit into 104 bits + */ + function toUint104(uint256 value) internal pure returns (uint104) { + if (value > type(uint104).max) { + revert SafeCastOverflowedUintDowncast(104, value); + } + return uint104(value); + } + + /** + * @dev Returns the downcasted uint96 from uint256, reverting on + * overflow (when the input is greater than largest uint96). + * + * Counterpart to Solidity's `uint96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toUint96(uint256 value) internal pure returns (uint96) { + if (value > type(uint96).max) { + revert SafeCastOverflowedUintDowncast(96, value); + } + return uint96(value); + } + + /** + * @dev Returns the downcasted uint88 from uint256, reverting on + * overflow (when the input is greater than largest uint88). + * + * Counterpart to Solidity's `uint88` operator. + * + * Requirements: + * + * - input must fit into 88 bits + */ + function toUint88(uint256 value) internal pure returns (uint88) { + if (value > type(uint88).max) { + revert SafeCastOverflowedUintDowncast(88, value); + } + return uint88(value); + } + + /** + * @dev Returns the downcasted uint80 from uint256, reverting on + * overflow (when the input is greater than largest uint80). + * + * Counterpart to Solidity's `uint80` operator. + * + * Requirements: + * + * - input must fit into 80 bits + */ + function toUint80(uint256 value) internal pure returns (uint80) { + if (value > type(uint80).max) { + revert SafeCastOverflowedUintDowncast(80, value); + } + return uint80(value); + } + + /** + * @dev Returns the downcasted uint72 from uint256, reverting on + * overflow (when the input is greater than largest uint72). + * + * Counterpart to Solidity's `uint72` operator. + * + * Requirements: + * + * - input must fit into 72 bits + */ + function toUint72(uint256 value) internal pure returns (uint72) { + if (value > type(uint72).max) { + revert SafeCastOverflowedUintDowncast(72, value); + } + return uint72(value); + } + + /** + * @dev Returns the downcasted uint64 from uint256, reverting on + * overflow (when the input is greater than largest uint64). + * + * Counterpart to Solidity's `uint64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toUint64(uint256 value) internal pure returns (uint64) { + if (value > type(uint64).max) { + revert SafeCastOverflowedUintDowncast(64, value); + } + return uint64(value); + } + + /** + * @dev Returns the downcasted uint56 from uint256, reverting on + * overflow (when the input is greater than largest uint56). + * + * Counterpart to Solidity's `uint56` operator. + * + * Requirements: + * + * - input must fit into 56 bits + */ + function toUint56(uint256 value) internal pure returns (uint56) { + if (value > type(uint56).max) { + revert SafeCastOverflowedUintDowncast(56, value); + } + return uint56(value); + } + + /** + * @dev Returns the downcasted uint48 from uint256, reverting on + * overflow (when the input is greater than largest uint48). + * + * Counterpart to Solidity's `uint48` operator. + * + * Requirements: + * + * - input must fit into 48 bits + */ + function toUint48(uint256 value) internal pure returns (uint48) { + if (value > type(uint48).max) { + revert SafeCastOverflowedUintDowncast(48, value); + } + return uint48(value); + } + + /** + * @dev Returns the downcasted uint40 from uint256, reverting on + * overflow (when the input is greater than largest uint40). + * + * Counterpart to Solidity's `uint40` operator. + * + * Requirements: + * + * - input must fit into 40 bits + */ + function toUint40(uint256 value) internal pure returns (uint40) { + if (value > type(uint40).max) { + revert SafeCastOverflowedUintDowncast(40, value); + } + return uint40(value); + } + + /** + * @dev Returns the downcasted uint32 from uint256, reverting on + * overflow (when the input is greater than largest uint32). + * + * Counterpart to Solidity's `uint32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toUint32(uint256 value) internal pure returns (uint32) { + if (value > type(uint32).max) { + revert SafeCastOverflowedUintDowncast(32, value); + } + return uint32(value); + } + + /** + * @dev Returns the downcasted uint24 from uint256, reverting on + * overflow (when the input is greater than largest uint24). + * + * Counterpart to Solidity's `uint24` operator. + * + * Requirements: + * + * - input must fit into 24 bits + */ + function toUint24(uint256 value) internal pure returns (uint24) { + if (value > type(uint24).max) { + revert SafeCastOverflowedUintDowncast(24, value); + } + return uint24(value); + } + + /** + * @dev Returns the downcasted uint16 from uint256, reverting on + * overflow (when the input is greater than largest uint16). + * + * Counterpart to Solidity's `uint16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toUint16(uint256 value) internal pure returns (uint16) { + if (value > type(uint16).max) { + revert SafeCastOverflowedUintDowncast(16, value); + } + return uint16(value); + } + + /** + * @dev Returns the downcasted uint8 from uint256, reverting on + * overflow (when the input is greater than largest uint8). + * + * Counterpart to Solidity's `uint8` operator. + * + * Requirements: + * + * - input must fit into 8 bits + */ + function toUint8(uint256 value) internal pure returns (uint8) { + if (value > type(uint8).max) { + revert SafeCastOverflowedUintDowncast(8, value); + } + return uint8(value); + } + + /** + * @dev Converts a signed int256 into an unsigned uint256. + * + * Requirements: + * + * - input must be greater than or equal to 0. + */ + function toUint256(int256 value) internal pure returns (uint256) { + if (value < 0) { + revert SafeCastOverflowedIntToUint(value); + } + return uint256(value); + } + + /** + * @dev Returns the downcasted int248 from int256, reverting on + * overflow (when the input is less than smallest int248 or + * greater than largest int248). + * + * Counterpart to Solidity's `int248` operator. + * + * Requirements: + * + * - input must fit into 248 bits + */ + function toInt248(int256 value) internal pure returns (int248 downcasted) { + downcasted = int248(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(248, value); + } + } + + /** + * @dev Returns the downcasted int240 from int256, reverting on + * overflow (when the input is less than smallest int240 or + * greater than largest int240). + * + * Counterpart to Solidity's `int240` operator. + * + * Requirements: + * + * - input must fit into 240 bits + */ + function toInt240(int256 value) internal pure returns (int240 downcasted) { + downcasted = int240(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(240, value); + } + } + + /** + * @dev Returns the downcasted int232 from int256, reverting on + * overflow (when the input is less than smallest int232 or + * greater than largest int232). + * + * Counterpart to Solidity's `int232` operator. + * + * Requirements: + * + * - input must fit into 232 bits + */ + function toInt232(int256 value) internal pure returns (int232 downcasted) { + downcasted = int232(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(232, value); + } + } + + /** + * @dev Returns the downcasted int224 from int256, reverting on + * overflow (when the input is less than smallest int224 or + * greater than largest int224). + * + * Counterpart to Solidity's `int224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toInt224(int256 value) internal pure returns (int224 downcasted) { + downcasted = int224(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(224, value); + } + } + + /** + * @dev Returns the downcasted int216 from int256, reverting on + * overflow (when the input is less than smallest int216 or + * greater than largest int216). + * + * Counterpart to Solidity's `int216` operator. + * + * Requirements: + * + * - input must fit into 216 bits + */ + function toInt216(int256 value) internal pure returns (int216 downcasted) { + downcasted = int216(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(216, value); + } + } + + /** + * @dev Returns the downcasted int208 from int256, reverting on + * overflow (when the input is less than smallest int208 or + * greater than largest int208). + * + * Counterpart to Solidity's `int208` operator. + * + * Requirements: + * + * - input must fit into 208 bits + */ + function toInt208(int256 value) internal pure returns (int208 downcasted) { + downcasted = int208(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(208, value); + } + } + + /** + * @dev Returns the downcasted int200 from int256, reverting on + * overflow (when the input is less than smallest int200 or + * greater than largest int200). + * + * Counterpart to Solidity's `int200` operator. + * + * Requirements: + * + * - input must fit into 200 bits + */ + function toInt200(int256 value) internal pure returns (int200 downcasted) { + downcasted = int200(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(200, value); + } + } + + /** + * @dev Returns the downcasted int192 from int256, reverting on + * overflow (when the input is less than smallest int192 or + * greater than largest int192). + * + * Counterpart to Solidity's `int192` operator. + * + * Requirements: + * + * - input must fit into 192 bits + */ + function toInt192(int256 value) internal pure returns (int192 downcasted) { + downcasted = int192(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(192, value); + } + } + + /** + * @dev Returns the downcasted int184 from int256, reverting on + * overflow (when the input is less than smallest int184 or + * greater than largest int184). + * + * Counterpart to Solidity's `int184` operator. + * + * Requirements: + * + * - input must fit into 184 bits + */ + function toInt184(int256 value) internal pure returns (int184 downcasted) { + downcasted = int184(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(184, value); + } + } + + /** + * @dev Returns the downcasted int176 from int256, reverting on + * overflow (when the input is less than smallest int176 or + * greater than largest int176). + * + * Counterpart to Solidity's `int176` operator. + * + * Requirements: + * + * - input must fit into 176 bits + */ + function toInt176(int256 value) internal pure returns (int176 downcasted) { + downcasted = int176(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(176, value); + } + } + + /** + * @dev Returns the downcasted int168 from int256, reverting on + * overflow (when the input is less than smallest int168 or + * greater than largest int168). + * + * Counterpart to Solidity's `int168` operator. + * + * Requirements: + * + * - input must fit into 168 bits + */ + function toInt168(int256 value) internal pure returns (int168 downcasted) { + downcasted = int168(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(168, value); + } + } + + /** + * @dev Returns the downcasted int160 from int256, reverting on + * overflow (when the input is less than smallest int160 or + * greater than largest int160). + * + * Counterpart to Solidity's `int160` operator. + * + * Requirements: + * + * - input must fit into 160 bits + */ + function toInt160(int256 value) internal pure returns (int160 downcasted) { + downcasted = int160(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(160, value); + } + } + + /** + * @dev Returns the downcasted int152 from int256, reverting on + * overflow (when the input is less than smallest int152 or + * greater than largest int152). + * + * Counterpart to Solidity's `int152` operator. + * + * Requirements: + * + * - input must fit into 152 bits + */ + function toInt152(int256 value) internal pure returns (int152 downcasted) { + downcasted = int152(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(152, value); + } + } + + /** + * @dev Returns the downcasted int144 from int256, reverting on + * overflow (when the input is less than smallest int144 or + * greater than largest int144). + * + * Counterpart to Solidity's `int144` operator. + * + * Requirements: + * + * - input must fit into 144 bits + */ + function toInt144(int256 value) internal pure returns (int144 downcasted) { + downcasted = int144(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(144, value); + } + } + + /** + * @dev Returns the downcasted int136 from int256, reverting on + * overflow (when the input is less than smallest int136 or + * greater than largest int136). + * + * Counterpart to Solidity's `int136` operator. + * + * Requirements: + * + * - input must fit into 136 bits + */ + function toInt136(int256 value) internal pure returns (int136 downcasted) { + downcasted = int136(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(136, value); + } + } + + /** + * @dev Returns the downcasted int128 from int256, reverting on + * overflow (when the input is less than smallest int128 or + * greater than largest int128). + * + * Counterpart to Solidity's `int128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toInt128(int256 value) internal pure returns (int128 downcasted) { + downcasted = int128(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(128, value); + } + } + + /** + * @dev Returns the downcasted int120 from int256, reverting on + * overflow (when the input is less than smallest int120 or + * greater than largest int120). + * + * Counterpart to Solidity's `int120` operator. + * + * Requirements: + * + * - input must fit into 120 bits + */ + function toInt120(int256 value) internal pure returns (int120 downcasted) { + downcasted = int120(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(120, value); + } + } + + /** + * @dev Returns the downcasted int112 from int256, reverting on + * overflow (when the input is less than smallest int112 or + * greater than largest int112). + * + * Counterpart to Solidity's `int112` operator. + * + * Requirements: + * + * - input must fit into 112 bits + */ + function toInt112(int256 value) internal pure returns (int112 downcasted) { + downcasted = int112(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(112, value); + } + } + + /** + * @dev Returns the downcasted int104 from int256, reverting on + * overflow (when the input is less than smallest int104 or + * greater than largest int104). + * + * Counterpart to Solidity's `int104` operator. + * + * Requirements: + * + * - input must fit into 104 bits + */ + function toInt104(int256 value) internal pure returns (int104 downcasted) { + downcasted = int104(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(104, value); + } + } + + /** + * @dev Returns the downcasted int96 from int256, reverting on + * overflow (when the input is less than smallest int96 or + * greater than largest int96). + * + * Counterpart to Solidity's `int96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toInt96(int256 value) internal pure returns (int96 downcasted) { + downcasted = int96(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(96, value); + } + } + + /** + * @dev Returns the downcasted int88 from int256, reverting on + * overflow (when the input is less than smallest int88 or + * greater than largest int88). + * + * Counterpart to Solidity's `int88` operator. + * + * Requirements: + * + * - input must fit into 88 bits + */ + function toInt88(int256 value) internal pure returns (int88 downcasted) { + downcasted = int88(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(88, value); + } + } + + /** + * @dev Returns the downcasted int80 from int256, reverting on + * overflow (when the input is less than smallest int80 or + * greater than largest int80). + * + * Counterpart to Solidity's `int80` operator. + * + * Requirements: + * + * - input must fit into 80 bits + */ + function toInt80(int256 value) internal pure returns (int80 downcasted) { + downcasted = int80(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(80, value); + } + } + + /** + * @dev Returns the downcasted int72 from int256, reverting on + * overflow (when the input is less than smallest int72 or + * greater than largest int72). + * + * Counterpart to Solidity's `int72` operator. + * + * Requirements: + * + * - input must fit into 72 bits + */ + function toInt72(int256 value) internal pure returns (int72 downcasted) { + downcasted = int72(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(72, value); + } + } + + /** + * @dev Returns the downcasted int64 from int256, reverting on + * overflow (when the input is less than smallest int64 or + * greater than largest int64). + * + * Counterpart to Solidity's `int64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toInt64(int256 value) internal pure returns (int64 downcasted) { + downcasted = int64(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(64, value); + } + } + + /** + * @dev Returns the downcasted int56 from int256, reverting on + * overflow (when the input is less than smallest int56 or + * greater than largest int56). + * + * Counterpart to Solidity's `int56` operator. + * + * Requirements: + * + * - input must fit into 56 bits + */ + function toInt56(int256 value) internal pure returns (int56 downcasted) { + downcasted = int56(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(56, value); + } + } + + /** + * @dev Returns the downcasted int48 from int256, reverting on + * overflow (when the input is less than smallest int48 or + * greater than largest int48). + * + * Counterpart to Solidity's `int48` operator. + * + * Requirements: + * + * - input must fit into 48 bits + */ + function toInt48(int256 value) internal pure returns (int48 downcasted) { + downcasted = int48(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(48, value); + } + } + + /** + * @dev Returns the downcasted int40 from int256, reverting on + * overflow (when the input is less than smallest int40 or + * greater than largest int40). + * + * Counterpart to Solidity's `int40` operator. + * + * Requirements: + * + * - input must fit into 40 bits + */ + function toInt40(int256 value) internal pure returns (int40 downcasted) { + downcasted = int40(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(40, value); + } + } + + /** + * @dev Returns the downcasted int32 from int256, reverting on + * overflow (when the input is less than smallest int32 or + * greater than largest int32). + * + * Counterpart to Solidity's `int32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toInt32(int256 value) internal pure returns (int32 downcasted) { + downcasted = int32(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(32, value); + } + } + + /** + * @dev Returns the downcasted int24 from int256, reverting on + * overflow (when the input is less than smallest int24 or + * greater than largest int24). + * + * Counterpart to Solidity's `int24` operator. + * + * Requirements: + * + * - input must fit into 24 bits + */ + function toInt24(int256 value) internal pure returns (int24 downcasted) { + downcasted = int24(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(24, value); + } + } + + /** + * @dev Returns the downcasted int16 from int256, reverting on + * overflow (when the input is less than smallest int16 or + * greater than largest int16). + * + * Counterpart to Solidity's `int16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toInt16(int256 value) internal pure returns (int16 downcasted) { + downcasted = int16(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(16, value); + } + } + + /** + * @dev Returns the downcasted int8 from int256, reverting on + * overflow (when the input is less than smallest int8 or + * greater than largest int8). + * + * Counterpart to Solidity's `int8` operator. + * + * Requirements: + * + * - input must fit into 8 bits + */ + function toInt8(int256 value) internal pure returns (int8 downcasted) { + downcasted = int8(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(8, value); + } + } + + /** + * @dev Converts an unsigned uint256 into a signed int256. + * + * Requirements: + * + * - input must be less than or equal to maxInt256. + */ + function toInt256(uint256 value) internal pure returns (int256) { + // Note: Unsafe cast below is okay because `type(int256).max` is guaranteed to be positive + if (value > uint256(type(int256).max)) { + revert SafeCastOverflowedUintToInt(value); + } + return int256(value); + } + + /** + * @dev Cast a boolean (false or true) to a uint256 (0 or 1) with no jump. + */ + function toUint(bool b) internal pure returns (uint256 u) { + assembly ("memory-safe") { + u := iszero(iszero(b)) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/Math.sol + + +// OpenZeppelin Contracts (last updated v5.3.0) (utils/math/Math.sol) + +pragma solidity ^0.8.20; + + + +/** + * @dev Standard math utilities missing in the Solidity language. + */ +library Math { + enum Rounding { + Floor, // Toward negative infinity + Ceil, // Toward positive infinity + Trunc, // Toward zero + Expand // Away from zero + } + + /** + * @dev Return the 512-bit addition of two uint256. + * + * The result is stored in two 256 variables such that sum = high * 2²⁵⁶ + low. + */ + function add512(uint256 a, uint256 b) internal pure returns (uint256 high, uint256 low) { + assembly ("memory-safe") { + low := add(a, b) + high := lt(low, a) + } + } + + /** + * @dev Return the 512-bit multiplication of two uint256. + * + * The result is stored in two 256 variables such that product = high * 2²⁵⁶ + low. + */ + function mul512(uint256 a, uint256 b) internal pure returns (uint256 high, uint256 low) { + // 512-bit multiply [high low] = x * y. Compute the product mod 2²⁵⁶ and mod 2²⁵⁶ - 1, then use + // the Chinese Remainder Theorem to reconstruct the 512 bit result. The result is stored in two 256 + // variables such that product = high * 2²⁵⁶ + low. + assembly ("memory-safe") { + let mm := mulmod(a, b, not(0)) + low := mul(a, b) + high := sub(sub(mm, low), lt(mm, low)) + } + } + + /** + * @dev Returns the addition of two unsigned integers, with a success flag (no overflow). + */ + function tryAdd(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a + b; + success = c >= a; + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the subtraction of two unsigned integers, with a success flag (no overflow). + */ + function trySub(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a - b; + success = c <= a; + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the multiplication of two unsigned integers, with a success flag (no overflow). + */ + function tryMul(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a * b; + assembly ("memory-safe") { + // Only true when the multiplication doesn't overflow + // (c / a == b) || (a == 0) + success := or(eq(div(c, a), b), iszero(a)) + } + // equivalent to: success ? c : 0 + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the division of two unsigned integers, with a success flag (no division by zero). + */ + function tryDiv(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + success = b > 0; + assembly ("memory-safe") { + // The `DIV` opcode returns zero when the denominator is 0. + result := div(a, b) + } + } + } + + /** + * @dev Returns the remainder of dividing two unsigned integers, with a success flag (no division by zero). + */ + function tryMod(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + success = b > 0; + assembly ("memory-safe") { + // The `MOD` opcode returns zero when the denominator is 0. + result := mod(a, b) + } + } + } + + /** + * @dev Unsigned saturating addition, bounds to `2²⁵⁶ - 1` instead of overflowing. + */ + function saturatingAdd(uint256 a, uint256 b) internal pure returns (uint256) { + (bool success, uint256 result) = tryAdd(a, b); + return ternary(success, result, type(uint256).max); + } + + /** + * @dev Unsigned saturating subtraction, bounds to zero instead of overflowing. + */ + function saturatingSub(uint256 a, uint256 b) internal pure returns (uint256) { + (, uint256 result) = trySub(a, b); + return result; + } + + /** + * @dev Unsigned saturating multiplication, bounds to `2²⁵⁶ - 1` instead of overflowing. + */ + function saturatingMul(uint256 a, uint256 b) internal pure returns (uint256) { + (bool success, uint256 result) = tryMul(a, b); + return ternary(success, result, type(uint256).max); + } + + /** + * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * + * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. + * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * one branch when needed, making this function more expensive. + */ + function ternary(bool condition, uint256 a, uint256 b) internal pure returns (uint256) { + unchecked { + // branchless ternary works because: + // b ^ (a ^ b) == a + // b ^ 0 == b + return b ^ ((a ^ b) * SafeCast.toUint(condition)); + } + } + + /** + * @dev Returns the largest of two numbers. + */ + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return ternary(a > b, a, b); + } + + /** + * @dev Returns the smallest of two numbers. + */ + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return ternary(a < b, a, b); + } + + /** + * @dev Returns the average of two numbers. The result is rounded towards + * zero. + */ + function average(uint256 a, uint256 b) internal pure returns (uint256) { + // (a + b) / 2 can overflow. + return (a & b) + (a ^ b) / 2; + } + + /** + * @dev Returns the ceiling of the division of two numbers. + * + * This differs from standard division with `/` in that it rounds towards infinity instead + * of rounding towards zero. + */ + function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) { + if (b == 0) { + // Guarantee the same behavior as in a regular Solidity division. + Panic.panic(Panic.DIVISION_BY_ZERO); + } + + // The following calculation ensures accurate ceiling division without overflow. + // Since a is non-zero, (a - 1) / b will not overflow. + // The largest possible result occurs when (a - 1) / b is type(uint256).max, + // but the largest value we can obtain is type(uint256).max - 1, which happens + // when a = type(uint256).max and b = 1. + unchecked { + return SafeCast.toUint(a > 0) * ((a - 1) / b + 1); + } + } + + /** + * @dev Calculates floor(x * y / denominator) with full precision. Throws if result overflows a uint256 or + * denominator == 0. + * + * Original credit to Remco Bloemen under MIT license (https://xn--2-umb.com/21/muldiv) with further edits by + * Uniswap Labs also under MIT license. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { + unchecked { + (uint256 high, uint256 low) = mul512(x, y); + + // Handle non-overflow cases, 256 by 256 division. + if (high == 0) { + // Solidity will revert if denominator == 0, unlike the div opcode on its own. + // The surrounding unchecked block does not change this fact. + // See https://docs.soliditylang.org/en/latest/control-structures.html#checked-or-unchecked-arithmetic. + return low / denominator; + } + + // Make sure the result is less than 2²⁵⁶. Also prevents denominator == 0. + if (denominator <= high) { + Panic.panic(ternary(denominator == 0, Panic.DIVISION_BY_ZERO, Panic.UNDER_OVERFLOW)); + } + + /////////////////////////////////////////////// + // 512 by 256 division. + /////////////////////////////////////////////// + + // Make division exact by subtracting the remainder from [high low]. + uint256 remainder; + assembly ("memory-safe") { + // Compute remainder using mulmod. + remainder := mulmod(x, y, denominator) + + // Subtract 256 bit number from 512 bit number. + high := sub(high, gt(remainder, low)) + low := sub(low, remainder) + } + + // Factor powers of two out of denominator and compute largest power of two divisor of denominator. + // Always >= 1. See https://cs.stackexchange.com/q/138556/92363. + + uint256 twos = denominator & (0 - denominator); + assembly ("memory-safe") { + // Divide denominator by twos. + denominator := div(denominator, twos) + + // Divide [high low] by twos. + low := div(low, twos) + + // Flip twos such that it is 2²⁵⁶ / twos. If twos is zero, then it becomes one. + twos := add(div(sub(0, twos), twos), 1) + } + + // Shift in bits from high into low. + low |= high * twos; + + // Invert denominator mod 2²⁵⁶. Now that denominator is an odd number, it has an inverse modulo 2²⁵⁶ such + // that denominator * inv ≡ 1 mod 2²⁵⁶. Compute the inverse by starting with a seed that is correct for + // four bits. That is, denominator * inv ≡ 1 mod 2⁴. + uint256 inverse = (3 * denominator) ^ 2; + + // Use the Newton-Raphson iteration to improve the precision. Thanks to Hensel's lifting lemma, this also + // works in modular arithmetic, doubling the correct bits in each step. + inverse *= 2 - denominator * inverse; // inverse mod 2⁸ + inverse *= 2 - denominator * inverse; // inverse mod 2¹⁶ + inverse *= 2 - denominator * inverse; // inverse mod 2³² + inverse *= 2 - denominator * inverse; // inverse mod 2⁶⁴ + inverse *= 2 - denominator * inverse; // inverse mod 2¹²⁸ + inverse *= 2 - denominator * inverse; // inverse mod 2²⁵⁶ + + // Because the division is now exact we can divide by multiplying with the modular inverse of denominator. + // This will give us the correct result modulo 2²⁵⁶. Since the preconditions guarantee that the outcome is + // less than 2²⁵⁶, this is the final result. We don't need to compute the high bits of the result and high + // is no longer required. + result = low * inverse; + return result; + } + } + + /** + * @dev Calculates x * y / denominator with full precision, following the selected rounding direction. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator, Rounding rounding) internal pure returns (uint256) { + return mulDiv(x, y, denominator) + SafeCast.toUint(unsignedRoundsUp(rounding) && mulmod(x, y, denominator) > 0); + } + + /** + * @dev Calculates floor(x * y >> n) with full precision. Throws if result overflows a uint256. + */ + function mulShr(uint256 x, uint256 y, uint8 n) internal pure returns (uint256 result) { + unchecked { + (uint256 high, uint256 low) = mul512(x, y); + if (high >= 1 << n) { + Panic.panic(Panic.UNDER_OVERFLOW); + } + return (high << (256 - n)) | (low >> n); + } + } + + /** + * @dev Calculates x * y >> n with full precision, following the selected rounding direction. + */ + function mulShr(uint256 x, uint256 y, uint8 n, Rounding rounding) internal pure returns (uint256) { + return mulShr(x, y, n) + SafeCast.toUint(unsignedRoundsUp(rounding) && mulmod(x, y, 1 << n) > 0); + } + + /** + * @dev Calculate the modular multiplicative inverse of a number in Z/nZ. + * + * If n is a prime, then Z/nZ is a field. In that case all elements are inversible, except 0. + * If n is not a prime, then Z/nZ is not a field, and some elements might not be inversible. + * + * If the input value is not inversible, 0 is returned. + * + * NOTE: If you know for sure that n is (big) a prime, it may be cheaper to use Fermat's little theorem and get the + * inverse using `Math.modExp(a, n - 2, n)`. See {invModPrime}. + */ + function invMod(uint256 a, uint256 n) internal pure returns (uint256) { + unchecked { + if (n == 0) return 0; + + // The inverse modulo is calculated using the Extended Euclidean Algorithm (iterative version) + // Used to compute integers x and y such that: ax + ny = gcd(a, n). + // When the gcd is 1, then the inverse of a modulo n exists and it's x. + // ax + ny = 1 + // ax = 1 + (-y)n + // ax ≡ 1 (mod n) # x is the inverse of a modulo n + + // If the remainder is 0 the gcd is n right away. + uint256 remainder = a % n; + uint256 gcd = n; + + // Therefore the initial coefficients are: + // ax + ny = gcd(a, n) = n + // 0a + 1n = n + int256 x = 0; + int256 y = 1; + + while (remainder != 0) { + uint256 quotient = gcd / remainder; + + (gcd, remainder) = ( + // The old remainder is the next gcd to try. + remainder, + // Compute the next remainder. + // Can't overflow given that (a % gcd) * (gcd // (a % gcd)) <= gcd + // where gcd is at most n (capped to type(uint256).max) + gcd - remainder * quotient + ); + + (x, y) = ( + // Increment the coefficient of a. + y, + // Decrement the coefficient of n. + // Can overflow, but the result is casted to uint256 so that the + // next value of y is "wrapped around" to a value between 0 and n - 1. + x - y * int256(quotient) + ); + } + + if (gcd != 1) return 0; // No inverse exists. + return ternary(x < 0, n - uint256(-x), uint256(x)); // Wrap the result if it's negative. + } + } + + /** + * @dev Variant of {invMod}. More efficient, but only works if `p` is known to be a prime greater than `2`. + * + * From https://en.wikipedia.org/wiki/Fermat%27s_little_theorem[Fermat's little theorem], we know that if p is + * prime, then `a**(p-1) ≡ 1 mod p`. As a consequence, we have `a * a**(p-2) ≡ 1 mod p`, which means that + * `a**(p-2)` is the modular multiplicative inverse of a in Fp. + * + * NOTE: this function does NOT check that `p` is a prime greater than `2`. + */ + function invModPrime(uint256 a, uint256 p) internal view returns (uint256) { + unchecked { + return Math.modExp(a, p - 2, p); + } + } + + /** + * @dev Returns the modular exponentiation of the specified base, exponent and modulus (b ** e % m) + * + * Requirements: + * - modulus can't be zero + * - underlying staticcall to precompile must succeed + * + * IMPORTANT: The result is only valid if the underlying call succeeds. When using this function, make + * sure the chain you're using it on supports the precompiled contract for modular exponentiation + * at address 0x05 as specified in https://eips.ethereum.org/EIPS/eip-198[EIP-198]. Otherwise, + * the underlying function will succeed given the lack of a revert, but the result may be incorrectly + * interpreted as 0. + */ + function modExp(uint256 b, uint256 e, uint256 m) internal view returns (uint256) { + (bool success, uint256 result) = tryModExp(b, e, m); + if (!success) { + Panic.panic(Panic.DIVISION_BY_ZERO); + } + return result; + } + + /** + * @dev Returns the modular exponentiation of the specified base, exponent and modulus (b ** e % m). + * It includes a success flag indicating if the operation succeeded. Operation will be marked as failed if trying + * to operate modulo 0 or if the underlying precompile reverted. + * + * IMPORTANT: The result is only valid if the success flag is true. When using this function, make sure the chain + * you're using it on supports the precompiled contract for modular exponentiation at address 0x05 as specified in + * https://eips.ethereum.org/EIPS/eip-198[EIP-198]. Otherwise, the underlying function will succeed given the lack + * of a revert, but the result may be incorrectly interpreted as 0. + */ + function tryModExp(uint256 b, uint256 e, uint256 m) internal view returns (bool success, uint256 result) { + if (m == 0) return (false, 0); + assembly ("memory-safe") { + let ptr := mload(0x40) + // | Offset | Content | Content (Hex) | + // |-----------|------------|--------------------------------------------------------------------| + // | 0x00:0x1f | size of b | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x20:0x3f | size of e | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x40:0x5f | size of m | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x60:0x7f | value of b | 0x<.............................................................b> | + // | 0x80:0x9f | value of e | 0x<.............................................................e> | + // | 0xa0:0xbf | value of m | 0x<.............................................................m> | + mstore(ptr, 0x20) + mstore(add(ptr, 0x20), 0x20) + mstore(add(ptr, 0x40), 0x20) + mstore(add(ptr, 0x60), b) + mstore(add(ptr, 0x80), e) + mstore(add(ptr, 0xa0), m) + + // Given the result < m, it's guaranteed to fit in 32 bytes, + // so we can use the memory scratch space located at offset 0. + success := staticcall(gas(), 0x05, ptr, 0xc0, 0x00, 0x20) + result := mload(0x00) + } + } + + /** + * @dev Variant of {modExp} that supports inputs of arbitrary length. + */ + function modExp(bytes memory b, bytes memory e, bytes memory m) internal view returns (bytes memory) { + (bool success, bytes memory result) = tryModExp(b, e, m); + if (!success) { + Panic.panic(Panic.DIVISION_BY_ZERO); + } + return result; + } + + /** + * @dev Variant of {tryModExp} that supports inputs of arbitrary length. + */ + function tryModExp( + bytes memory b, + bytes memory e, + bytes memory m + ) internal view returns (bool success, bytes memory result) { + if (_zeroBytes(m)) return (false, new bytes(0)); + + uint256 mLen = m.length; + + // Encode call args in result and move the free memory pointer + result = abi.encodePacked(b.length, e.length, mLen, b, e, m); + + assembly ("memory-safe") { + let dataPtr := add(result, 0x20) + // Write result on top of args to avoid allocating extra memory. + success := staticcall(gas(), 0x05, dataPtr, mload(result), dataPtr, mLen) + // Overwrite the length. + // result.length > returndatasize() is guaranteed because returndatasize() == m.length + mstore(result, mLen) + // Set the memory pointer after the returned data. + mstore(0x40, add(dataPtr, mLen)) + } + } + + /** + * @dev Returns whether the provided byte array is zero. + */ + function _zeroBytes(bytes memory byteArray) private pure returns (bool) { + for (uint256 i = 0; i < byteArray.length; ++i) { + if (byteArray[i] != 0) { + return false; + } + } + return true; + } + + /** + * @dev Returns the square root of a number. If the number is not a perfect square, the value is rounded + * towards zero. + * + * This method is based on Newton's method for computing square roots; the algorithm is restricted to only + * using integer operations. + */ + function sqrt(uint256 a) internal pure returns (uint256) { + unchecked { + // Take care of easy edge cases when a == 0 or a == 1 + if (a <= 1) { + return a; + } + + // In this function, we use Newton's method to get a root of `f(x) := x² - a`. It involves building a + // sequence x_n that converges toward sqrt(a). For each iteration x_n, we also define the error between + // the current value as `ε_n = | x_n - sqrt(a) |`. + // + // For our first estimation, we consider `e` the smallest power of 2 which is bigger than the square root + // of the target. (i.e. `2**(e-1) ≤ sqrt(a) < 2**e`). We know that `e ≤ 128` because `(2¹²⁸)² = 2²⁵⁶` is + // bigger than any uint256. + // + // By noticing that + // `2**(e-1) ≤ sqrt(a) < 2**e → (2**(e-1))² ≤ a < (2**e)² → 2**(2*e-2) ≤ a < 2**(2*e)` + // we can deduce that `e - 1` is `log2(a) / 2`. We can thus compute `x_n = 2**(e-1)` using a method similar + // to the msb function. + uint256 aa = a; + uint256 xn = 1; + + if (aa >= (1 << 128)) { + aa >>= 128; + xn <<= 64; + } + if (aa >= (1 << 64)) { + aa >>= 64; + xn <<= 32; + } + if (aa >= (1 << 32)) { + aa >>= 32; + xn <<= 16; + } + if (aa >= (1 << 16)) { + aa >>= 16; + xn <<= 8; + } + if (aa >= (1 << 8)) { + aa >>= 8; + xn <<= 4; + } + if (aa >= (1 << 4)) { + aa >>= 4; + xn <<= 2; + } + if (aa >= (1 << 2)) { + xn <<= 1; + } + + // We now have x_n such that `x_n = 2**(e-1) ≤ sqrt(a) < 2**e = 2 * x_n`. This implies ε_n ≤ 2**(e-1). + // + // We can refine our estimation by noticing that the middle of that interval minimizes the error. + // If we move x_n to equal 2**(e-1) + 2**(e-2), then we reduce the error to ε_n ≤ 2**(e-2). + // This is going to be our x_0 (and ε_0) + xn = (3 * xn) >> 1; // ε_0 := | x_0 - sqrt(a) | ≤ 2**(e-2) + + // From here, Newton's method give us: + // x_{n+1} = (x_n + a / x_n) / 2 + // + // One should note that: + // x_{n+1}² - a = ((x_n + a / x_n) / 2)² - a + // = ((x_n² + a) / (2 * x_n))² - a + // = (x_n⁴ + 2 * a * x_n² + a²) / (4 * x_n²) - a + // = (x_n⁴ + 2 * a * x_n² + a² - 4 * a * x_n²) / (4 * x_n²) + // = (x_n⁴ - 2 * a * x_n² + a²) / (4 * x_n²) + // = (x_n² - a)² / (2 * x_n)² + // = ((x_n² - a) / (2 * x_n))² + // ≥ 0 + // Which proves that for all n ≥ 1, sqrt(a) ≤ x_n + // + // This gives us the proof of quadratic convergence of the sequence: + // ε_{n+1} = | x_{n+1} - sqrt(a) | + // = | (x_n + a / x_n) / 2 - sqrt(a) | + // = | (x_n² + a - 2*x_n*sqrt(a)) / (2 * x_n) | + // = | (x_n - sqrt(a))² / (2 * x_n) | + // = | ε_n² / (2 * x_n) | + // = ε_n² / | (2 * x_n) | + // + // For the first iteration, we have a special case where x_0 is known: + // ε_1 = ε_0² / | (2 * x_0) | + // ≤ (2**(e-2))² / (2 * (2**(e-1) + 2**(e-2))) + // ≤ 2**(2*e-4) / (3 * 2**(e-1)) + // ≤ 2**(e-3) / 3 + // ≤ 2**(e-3-log2(3)) + // ≤ 2**(e-4.5) + // + // For the following iterations, we use the fact that, 2**(e-1) ≤ sqrt(a) ≤ x_n: + // ε_{n+1} = ε_n² / | (2 * x_n) | + // ≤ (2**(e-k))² / (2 * 2**(e-1)) + // ≤ 2**(2*e-2*k) / 2**e + // ≤ 2**(e-2*k) + xn = (xn + a / xn) >> 1; // ε_1 := | x_1 - sqrt(a) | ≤ 2**(e-4.5) -- special case, see above + xn = (xn + a / xn) >> 1; // ε_2 := | x_2 - sqrt(a) | ≤ 2**(e-9) -- general case with k = 4.5 + xn = (xn + a / xn) >> 1; // ε_3 := | x_3 - sqrt(a) | ≤ 2**(e-18) -- general case with k = 9 + xn = (xn + a / xn) >> 1; // ε_4 := | x_4 - sqrt(a) | ≤ 2**(e-36) -- general case with k = 18 + xn = (xn + a / xn) >> 1; // ε_5 := | x_5 - sqrt(a) | ≤ 2**(e-72) -- general case with k = 36 + xn = (xn + a / xn) >> 1; // ε_6 := | x_6 - sqrt(a) | ≤ 2**(e-144) -- general case with k = 72 + + // Because e ≤ 128 (as discussed during the first estimation phase), we know have reached a precision + // ε_6 ≤ 2**(e-144) < 1. Given we're operating on integers, then we can ensure that xn is now either + // sqrt(a) or sqrt(a) + 1. + return xn - SafeCast.toUint(xn > a / xn); + } + } + + /** + * @dev Calculates sqrt(a), following the selected rounding direction. + */ + function sqrt(uint256 a, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = sqrt(a); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && result * result < a); + } + } + + /** + * @dev Return the log in base 2 of a positive value rounded towards zero. + * Returns 0 if given 0. + */ + function log2(uint256 x) internal pure returns (uint256 r) { + // If value has upper 128 bits set, log2 result is at least 128 + r = SafeCast.toUint(x > 0xffffffffffffffffffffffffffffffff) << 7; + // If upper 64 bits of 128-bit half set, add 64 to result + r |= SafeCast.toUint((x >> r) > 0xffffffffffffffff) << 6; + // If upper 32 bits of 64-bit half set, add 32 to result + r |= SafeCast.toUint((x >> r) > 0xffffffff) << 5; + // If upper 16 bits of 32-bit half set, add 16 to result + r |= SafeCast.toUint((x >> r) > 0xffff) << 4; + // If upper 8 bits of 16-bit half set, add 8 to result + r |= SafeCast.toUint((x >> r) > 0xff) << 3; + // If upper 4 bits of 8-bit half set, add 4 to result + r |= SafeCast.toUint((x >> r) > 0xf) << 2; + + // Shifts value right by the current result and use it as an index into this lookup table: + // + // | x (4 bits) | index | table[index] = MSB position | + // |------------|---------|-----------------------------| + // | 0000 | 0 | table[0] = 0 | + // | 0001 | 1 | table[1] = 0 | + // | 0010 | 2 | table[2] = 1 | + // | 0011 | 3 | table[3] = 1 | + // | 0100 | 4 | table[4] = 2 | + // | 0101 | 5 | table[5] = 2 | + // | 0110 | 6 | table[6] = 2 | + // | 0111 | 7 | table[7] = 2 | + // | 1000 | 8 | table[8] = 3 | + // | 1001 | 9 | table[9] = 3 | + // | 1010 | 10 | table[10] = 3 | + // | 1011 | 11 | table[11] = 3 | + // | 1100 | 12 | table[12] = 3 | + // | 1101 | 13 | table[13] = 3 | + // | 1110 | 14 | table[14] = 3 | + // | 1111 | 15 | table[15] = 3 | + // + // The lookup table is represented as a 32-byte value with the MSB positions for 0-15 in the last 16 bytes. + assembly ("memory-safe") { + r := or(r, byte(shr(r, x), 0x0000010102020202030303030303030300000000000000000000000000000000)) + } + } + + /** + * @dev Return the log in base 2, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log2(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log2(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 1 << result < value); + } + } + + /** + * @dev Return the log in base 10 of a positive value rounded towards zero. + * Returns 0 if given 0. + */ + function log10(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >= 10 ** 64) { + value /= 10 ** 64; + result += 64; + } + if (value >= 10 ** 32) { + value /= 10 ** 32; + result += 32; + } + if (value >= 10 ** 16) { + value /= 10 ** 16; + result += 16; + } + if (value >= 10 ** 8) { + value /= 10 ** 8; + result += 8; + } + if (value >= 10 ** 4) { + value /= 10 ** 4; + result += 4; + } + if (value >= 10 ** 2) { + value /= 10 ** 2; + result += 2; + } + if (value >= 10 ** 1) { + result += 1; + } + } + return result; + } + + /** + * @dev Return the log in base 10, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log10(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log10(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 10 ** result < value); + } + } + + /** + * @dev Return the log in base 256 of a positive value rounded towards zero. + * Returns 0 if given 0. + * + * Adding one to the result gives the number of pairs of hex symbols needed to represent `value` as a hex string. + */ + function log256(uint256 x) internal pure returns (uint256 r) { + // If value has upper 128 bits set, log2 result is at least 128 + r = SafeCast.toUint(x > 0xffffffffffffffffffffffffffffffff) << 7; + // If upper 64 bits of 128-bit half set, add 64 to result + r |= SafeCast.toUint((x >> r) > 0xffffffffffffffff) << 6; + // If upper 32 bits of 64-bit half set, add 32 to result + r |= SafeCast.toUint((x >> r) > 0xffffffff) << 5; + // If upper 16 bits of 32-bit half set, add 16 to result + r |= SafeCast.toUint((x >> r) > 0xffff) << 4; + // Add 1 if upper 8 bits of 16-bit half set, and divide accumulated result by 8 + return (r >> 3) | SafeCast.toUint((x >> r) > 0xff); + } + + /** + * @dev Return the log in base 256, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log256(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log256(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 1 << (result << 3) < value); + } + } + + /** + * @dev Returns whether a provided rounding mode is considered rounding up for unsigned integers. + */ + function unsignedRoundsUp(Rounding rounding) internal pure returns (bool) { + return uint8(rounding) % 2 == 1; + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/SignedMath.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/math/SignedMath.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Standard signed math utilities missing in the Solidity language. + */ +library SignedMath { + /** + * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * + * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. + * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * one branch when needed, making this function more expensive. + */ + function ternary(bool condition, int256 a, int256 b) internal pure returns (int256) { + unchecked { + // branchless ternary works because: + // b ^ (a ^ b) == a + // b ^ 0 == b + return b ^ ((a ^ b) * int256(SafeCast.toUint(condition))); + } + } + + /** + * @dev Returns the largest of two signed numbers. + */ + function max(int256 a, int256 b) internal pure returns (int256) { + return ternary(a > b, a, b); + } + + /** + * @dev Returns the smallest of two signed numbers. + */ + function min(int256 a, int256 b) internal pure returns (int256) { + return ternary(a < b, a, b); + } + + /** + * @dev Returns the average of two signed numbers without overflow. + * The result is rounded towards zero. + */ + function average(int256 a, int256 b) internal pure returns (int256) { + // Formula from the book "Hacker's Delight" + int256 x = (a & b) + ((a ^ b) >> 1); + return x + (int256(uint256(x) >> 255) & (a ^ b)); + } + + /** + * @dev Returns the absolute unsigned value of a signed value. + */ + function abs(int256 n) internal pure returns (uint256) { + unchecked { + // Formula from the "Bit Twiddling Hacks" by Sean Eron Anderson. + // Since `n` is a signed integer, the generated bytecode will use the SAR opcode to perform the right shift, + // taking advantage of the most significant (or "sign" bit) in two's complement representation. + // This opcode adds new most significant bits set to the value of the previous most significant bit. As a result, + // the mask will either be `bytes32(0)` (if n is positive) or `~bytes32(0)` (if n is negative). + int256 mask = n >> 255; + + // A `bytes32(0)` mask leaves the input unchanged, while a `~bytes32(0)` mask complements it. + return uint256((n + mask) ^ mask); + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Strings.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/Strings.sol) + +pragma solidity ^0.8.20; + + + + +/** + * @dev String operations. + */ +library Strings { + using SafeCast for *; + + bytes16 private constant HEX_DIGITS = "0123456789abcdef"; + uint8 private constant ADDRESS_LENGTH = 20; + uint256 private constant SPECIAL_CHARS_LOOKUP = + (1 << 0x08) | // backspace + (1 << 0x09) | // tab + (1 << 0x0a) | // newline + (1 << 0x0c) | // form feed + (1 << 0x0d) | // carriage return + (1 << 0x22) | // double quote + (1 << 0x5c); // backslash + + /** + * @dev The `value` string doesn't fit in the specified `length`. + */ + error StringsInsufficientHexLength(uint256 value, uint256 length); + + /** + * @dev The string being parsed contains characters that are not in scope of the given base. + */ + error StringsInvalidChar(); + + /** + * @dev The string being parsed is not a properly formatted address. + */ + error StringsInvalidAddressFormat(); + + /** + * @dev Converts a `uint256` to its ASCII `string` decimal representation. + */ + function toString(uint256 value) internal pure returns (string memory) { + unchecked { + uint256 length = Math.log10(value) + 1; + string memory buffer = new string(length); + uint256 ptr; + assembly ("memory-safe") { + ptr := add(add(buffer, 0x20), length) + } + while (true) { + ptr--; + assembly ("memory-safe") { + mstore8(ptr, byte(mod(value, 10), HEX_DIGITS)) + } + value /= 10; + if (value == 0) break; + } + return buffer; + } + } + + /** + * @dev Converts a `int256` to its ASCII `string` decimal representation. + */ + function toStringSigned(int256 value) internal pure returns (string memory) { + return string.concat(value < 0 ? "-" : "", toString(SignedMath.abs(value))); + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation. + */ + function toHexString(uint256 value) internal pure returns (string memory) { + unchecked { + return toHexString(value, Math.log256(value) + 1); + } + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. + */ + function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { + uint256 localValue = value; + bytes memory buffer = new bytes(2 * length + 2); + buffer[0] = "0"; + buffer[1] = "x"; + for (uint256 i = 2 * length + 1; i > 1; --i) { + buffer[i] = HEX_DIGITS[localValue & 0xf]; + localValue >>= 4; + } + if (localValue != 0) { + revert StringsInsufficientHexLength(value, length); + } + return string(buffer); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its not checksummed ASCII `string` hexadecimal + * representation. + */ + function toHexString(address addr) internal pure returns (string memory) { + return toHexString(uint256(uint160(addr)), ADDRESS_LENGTH); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its checksummed ASCII `string` hexadecimal + * representation, according to EIP-55. + */ + function toChecksumHexString(address addr) internal pure returns (string memory) { + bytes memory buffer = bytes(toHexString(addr)); + + // hash the hex part of buffer (skip length + 2 bytes, length 40) + uint256 hashValue; + assembly ("memory-safe") { + hashValue := shr(96, keccak256(add(buffer, 0x22), 40)) + } + + for (uint256 i = 41; i > 1; --i) { + // possible values for buffer[i] are 48 (0) to 57 (9) and 97 (a) to 102 (f) + if (hashValue & 0xf > 7 && uint8(buffer[i]) > 96) { + // case shift by xoring with 0x20 + buffer[i] ^= 0x20; + } + hashValue >>= 4; + } + return string(buffer); + } + + /** + * @dev Returns true if the two strings are equal. + */ + function equal(string memory a, string memory b) internal pure returns (bool) { + return bytes(a).length == bytes(b).length && keccak256(bytes(a)) == keccak256(bytes(b)); + } + + /** + * @dev Parse a decimal string and returns the value as a `uint256`. + * + * Requirements: + * - The string must be formatted as `[0-9]*` + * - The result must fit into an `uint256` type + */ + function parseUint(string memory input) internal pure returns (uint256) { + return parseUint(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseUint-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `[0-9]*` + * - The result must fit into an `uint256` type + */ + function parseUint(string memory input, uint256 begin, uint256 end) internal pure returns (uint256) { + (bool success, uint256 value) = tryParseUint(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseUint-string} that returns false if the parsing fails because of an invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseUint(string memory input) internal pure returns (bool success, uint256 value) { + return _tryParseUintUncheckedBounds(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseUint-string-uint256-uint256} that returns false if the parsing fails because of an invalid + * character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseUint( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, uint256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseUintUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseUint-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseUintUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, uint256 value) { + bytes memory buffer = bytes(input); + + uint256 result = 0; + for (uint256 i = begin; i < end; ++i) { + uint8 chr = _tryParseChr(bytes1(_unsafeReadBytesOffset(buffer, i))); + if (chr > 9) return (false, 0); + result *= 10; + result += chr; + } + return (true, result); + } + + /** + * @dev Parse a decimal string and returns the value as a `int256`. + * + * Requirements: + * - The string must be formatted as `[-+]?[0-9]*` + * - The result must fit in an `int256` type. + */ + function parseInt(string memory input) internal pure returns (int256) { + return parseInt(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseInt-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `[-+]?[0-9]*` + * - The result must fit in an `int256` type. + */ + function parseInt(string memory input, uint256 begin, uint256 end) internal pure returns (int256) { + (bool success, int256 value) = tryParseInt(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseInt-string} that returns false if the parsing fails because of an invalid character or if + * the result does not fit in a `int256`. + * + * NOTE: This function will revert if the absolute value of the result does not fit in a `uint256`. + */ + function tryParseInt(string memory input) internal pure returns (bool success, int256 value) { + return _tryParseIntUncheckedBounds(input, 0, bytes(input).length); + } + + uint256 private constant ABS_MIN_INT256 = 2 ** 255; + + /** + * @dev Variant of {parseInt-string-uint256-uint256} that returns false if the parsing fails because of an invalid + * character or if the result does not fit in a `int256`. + * + * NOTE: This function will revert if the absolute value of the result does not fit in a `uint256`. + */ + function tryParseInt( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, int256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseIntUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseInt-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseIntUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, int256 value) { + bytes memory buffer = bytes(input); + + // Check presence of a negative sign. + bytes1 sign = begin == end ? bytes1(0) : bytes1(_unsafeReadBytesOffset(buffer, begin)); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + bool positiveSign = sign == bytes1("+"); + bool negativeSign = sign == bytes1("-"); + uint256 offset = (positiveSign || negativeSign).toUint(); + + (bool absSuccess, uint256 absValue) = tryParseUint(input, begin + offset, end); + + if (absSuccess && absValue < ABS_MIN_INT256) { + return (true, negativeSign ? -int256(absValue) : int256(absValue)); + } else if (absSuccess && negativeSign && absValue == ABS_MIN_INT256) { + return (true, type(int256).min); + } else return (false, 0); + } + + /** + * @dev Parse a hexadecimal string (with or without "0x" prefix), and returns the value as a `uint256`. + * + * Requirements: + * - The string must be formatted as `(0x)?[0-9a-fA-F]*` + * - The result must fit in an `uint256` type. + */ + function parseHexUint(string memory input) internal pure returns (uint256) { + return parseHexUint(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseHexUint-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `(0x)?[0-9a-fA-F]*` + * - The result must fit in an `uint256` type. + */ + function parseHexUint(string memory input, uint256 begin, uint256 end) internal pure returns (uint256) { + (bool success, uint256 value) = tryParseHexUint(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseHexUint-string} that returns false if the parsing fails because of an invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseHexUint(string memory input) internal pure returns (bool success, uint256 value) { + return _tryParseHexUintUncheckedBounds(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseHexUint-string-uint256-uint256} that returns false if the parsing fails because of an + * invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseHexUint( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, uint256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseHexUintUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseHexUint-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseHexUintUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, uint256 value) { + bytes memory buffer = bytes(input); + + // skip 0x prefix if present + bool hasPrefix = (end > begin + 1) && bytes2(_unsafeReadBytesOffset(buffer, begin)) == bytes2("0x"); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + uint256 offset = hasPrefix.toUint() * 2; + + uint256 result = 0; + for (uint256 i = begin + offset; i < end; ++i) { + uint8 chr = _tryParseChr(bytes1(_unsafeReadBytesOffset(buffer, i))); + if (chr > 15) return (false, 0); + result *= 16; + unchecked { + // Multiplying by 16 is equivalent to a shift of 4 bits (with additional overflow check). + // This guarantees that adding a value < 16 will not cause an overflow, hence the unchecked. + result += chr; + } + } + return (true, result); + } + + /** + * @dev Parse a hexadecimal string (with or without "0x" prefix), and returns the value as an `address`. + * + * Requirements: + * - The string must be formatted as `(0x)?[0-9a-fA-F]{40}` + */ + function parseAddress(string memory input) internal pure returns (address) { + return parseAddress(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseAddress-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `(0x)?[0-9a-fA-F]{40}` + */ + function parseAddress(string memory input, uint256 begin, uint256 end) internal pure returns (address) { + (bool success, address value) = tryParseAddress(input, begin, end); + if (!success) revert StringsInvalidAddressFormat(); + return value; + } + + /** + * @dev Variant of {parseAddress-string} that returns false if the parsing fails because the input is not a properly + * formatted address. See {parseAddress-string} requirements. + */ + function tryParseAddress(string memory input) internal pure returns (bool success, address value) { + return tryParseAddress(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseAddress-string-uint256-uint256} that returns false if the parsing fails because input is not a properly + * formatted address. See {parseAddress-string-uint256-uint256} requirements. + */ + function tryParseAddress( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, address value) { + if (end > bytes(input).length || begin > end) return (false, address(0)); + + bool hasPrefix = (end > begin + 1) && bytes2(_unsafeReadBytesOffset(bytes(input), begin)) == bytes2("0x"); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + uint256 expectedLength = 40 + hasPrefix.toUint() * 2; + + // check that input is the correct length + if (end - begin == expectedLength) { + // length guarantees that this does not overflow, and value is at most type(uint160).max + (bool s, uint256 v) = _tryParseHexUintUncheckedBounds(input, begin, end); + return (s, address(uint160(v))); + } else { + return (false, address(0)); + } + } + + function _tryParseChr(bytes1 chr) private pure returns (uint8) { + uint8 value = uint8(chr); + + // Try to parse `chr`: + // - Case 1: [0-9] + // - Case 2: [a-f] + // - Case 3: [A-F] + // - otherwise not supported + unchecked { + if (value > 47 && value < 58) value -= 48; + else if (value > 96 && value < 103) value -= 87; + else if (value > 64 && value < 71) value -= 55; + else return type(uint8).max; + } + + return value; + } + + /** + * @dev Escape special characters in JSON strings. This can be useful to prevent JSON injection in NFT metadata. + * + * WARNING: This function should only be used in double quoted JSON strings. Single quotes are not escaped. + * + * NOTE: This function escapes all unicode characters, and not just the ones in ranges defined in section 2.5 of + * RFC-4627 (U+0000 to U+001F, U+0022 and U+005C). ECMAScript's `JSON.parse` does recover escaped unicode + * characters that are not in this range, but other tooling may provide different results. + */ + function escapeJSON(string memory input) internal pure returns (string memory) { + bytes memory buffer = bytes(input); + bytes memory output = new bytes(2 * buffer.length); // worst case scenario + uint256 outputLength = 0; + + for (uint256 i; i < buffer.length; ++i) { + bytes1 char = bytes1(_unsafeReadBytesOffset(buffer, i)); + if (((SPECIAL_CHARS_LOOKUP & (1 << uint8(char))) != 0)) { + output[outputLength++] = "\\"; + if (char == 0x08) output[outputLength++] = "b"; + else if (char == 0x09) output[outputLength++] = "t"; + else if (char == 0x0a) output[outputLength++] = "n"; + else if (char == 0x0c) output[outputLength++] = "f"; + else if (char == 0x0d) output[outputLength++] = "r"; + else if (char == 0x5c) output[outputLength++] = "\\"; + else if (char == 0x22) { + // solhint-disable-next-line quotes + output[outputLength++] = '"'; + } + } else { + output[outputLength++] = char; + } + } + // write the actual length and deallocate unused memory + assembly ("memory-safe") { + mstore(output, outputLength) + mstore(0x40, add(output, shl(5, shr(5, add(outputLength, 63))))) + } + + return string(output); + } + + /** + * @dev Reads a bytes32 from a bytes array without bounds checking. + * + * NOTE: making this function internal would mean it could be used with memory unsafe offset, and marking the + * assembly block as such would prevent some optimizations. + */ + function _unsafeReadBytesOffset(bytes memory buffer, uint256 offset) private pure returns (bytes32 value) { + // This is not memory safe in the general case, but all calls to this private function are within bounds. + assembly ("memory-safe") { + value := mload(add(add(buffer, 0x20), offset)) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/introspection/ERC165.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/ERC165.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Implementation of the {IERC165} interface. + * + * Contracts that want to implement ERC-165 should inherit from this contract and override {supportsInterface} to check + * for the additional interface id that will be supported. For example: + * + * ```solidity + * function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + * return interfaceId == type(MyInterface).interfaceId || super.supportsInterface(interfaceId); + * } + * ``` + */ +abstract contract ERC165 is IERC165 { + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IERC165).interfaceId; + } +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/ERC721.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/ERC721.sol) + +pragma solidity ^0.8.20; + + + + + + + + +/** + * @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC-721] Non-Fungible Token Standard, including + * the Metadata extension, but not including the Enumerable extension, which is available separately as + * {ERC721Enumerable}. + */ +abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Errors { + using Strings for uint256; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + mapping(uint256 tokenId => address) private _owners; + + mapping(address owner => uint256) private _balances; + + mapping(uint256 tokenId => address) private _tokenApprovals; + + mapping(address owner => mapping(address operator => bool)) private _operatorApprovals; + + /** + * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection. + */ + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC721Metadata).interfaceId || + super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721 + function balanceOf(address owner) public view virtual returns (uint256) { + if (owner == address(0)) { + revert ERC721InvalidOwner(address(0)); + } + return _balances[owner]; + } + + /// @inheritdoc IERC721 + function ownerOf(uint256 tokenId) public view virtual returns (address) { + return _requireOwned(tokenId); + } + + /// @inheritdoc IERC721Metadata + function name() public view virtual returns (string memory) { + return _name; + } + + /// @inheritdoc IERC721Metadata + function symbol() public view virtual returns (string memory) { + return _symbol; + } + + /// @inheritdoc IERC721Metadata + function tokenURI(uint256 tokenId) public view virtual returns (string memory) { + _requireOwned(tokenId); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length > 0 ? string.concat(baseURI, tokenId.toString()) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, can be overridden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + /// @inheritdoc IERC721 + function approve(address to, uint256 tokenId) public virtual { + _approve(to, tokenId, _msgSender()); + } + + /// @inheritdoc IERC721 + function getApproved(uint256 tokenId) public view virtual returns (address) { + _requireOwned(tokenId); + + return _getApproved(tokenId); + } + + /// @inheritdoc IERC721 + function setApprovalForAll(address operator, bool approved) public virtual { + _setApprovalForAll(_msgSender(), operator, approved); + } + + /// @inheritdoc IERC721 + function isApprovedForAll(address owner, address operator) public view virtual returns (bool) { + return _operatorApprovals[owner][operator]; + } + + /// @inheritdoc IERC721 + function transferFrom(address from, address to, uint256 tokenId) public virtual { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + // Setting an "auth" arguments enables the `_isAuthorized` check which verifies that the token exists + // (from != 0). Therefore, it is not needed to verify that the return value is not 0 here. + address previousOwner = _update(to, tokenId, _msgSender()); + if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /// @inheritdoc IERC721 + function safeTransferFrom(address from, address to, uint256 tokenId) public { + safeTransferFrom(from, to, tokenId, ""); + } + + /// @inheritdoc IERC721 + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual { + transferFrom(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } + + /** + * @dev Returns the owner of the `tokenId`. Does NOT revert if token doesn't exist + * + * IMPORTANT: Any overrides to this function that add ownership of tokens not tracked by the + * core ERC-721 logic MUST be matched with the use of {_increaseBalance} to keep balances + * consistent with ownership. The invariant to preserve is that for any address `a` the value returned by + * `balanceOf(a)` must be equal to the number of tokens such that `_ownerOf(tokenId)` is `a`. + */ + function _ownerOf(uint256 tokenId) internal view virtual returns (address) { + return _owners[tokenId]; + } + + /** + * @dev Returns the approved address for `tokenId`. Returns 0 if `tokenId` is not minted. + */ + function _getApproved(uint256 tokenId) internal view virtual returns (address) { + return _tokenApprovals[tokenId]; + } + + /** + * @dev Returns whether `spender` is allowed to manage `owner`'s tokens, or `tokenId` in + * particular (ignoring whether it is owned by `owner`). + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + */ + function _isAuthorized(address owner, address spender, uint256 tokenId) internal view virtual returns (bool) { + return + spender != address(0) && + (owner == spender || isApprovedForAll(owner, spender) || _getApproved(tokenId) == spender); + } + + /** + * @dev Checks if `spender` can operate on `tokenId`, assuming the provided `owner` is the actual owner. + * Reverts if: + * - `spender` does not have approval from `owner` for `tokenId`. + * - `spender` does not have approval to manage all of `owner`'s assets. + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + */ + function _checkAuthorized(address owner, address spender, uint256 tokenId) internal view virtual { + if (!_isAuthorized(owner, spender, tokenId)) { + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else { + revert ERC721InsufficientApproval(spender, tokenId); + } + } + } + + /** + * @dev Unsafe write access to the balances, used by extensions that "mint" tokens using an {ownerOf} override. + * + * NOTE: the value is limited to type(uint128).max. This protect against _balance overflow. It is unrealistic that + * a uint256 would ever overflow from increments when these increments are bounded to uint128 values. + * + * WARNING: Increasing an account's balance using this function tends to be paired with an override of the + * {_ownerOf} function to resolve the ownership of the corresponding tokens so that balances and ownership + * remain consistent with one another. + */ + function _increaseBalance(address account, uint128 value) internal virtual { + unchecked { + _balances[account] += value; + } + } + + /** + * @dev Transfers `tokenId` from its current owner to `to`, or alternatively mints (or burns) if the current owner + * (or `to`) is the zero address. Returns the owner of the `tokenId` before the update. + * + * The `auth` argument is optional. If the value passed is non 0, then this function will check that + * `auth` is either the owner of the token, or approved to operate on the token (by the owner). + * + * Emits a {Transfer} event. + * + * NOTE: If overriding this function in a way that tracks balances, see also {_increaseBalance}. + */ + function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) { + address from = _ownerOf(tokenId); + + // Perform (optional) operator check + if (auth != address(0)) { + _checkAuthorized(from, auth, tokenId); + } + + // Execute the update + if (from != address(0)) { + // Clear approval. No need to re-authorize or emit the Approval event + _approve(address(0), tokenId, address(0), false); + + unchecked { + _balances[from] -= 1; + } + } + + if (to != address(0)) { + unchecked { + _balances[to] += 1; + } + } + + _owners[tokenId] = to; + + emit Transfer(from, to, tokenId); + + return from; + } + + /** + * @dev Mints `tokenId` and transfers it to `to`. + * + * WARNING: Usage of this method is discouraged, use {_safeMint} whenever possible + * + * Requirements: + * + * - `tokenId` must not exist. + * - `to` cannot be the zero address. + * + * Emits a {Transfer} event. + */ + function _mint(address to, uint256 tokenId) internal { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address previousOwner = _update(to, tokenId, address(0)); + if (previousOwner != address(0)) { + revert ERC721InvalidSender(address(0)); + } + } + + /** + * @dev Mints `tokenId`, transfers it to `to` and checks for `to` acceptance. + * + * Requirements: + * + * - `tokenId` must not exist. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function _safeMint(address to, uint256 tokenId) internal { + _safeMint(to, tokenId, ""); + } + + /** + * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is + * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. + */ + function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual { + _mint(to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), address(0), to, tokenId, data); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * This is an internal function that does not check if the sender is authorized to operate on the token. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId) internal { + address previousOwner = _update(address(0), tokenId, address(0)); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * As opposed to {transferFrom}, this imposes no restrictions on msg.sender. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * + * Emits a {Transfer} event. + */ + function _transfer(address from, address to, uint256 tokenId) internal { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address previousOwner = _update(to, tokenId, address(0)); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking that contract recipients + * are aware of the ERC-721 standard to prevent tokens from being forever locked. + * + * `data` is additional data, it has no specified format and it is sent in call to `to`. + * + * This internal function is like {safeTransferFrom} in the sense that it invokes + * {IERC721Receiver-onERC721Received} on the receiver, and can be used to e.g. + * implement alternative mechanisms to perform token transfer, such as signature-based. + * + * Requirements: + * + * - `tokenId` token must exist and be owned by `from`. + * - `to` cannot be the zero address. + * - `from` cannot be the zero address. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function _safeTransfer(address from, address to, uint256 tokenId) internal { + _safeTransfer(from, to, tokenId, ""); + } + + /** + * @dev Same as {xref-ERC721-_safeTransfer-address-address-uint256-}[`_safeTransfer`], with an additional `data` parameter which is + * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. + */ + function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual { + _transfer(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } + + /** + * @dev Approve `to` to operate on `tokenId` + * + * The `auth` argument is optional. If the value passed is non 0, then this function will check that `auth` is + * either the owner of the token, or approved to operate on all tokens held by this owner. + * + * Emits an {Approval} event. + * + * Overrides to this logic should be done to the variant with an additional `bool emitEvent` argument. + */ + function _approve(address to, uint256 tokenId, address auth) internal { + _approve(to, tokenId, auth, true); + } + + /** + * @dev Variant of `_approve` with an optional flag to enable or disable the {Approval} event. The event is not + * emitted in the context of transfers. + */ + function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal virtual { + // Avoid reading the owner unless necessary + if (emitEvent || auth != address(0)) { + address owner = _requireOwned(tokenId); + + // We do not use _isAuthorized because single-token approvals should not be able to call approve + if (auth != address(0) && owner != auth && !isApprovedForAll(owner, auth)) { + revert ERC721InvalidApprover(auth); + } + + if (emitEvent) { + emit Approval(owner, to, tokenId); + } + } + + _tokenApprovals[tokenId] = to; + } + + /** + * @dev Approve `operator` to operate on all of `owner` tokens + * + * Requirements: + * - operator can't be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function _setApprovalForAll(address owner, address operator, bool approved) internal virtual { + if (operator == address(0)) { + revert ERC721InvalidOperator(operator); + } + _operatorApprovals[owner][operator] = approved; + emit ApprovalForAll(owner, operator, approved); + } + + /** + * @dev Reverts if the `tokenId` doesn't have a current owner (it hasn't been minted, or it has been burned). + * Returns the owner. + * + * Overrides to ownership logic should be done to {_ownerOf}. + */ + function _requireOwned(uint256 tokenId) internal view returns (address) { + address owner = _ownerOf(tokenId); + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + return owner; + } +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/IERC721Enumerable.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/IERC721Enumerable.sol) + +pragma solidity >=0.6.2; + + +/** + * @title ERC-721 Non-Fungible Token Standard, optional enumeration extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + */ +interface IERC721Enumerable is IERC721 { + /** + * @dev Returns the total amount of tokens stored by the contract. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns a token ID owned by `owner` at a given `index` of its token list. + * Use along with {balanceOf} to enumerate all of ``owner``'s tokens. + */ + function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256); + + /** + * @dev Returns a token ID at a given `index` of all the tokens stored by the contract. + * Use along with {totalSupply} to enumerate all tokens. + */ + function tokenByIndex(uint256 index) external view returns (uint256); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/ERC721Enumerable.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/ERC721Enumerable.sol) + +pragma solidity ^0.8.20; + + + + +/** + * @dev This implements an optional extension of {ERC721} defined in the ERC that adds enumerability + * of all the token ids in the contract as well as all token ids owned by each account. + * + * CAUTION: {ERC721} extensions that implement custom `balanceOf` logic, such as {ERC721Consecutive}, + * interfere with enumerability and should not be used together with {ERC721Enumerable}. + */ +abstract contract ERC721Enumerable is ERC721, IERC721Enumerable { + mapping(address owner => mapping(uint256 index => uint256)) private _ownedTokens; + mapping(uint256 tokenId => uint256) private _ownedTokensIndex; + + uint256[] private _allTokens; + mapping(uint256 tokenId => uint256) private _allTokensIndex; + + /** + * @dev An `owner`'s token query was out of bounds for `index`. + * + * NOTE: The owner being `address(0)` indicates a global out of bounds index. + */ + error ERC721OutOfBoundsIndex(address owner, uint256 index); + + /** + * @dev Batch mint is not allowed. + */ + error ERC721EnumerableForbiddenBatchMint(); + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC721) returns (bool) { + return interfaceId == type(IERC721Enumerable).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721Enumerable + function tokenOfOwnerByIndex(address owner, uint256 index) public view virtual returns (uint256) { + if (index >= balanceOf(owner)) { + revert ERC721OutOfBoundsIndex(owner, index); + } + return _ownedTokens[owner][index]; + } + + /// @inheritdoc IERC721Enumerable + function totalSupply() public view virtual returns (uint256) { + return _allTokens.length; + } + + /// @inheritdoc IERC721Enumerable + function tokenByIndex(uint256 index) public view virtual returns (uint256) { + if (index >= totalSupply()) { + revert ERC721OutOfBoundsIndex(address(0), index); + } + return _allTokens[index]; + } + + /// @inheritdoc ERC721 + function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) { + address previousOwner = super._update(to, tokenId, auth); + + if (previousOwner == address(0)) { + _addTokenToAllTokensEnumeration(tokenId); + } else if (previousOwner != to) { + _removeTokenFromOwnerEnumeration(previousOwner, tokenId); + } + if (to == address(0)) { + _removeTokenFromAllTokensEnumeration(tokenId); + } else if (previousOwner != to) { + _addTokenToOwnerEnumeration(to, tokenId); + } + + return previousOwner; + } + + /** + * @dev Private function to add a token to this extension's ownership-tracking data structures. + * @param to address representing the new owner of the given token ID + * @param tokenId uint256 ID of the token to be added to the tokens list of the given address + */ + function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private { + uint256 length = balanceOf(to) - 1; + _ownedTokens[to][length] = tokenId; + _ownedTokensIndex[tokenId] = length; + } + + /** + * @dev Private function to add a token to this extension's token tracking data structures. + * @param tokenId uint256 ID of the token to be added to the tokens list + */ + function _addTokenToAllTokensEnumeration(uint256 tokenId) private { + _allTokensIndex[tokenId] = _allTokens.length; + _allTokens.push(tokenId); + } + + /** + * @dev Private function to remove a token from this extension's ownership-tracking data structures. Note that + * while the token is not assigned a new owner, the `_ownedTokensIndex` mapping is _not_ updated: this allows for + * gas optimizations e.g. when performing a transfer operation (avoiding double writes). + * This has O(1) time complexity, but alters the order of the _ownedTokens array. + * @param from address representing the previous owner of the given token ID + * @param tokenId uint256 ID of the token to be removed from the tokens list of the given address + */ + function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private { + // To prevent a gap in from's tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + uint256 lastTokenIndex = balanceOf(from); + uint256 tokenIndex = _ownedTokensIndex[tokenId]; + + mapping(uint256 index => uint256) storage _ownedTokensByOwner = _ownedTokens[from]; + + // When the token to delete is the last token, the swap operation is unnecessary + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = _ownedTokensByOwner[lastTokenIndex]; + + _ownedTokensByOwner[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + } + + // This also deletes the contents at the last position of the array + delete _ownedTokensIndex[tokenId]; + delete _ownedTokensByOwner[lastTokenIndex]; + } + + /** + * @dev Private function to remove a token from this extension's token tracking data structures. + * This has O(1) time complexity, but alters the order of the _allTokens array. + * @param tokenId uint256 ID of the token to be removed from the tokens list + */ + function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private { + // To prevent a gap in the tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + uint256 lastTokenIndex = _allTokens.length - 1; + uint256 tokenIndex = _allTokensIndex[tokenId]; + + // When the token to delete is the last token, the swap operation is unnecessary. However, since this occurs so + // rarely (when the last minted token is burnt) that we still do the swap here to avoid the gas cost of adding + // an 'if' statement (like in _removeTokenFromOwnerEnumeration) + uint256 lastTokenId = _allTokens[lastTokenIndex]; + + _allTokens[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _allTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + + // This also deletes the contents at the last position of the array + delete _allTokensIndex[tokenId]; + _allTokens.pop(); + } + + /** + * See {ERC721-_increaseBalance}. We need that to account tokens that were minted in batch + */ + function _increaseBalance(address account, uint128 amount) internal virtual override { + if (amount > 0) { + revert ERC721EnumerableForbiddenBatchMint(); + } + super._increaseBalance(account, amount); + } +} + +// File: @openzeppelin/contracts@5.4.0/access/Ownable.sol + + +// OpenZeppelin Contracts (last updated v5.0.0) (access/Ownable.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Contract module which provides a basic access control mechanism, where + * there is an account (an owner) that can be granted exclusive access to + * specific functions. + * + * The initial owner is set to the address provided by the deployer. This can + * later be changed with {transferOwnership}. + * + * This module is used through inheritance. It will make available the modifier + * `onlyOwner`, which can be applied to your functions to restrict their use to + * the owner. + */ +abstract contract Ownable is Context { + address private _owner; + + /** + * @dev The caller account is not authorized to perform an operation. + */ + error OwnableUnauthorizedAccount(address account); + + /** + * @dev The owner is not a valid owner account. (eg. `address(0)`) + */ + error OwnableInvalidOwner(address owner); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @dev Initializes the contract setting the address provided by the deployer as the initial owner. + */ + constructor(address initialOwner) { + if (initialOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(initialOwner); + } + + /** + * @dev Throws if called by any account other than the owner. + */ + modifier onlyOwner() { + _checkOwner(); + _; + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view virtual returns (address) { + return _owner; + } + + /** + * @dev Throws if the sender is not the owner. + */ + function _checkOwner() internal view virtual { + if (owner() != _msgSender()) { + revert OwnableUnauthorizedAccount(_msgSender()); + } + } + + /** + * @dev Leaves the contract without owner. It will not be possible to call + * `onlyOwner` functions. Can only be called by the current owner. + * + * NOTE: Renouncing ownership will leave the contract without an owner, + * thereby disabling any functionality that is only available to the owner. + */ + function renounceOwnership() public virtual onlyOwner { + _transferOwnership(address(0)); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Can only be called by the current owner. + */ + function transferOwnership(address newOwner) public virtual onlyOwner { + if (newOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(newOwner); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Internal function without access restriction. + */ + function _transferOwnership(address newOwner) internal virtual { + address oldOwner = _owner; + _owner = newOwner; + emit OwnershipTransferred(oldOwner, newOwner); + } +} + +// File: contracts/NFTNumbered.sol + + +pragma solidity ^0.8.27; + + + + +/// @title NFTNumbered +/// @notice ERC721 contract with sequential variants for immutable metadata for minted tokens, allowing to change metadata for future tokens. +/// @dev Non-upgradeable. Minter address is settable by owner. Global sequential token ID. +contract NFTNumbered is ERC721Enumerable, Ownable { + address public minter; + bool public mintingLocked; + TokenInfo[] private tokenInfos; + uint public nextTokenId = 1; + + struct TokenInfo { + uint tokenId; + string tokenUri; + } + + constructor( + string memory _name, + string memory _symbol, + string memory _tokenUri + ) ERC721(_name, _symbol) Ownable(msg.sender) { + minter = msg.sender; + mintingLocked = false; + setNextTokenURI(_tokenUri); + } + + event MinterUpdated(address indexed newMinter); + event MintingLocked(); + event NextTokenURI(uint nextTokenId, string nextTokenUri); + + /// @notice Updates the minter address. + function setMinter(address newMinter) external onlyOwner whenNotLocked { + minter = newMinter; + emit MinterUpdated(newMinter); + } + + /// @notice Permanently disable minting and changes. + function lockMintingPermanently() external onlyOwner { + mintingLocked = true; + emit MintingLocked(); + } + + modifier whenNotLocked() { + require(!mintingLocked, "Contract permanently locked for changes and minting"); + _; + } + + /// @notice Sets tokenUri for future mints only. + function setNextTokenURI(string memory newTokenUri) public onlyOwner whenNotLocked { + uint len = tokenInfos.length; + TokenInfo memory newInfo = TokenInfo({tokenId: nextTokenId, tokenUri: newTokenUri}); + if (len == 0 || tokenInfos[len - 1].tokenId < nextTokenId) { // add + tokenInfos.push(newInfo); + } else { // replace + tokenInfos[len - 1] = newInfo; + } + emit NextTokenURI(nextTokenId, newTokenUri); + } + + /// @notice metadata URI of the token that will be minted next + function nextTokenURI() external view returns (string memory) { + return tokenInfos[tokenInfos.length - 1].tokenUri; + } + + /// @notice Mints a new token. + /// @param to The recipient of the token. + function mint(address to) external whenNotLocked { + require(msg.sender == owner() || msg.sender == minter, "Caller must be the owner or minter"); + require(to != address(0), "Cannot mint to zero address"); + uint tokenId = nextTokenId++; + _safeMint(to, tokenId); + } + + /// @notice Burn owned token. + function burn(uint tokenId) external { + _burn(tokenId); + } + + /// @notice prohibit approvals + // address to, uint256 tokenId + function approve(address, uint256) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice prohibit approvals + // address operator, bool approved + function setApprovalForAll(address, bool) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice Limits ownership to 1 token. + function _update(address to, uint tokenId, address auth) internal virtual override returns (address) { + require(to == address(0) || balanceOf(to) == 0, "Soulbound token: only 1 per address"); + require(to == address(0) || _ownerOf(tokenId) == address(0), "Soulbound token: transfers prohibited"); + return super._update(to, tokenId, auth); + } + + /// @notice Returns embedded JSON metadata URI. + /// @param id Token ID. + function tokenURI(uint id) public view virtual override returns (string memory) { + _requireOwned(id); + uint len = tokenInfos.length; + for (uint i = len; i > 0; ) { + unchecked { i--; } + if (tokenInfos[i].tokenId > id) continue; + return tokenInfos[i].tokenUri; + } + revert("Unknown token ID"); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + (bool success, ) = payable(owner()).call{value: address(this).balance}(""); + require(success, "Withdraw failed"); + } +} diff --git a/eth/nft/scripts/deploy_with_ethers.ts b/eth/nft/scripts/deploy_with_ethers.ts new file mode 100644 index 0000000000..63533fd6f5 --- /dev/null +++ b/eth/nft/scripts/deploy_with_ethers.ts @@ -0,0 +1,10 @@ +import { deploy } from './ethers-lib' + +(async () => { + try { + const result = await deploy('MyToken', []) + console.log(`address: ${result.address}`) + } catch (e) { + console.log(e.message) + } +})() \ No newline at end of file diff --git a/eth/nft/tests/MultiERC1155_test.sol b/eth/nft/tests/MultiERC1155_test.sol new file mode 100644 index 0000000000..f0152ad657 --- /dev/null +++ b/eth/nft/tests/MultiERC1155_test.sol @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "remix_tests.sol"; + +import "../contracts/MultiERC1155.sol"; + +contract MultiERC1155Test { + MultiERC1155 public ct; + address public owner = address(this); + address public admin = address(0x1); + address public minter = address(0x2); + address public user = address(0x3); + address public recipient = address(0x4); + + MultiERC1155.TokenInfo defaultInfo = MultiERC1155.TokenInfo({ + tokenUri: "https://example.com/token.json", + totalSupply: 0, + enabled: true + }); + + function beforeAll() public { + ct = new MultiERC1155(); + MultiERC1155.TokenInfo memory newInfo = defaultInfo; + (bool success, ) = address(ct).call(abi.encodeWithSignature("addToken((string,bool,string,string,string,uint256))", newInfo)); + Assert.equal(success, true, "addToken success"); + } + + // Constructor Test + function testConstructor() public { + Assert.equal(ct.owner(), owner, "Owner should be deployer"); + Assert.equal(ct.admin(), owner, "Admin should be deployer"); + Assert.equal(ct.minter(), owner, "Minter should be deployer"); + Assert.equal(ct.mintingEnabled(), true, "Minting should be enabled"); + Assert.equal(ct.contractLocked(), false, "Contract should not be locked"); + uint[] memory ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(1), "One token ID added"); + Assert.equal(ids[0], uint(1), "First token ID is 1"); + (, uint currentSupply, bool exists, bool locked) = ct.tokens(1); + Assert.equal(exists, true, "Token 1 exists"); + Assert.equal(locked, false, "Token 1 not locked"); + Assert.equal(currentSupply, uint(0), "Token 1 supply 0"); + } + + // setAdmin Test + function testSetAdmin() public { + ct.setAdmin(admin); + Assert.equal(ct.admin(), admin, "Admin updated"); + } + + function testSetAdminOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("setAdmin(address)", admin)); + Assert.equal(success, true, "Set admin from owner succeeds"); // Since this is owner + + // To test revert, Remix plugin runs as this, so for non-owner, manual simulation not easy; note as TODO or skip + } + + // setMinter Test + function testSetMinter() public { + ct.setMinter(minter); + Assert.equal(ct.minter(), minter, "Minter updated"); + } + + function testSetMinterOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("setMinter(address)", minter)); + Assert.equal(success, true, "Set minter from owner succeeds"); + } + + // toggleMinting Test + function testToggleMinting() public { + ct.toggleMinting(false); + Assert.equal(ct.mintingEnabled(), false, "Minting disabled"); + + ct.toggleMinting(true); + Assert.equal(ct.mintingEnabled(), true, "Minting enabled"); + } + + function testToggleMintingNoChange() public { + ct.toggleMinting(true); // Already true + Assert.equal(ct.mintingEnabled(), true, "No change if same"); + } + + function testToggleMintingOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("toggleMinting(bool)", false)); + Assert.equal(success, true, "Toggle from owner succeeds"); + } + + // lockContract Test + function testLockContract() public { + ct.lockContract(); + Assert.equal(ct.contractLocked(), true, "Contract locked"); + Assert.equal(ct.mintingEnabled(), false, "Minting disabled"); + } + + function testLockContractOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockContract()")); + Assert.equal(success, true, "Lock from owner succeeds"); + } + + // addToken Test + function testAddToken() public { + MultiERC1155.TokenInfo memory newInfo = MultiERC1155.TokenInfo({ + tokenUri: "https://example.com/new_token.json", + totalSupply: 100, + enabled: true + }); + + ct.addToken(newInfo); + uint[] memory ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(2), "Two token IDs"); + Assert.equal(ids[1], uint(2), "Second token ID 2"); + (MultiERC1155.TokenInfo memory tokenInfo, uint currentSupply, bool exists, bool locked) = ct.tokens(2); + Assert.equal(exists, true, "Token 2 exists"); + Assert.equal(locked, false, "Token 2 not locked"); + Assert.equal(currentSupply, uint(0), "Token 2 supply 0"); + Assert.equal(tokenInfo.totalSupply, uint(100), "Token 2 totalSupply 100"); + // Validate info fields as needed + } + + function testAddTokenByAdmin() public { + ct.setAdmin(admin); + MultiERC1155.TokenInfo memory newInfo = defaultInfo; + (bool success, ) = address(ct).call(abi.encodeWithSignature("addToken((string,bool,string,string,string,uint256))", newInfo)); + Assert.equal(success, true, "Add by admin succeeds"); + uint[] memory ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(2), "Two token IDs"); + } + + function testAddTokenRevertInvalidInfo() public { + MultiERC1155.TokenInfo memory invalidInfo = MultiERC1155.TokenInfo({ + tokenUri: "", + totalSupply: 0, + enabled: true + }); + (bool success, ) = address(ct).call(abi.encodeWithSignature("addToken((string,bool,string,string,string,uint256))", invalidInfo)); + Assert.equal(success, false, "Invalid info reverts"); + } + + function testAddTokenOnlyAdminOrOwner() public { + MultiERC1155.TokenInfo memory info = defaultInfo; + (bool success, ) = address(ct).call(abi.encodeWithSignature("addToken((string,bool,string,string,string,uint256))", info)); + Assert.equal(success, true, "Add from owner succeeds"); + } + + // removeToken Test + function testRemoveToken() public { + MultiERC1155.TokenInfo memory newInfo = defaultInfo; + ct.addToken(newInfo); + uint[] memory ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(2), "Two token IDs"); + + ct.removeToken(2); + ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(1), "One token ID after removal"); + (,,bool exists,) = ct.tokens(2); + Assert.equal(exists, false, "Token 2 removed"); + } + + function testRemoveTokenRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("removeToken(uint256)", 99)); + Assert.equal(success, false, "Remove non-exist reverts"); + } + + function testRemoveTokenRevertHasSupply() public { + ct.mint(user, 1, 10, ""); + (bool success,) = address(ct).call(abi.encodeWithSignature("removeToken(uint256)", 1)); + Assert.equal(success, false, "Remove with supply reverts"); + } + + function testRemoveTokenRevertLast() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("removeToken(uint256)", 1)); + Assert.equal(success, false, "Remove last reverts"); + } + + function testRemoveTokenOnlyAdminOrOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("removeToken(uint256)", 1)); + Assert.equal(success, false, "Remove last fails"); // But for non-owner, need simulation + } + + // updateToken Test + function testUpdateToken() public { + ct.updateToken(1, false, 100); + (MultiERC1155.TokenInfo memory tokenInfo,,,) = ct.tokens(1); + Assert.equal(tokenInfo.enabled, false, "Enabled updated"); + Assert.equal(tokenInfo.totalSupply, uint(100), "Total supply updated"); + } + + function testUpdateTokenNoChange() public { + (MultiERC1155.TokenInfo memory tokenInfo,,,) = ct.tokens(1); + ct.updateToken(1, tokenInfo.enabled, tokenInfo.totalSupply); + // No assert, as no change + } + + function testUpdateTokenRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("updateToken(uint256,bool,uint256)", 99, true, 0)); + Assert.equal(success, false, "Update non-exist reverts"); + } + + function testUpdateTokenRevertLocked() public { + ct.mint(user, 1, 10, ""); + ct.lockToken(1); + (bool success, ) = address(ct).call(abi.encodeWithSignature("updateToken(uint256,bool,uint256)", 1, true, 0)); + Assert.equal(success, false, "Update locked reverts"); + } + + function testUpdateTokenRevertInvalidSupply() public { + ct.mint(user, 1, 10, ""); + (bool success, ) = address(ct).call(abi.encodeWithSignature("updateToken(uint256,bool,uint256)", 1, true, 5)); + Assert.equal(success, false, "Invalid supply reverts"); + } + + function testUpdateTokenOnlyAdminOrOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("updateToken(uint256,bool,uint256)", 1, true, 0)); + Assert.equal(success, true, "Update from owner succeeds"); + } + + // lockToken Test + function testLockToken() public { + ct.mint(user, 1, 10, ""); + ct.lockToken(1); + (MultiERC1155.TokenInfo memory tokenInfo,,, bool locked) = ct.tokens(1); + Assert.equal(locked, true, "Token locked"); + Assert.equal(tokenInfo.enabled, false, "Enabled false"); + } + + function testLockTokenRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockToken(uint256)", 99)); + Assert.equal(success, false, "Lock non-exist reverts"); + } + + function testLockTokenRevertAlreadyLocked() public { + ct.mint(user, 1, 10, ""); + ct.lockToken(1); + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockToken(uint256)", 1)); + Assert.equal(success, false, "Lock already locked reverts"); + } + + function testLockTokenRevertNoSupply() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockToken(uint256)", 1)); + Assert.equal(success, false, "Lock no supply reverts"); + } + + function testLockTokenOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockToken(uint256)", 1)); + Assert.equal(success, false, "Lock no supply fails"); // Test with supply + } + + // mint Test + function testMint() public { + ct.mint(user, 1, 10, ""); + Assert.equal(ct.balanceOf(user, 1), 10, "User balance 10"); + (,uint currentSupply,,) = ct.tokens(1); + Assert.equal(currentSupply, 10, "Token supply 10"); + } + + function testMintRevertLockedContract() public { + ct.lockContract(); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, false, "Mint locked contract reverts"); + } + + function testMintRevertMintingDisabled() public { + ct.toggleMinting(false); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, false, "Mint disabled reverts"); + } + + function testMintRevertInvalidCaller() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, true, "Mint from owner succeeds"); + } + + function testMintRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 99, 10, "")); + Assert.equal(success, false, "Mint non-exist reverts"); + } + + function testMintRevertLockedToken() public { + ct.mint(user, 1, 10, ""); + ct.lockToken(1); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 5, "")); + Assert.equal(success, false, "Mint locked token reverts"); + } + + function testMintRevertDisabledToken() public { + ct.updateToken(1, false, 0); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, false, "Mint disabled token reverts"); + } + + function testMintRevertSupplyExceeded() public { + ct.updateToken(1, true, 5); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, false, "Mint supply exceeded reverts"); + } + + function testMintRevertZeroAmount() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 0, "")); + Assert.equal(success, false, "Mint zero amount reverts"); + } + + // _update Test (via transfer/burn) + function testUpdateMint() public { + ct.mint(user, 1, 10, ""); + (,uint currentSupply,,) = ct.tokens(1); + Assert.equal(currentSupply, 10, "Supply after mint"); + } + + function testUpdateBurn() public { + ct.mint(user, 1, 10, ""); + (bool success, ) = address(ct).call(abi.encodeWithSignature("safeTransferFrom(address,address,uint256,uint256,bytes)", user, address(0), 1, 5, "")); + Assert.equal(success, true, "Burn succeeds"); + (,uint currentSupply,,) = ct.tokens(1); + Assert.equal(currentSupply, 5, "Supply after burn"); + } + + function testUpdateTransfer() public { + ct.mint(user, 1, 10, ""); + (bool success, ) = address(ct).call(abi.encodeWithSignature("safeTransferFrom(address,address,uint256,uint256,bytes)", user, recipient, 1, 5, "")); + Assert.equal(success, true, "Transfer succeeds"); + (,uint currentSupply,,) = ct.tokens(1); + Assert.equal(currentSupply, 10, "Supply unchanged on transfer"); + } + + // uri Test + function testUri() public { + string memory json = Base64.encode(bytes('{"name":"Test Token","description":"Test Description","image":"https://test.com/image.png","properties":{}}')); + string memory expected = string.concat("data:application/json;base64,", json); + Assert.equal(ct.uri(1), expected, "URI matches"); + } + + function testUriRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("uri(uint256)", 99)); + Assert.equal(success, false, "URI non-exist reverts"); + } +} diff --git a/eth/nft/tests/NFTMinter_test.sol b/eth/nft/tests/NFTMinter_test.sol new file mode 100644 index 0000000000..c6393f5f2b --- /dev/null +++ b/eth/nft/tests/NFTMinter_test.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity >=0.4.22 <0.9.0; + +// This import is automatically injected by Remix +import "remix_tests.sol"; + +// This import is required to use custom transaction context +// Although it may fail compilation in 'Solidity Compiler' plugin +// But it will work fine in 'Solidity Unit Testing' plugin +import "remix_accounts.sol"; +import "../contracts/NFTMinter.sol"; +import "../contracts/NFTNumbered.sol"; + +// File name has to end with '_test.sol', this file can contain more than one testSuite contracts +contract NFTMinterTest { + NFTNumbered s; + NFTMinter m; + address public owner = address(this); + + function beforeAll() public { + s = new NFTNumbered( + "SimpleX NFT: SMPX testnet access", + "SIMPLEXNFT", + "https://ipfs.io/ipfs/abcd" + ); + m = new NFTMinter(address(s), 0, 0, false); + } + + function testCreateMinter() public { + Assert.equal(address(m.nft()), address(s), "bad nft contract"); + Assert.equal(m.mintEndTime(), 0, "bad time"); + Assert.equal(m.owner(), owner, "bad owner"); + } + + function testMinting() public { + m.setMintStartTime(block.timestamp + 86400); + try m.mint() { + Assert.ok(false, "expected revert"); + } catch Error(string memory reason) { + Assert.equal(reason, "Minting not started", "bad reason"); + } catch (bytes memory) { + Assert.ok(false, "unexpected error"); + } + m.setMintStartTime(0); + } +} diff --git a/eth/nft/tests/NFTNumbered_test.sol b/eth/nft/tests/NFTNumbered_test.sol new file mode 100644 index 0000000000..8c2f8fb51f --- /dev/null +++ b/eth/nft/tests/NFTNumbered_test.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity >=0.7.0 <0.9.0; +import "remix_tests.sol"; +import "remix_accounts.sol"; +import "../contracts/NFTNumbered.sol"; + +contract NFTNumberedTest { + NFTNumbered s; + address public owner = address(this); + address public user1 = address(0x1); + address public user2 = address(0x2); + address public user3 = address(0x3); + address public user4 = TestsAccounts.getAccount(1); + address public minter = address(0x5); + + function beforeAll () public { + s = new NFTNumbered( + "SimpleX NFT: SMPX testnet access", + "SIMPLEXNFT", + "https://ipfs.io/ipfs/abcd" + ); + } + + function testCreateToken () public { + Assert.equal(s.name(), "SimpleX NFT: SMPX testnet access", "bad name"); + Assert.equal(s.symbol(), "SIMPLEXNFT", "bad symbol"); + Assert.equal(s.nextTokenId(), 1, "bad next token ID"); + Assert.equal(s.minter(), s.owner(), "minter different from owner"); + Assert.equal(s.mintingLocked(), false, "minting locked"); + Assert.equal(s.owner(), owner, "bad owner"); + } + + function testTransferToken() public { + Assert.equal(s.balanceOf(user3), 0, "bad balance"); + s.mint(user4); + Assert.equal(s.balanceOf(user4), 1, "bad balance"); + /// #sender: account-1 + // try s.safeTransferFrom(user4, user3, 1) { + // Assert.ok(false, "expected revert"); + // } catch Error(string memory reason) { + // Assert.equal(reason, "Token is soulbound: transfers are prohibited", "bad reason"); + // } catch (bytes memory data) { + // Assert.ok(false, "unexpected error 2"); + // } + } + + function testMinting () public { + s.mint(user1); + Assert.equal(s.balanceOf(user1), 1, "bad balance"); + Assert.equal(s.tokenURI(2), "https://ipfs.io/ipfs/abcd", "bad URI"); + try s.mint(user1) { + Assert.ok(false, "expected revert"); + } catch Error(string memory reason) { + Assert.equal(reason, "Soulbound token: only 1 per address", "bad reason"); + } catch (bytes memory) { + Assert.ok(false, "unexpected error"); + } + Assert.equal(s.nextTokenId(), 3, "bad next token ID"); + s.mint(user2); + Assert.equal(s.balanceOf(user2), 1, "bad balance"); + Assert.equal(s.tokenURI(3), "https://ipfs.io/ipfs/abcd", "bad URI"); + Assert.equal(s.nextTokenURI(), "https://ipfs.io/ipfs/abcd", "bad URI"); + s.setNextTokenURI("https://ipfs.io/ipfs/efgh"); + Assert.equal(s.nextTokenURI(), "https://ipfs.io/ipfs/efgh", "bad URI"); + Assert.equal(s.balanceOf(user3), 0, "bad balance"); + s.mint(user3); + Assert.equal(s.balanceOf(user3), 1, "bad balance"); + Assert.equal(s.tokenURI(4), "https://ipfs.io/ipfs/efgh", "bad URI"); + } + + function testMintingLock () public { + s.lockMintingPermanently(); + try s.mint(user2) { + Assert.ok(false, "expected revert"); + } catch Error(string memory reason) { + Assert.equal(reason, "Contract permanently locked for changes and minting", "bad reason"); + } catch (bytes memory) { + Assert.ok(false, "unexpected error"); + } + } +} diff --git a/flake.nix b/flake.nix index 9e7e2bf521..9c145275c0 100644 --- a/flake.nix +++ b/flake.nix @@ -40,12 +40,25 @@ src = ./.; }; sha256map = import ./scripts/nix/sha256map.nix; - modules = [{ - packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-2.3.27.patch ]; - } - ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { - packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; - })] ++ extra-modules; + modules = [ + ({ pkgs, lib, config, ... }: + { + # Override ghcOptions for ALL packages + ghcOptions = lib.mkDefault [ + "-j1" + ]; + } + ) + + ({ pkgs, lib, ...}: lib.mkIf (!pkgs.stdenv.hostPlatform.isWindows) { + # This patch adds `dl` as an extra-library to direct-sqlciper, which is needed + # on pretty much all unix platforms, but then blows up on windows m( + packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-2.3.27.patch ]; + }) + + ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { + packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; + })] ++ extra-modules; }; in # by defualt we don't need to pass extra-modules. let drv = pkgs': drv' { extra-modules = []; inherit pkgs'; }; in @@ -176,7 +189,7 @@ # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for # template haskell cross compilation. Thus we just pass them as linker options (-optl). - setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi"]; + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi" "-j1"]; postInstall = '' set -x ${pkgs.tree}/bin/tree $out @@ -214,7 +227,16 @@ done ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsimplex.zip *) + + # Strip from debug symbols + find "$out/_pkg" -type f -name "*.so" -exec ${android32Pkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + + + # Normalize permissions + timestamps + find "$out/_pkg" -type f -exec chmod 644 {} + + find "$out/_pkg" -type d -exec chmod 755 {} + + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + + + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ @@ -242,7 +264,7 @@ # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for # template haskell cross compilation. Thus we just pass them as linker options (-optl). - setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi"]; + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi" "-j1"]; postInstall = '' set -x ${pkgs.tree}/bin/tree $out @@ -280,7 +302,16 @@ done ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-aarch64-android-libsimplex.zip *) + + # Strip from debug symbols + find "$out/_pkg" -type f -name "*.so" -exec ${androidPkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + + + # Normalize permissions + timestamps + find "$out/_pkg" -type f -exec chmod 644 {} + + find "$out/_pkg" -type d -exec chmod 755 {} + + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + + + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-aarch64-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ diff --git a/packages/simplex-chat-client/types/typescript/README.md b/packages/simplex-chat-client/types/typescript/README.md index ad13a5d76e..e30cf0d0c3 100644 --- a/packages/simplex-chat-client/types/typescript/README.md +++ b/packages/simplex-chat-client/types/typescript/README.md @@ -2,7 +2,7 @@ This TypeScript library provides auto-generated types for bots API: commands and responses, events and all types they use. -It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) library that uses WebSockets interface of SimpleX Chat CLI. +It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) Node.js library. [API reference](https://github.com/simplex-chat/simplex-chat/tree/stable/bots). diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 1bf593b483..c929125033 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.1.0", + "version": "0.7.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -35,7 +35,7 @@ "bugs": { "url": "https://github.com/simplex-chat/simplex-chat/issues" }, - "homepage": "https://github.com/simplex-chat/simplex-chat#readme", + "homepage": "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/types/typescript#readme", "dependencies": { "typescript": "^5.9.2" } diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index de6a7a7ce1..d1b89ffe27 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -96,7 +96,7 @@ export namespace APISendMessages { export type Response = CR.NewChatItems | CR.ChatCmdError export function cmdString(self: APISendMessages): string { - return '/_send ' + self.sendRef.toString() + (self.liveMessage ? ' live=on' : '') + (self.ttl ? ' ttl=' + self.ttl : '') + ' json ' + JSON.stringify(self.composedMessages) + return '/_send ' + T.ChatRef.cmdString(self.sendRef) + (self.liveMessage ? ' live=on' : '') + (self.ttl ? ' ttl=' + self.ttl : '') + ' json ' + JSON.stringify(self.composedMessages) } } @@ -113,7 +113,7 @@ export namespace APIUpdateChatItem { export type Response = CR.ChatItemUpdated | CR.ChatItemNotChanged | CR.ChatCmdError export function cmdString(self: APIUpdateChatItem): string { - return '/_update item ' + self.chatRef.toString() + ' ' + self.chatItemId + (self.liveMessage ? ' live=on' : '') + ' json ' + JSON.stringify(self.updatedMessage) + return '/_update item ' + T.ChatRef.cmdString(self.chatRef) + ' ' + self.chatItemId + (self.liveMessage ? ' live=on' : '') + ' json ' + JSON.stringify(self.updatedMessage) } } @@ -129,7 +129,7 @@ export namespace APIDeleteChatItem { export type Response = CR.ChatItemsDeleted | CR.ChatCmdError export function cmdString(self: APIDeleteChatItem): string { - return '/_delete item ' + self.chatRef.toString() + ' ' + self.chatItemIds.join(',') + ' ' + self.deleteMode + return '/_delete item ' + T.ChatRef.cmdString(self.chatRef) + ' ' + self.chatItemIds.join(',') + ' ' + self.deleteMode } } @@ -161,7 +161,7 @@ export namespace APIChatItemReaction { export type Response = CR.ChatItemReaction | CR.ChatCmdError export function cmdString(self: APIChatItemReaction): string { - return '/_reaction ' + self.chatRef.toString() + ' ' + self.chatItemId + ' ' + (self.add ? 'on' : 'off') + ' ' + JSON.stringify(self.reaction) + return '/_reaction ' + T.ChatRef.cmdString(self.chatRef) + ' ' + self.chatItemId + ' ' + (self.add ? 'on' : 'off') + ' ' + JSON.stringify(self.reaction) } } @@ -341,6 +341,66 @@ export namespace APINewGroup { } } +// Create public group. +// Network usage: interactive. +export interface APINewPublicGroup { + userId: number // int64 + incognito: boolean + relayIds: number[] // int64, non-empty + groupProfile: T.GroupProfile +} + +export namespace APINewPublicGroup { + export type Response = CR.PublicGroupCreated | CR.PublicGroupCreationFailed | CR.ChatCmdError + + export function cmdString(self: APINewPublicGroup): string { + return '/_public group ' + self.userId + (self.incognito ? ' incognito=on' : '') + ' ' + self.relayIds.join(',') + ' ' + JSON.stringify(self.groupProfile) + } +} + +// Get group relays. +// Network usage: no. +export interface APIGetGroupRelays { + groupId: number // int64 +} + +export namespace APIGetGroupRelays { + export type Response = CR.GroupRelays | CR.ChatCmdError + + export function cmdString(self: APIGetGroupRelays): string { + return '/_get relays #' + self.groupId + } +} + +// Add relays to group. +// Network usage: interactive. +export interface APIAddGroupRelays { + groupId: number // int64 + relayIds: number[] // int64, non-empty +} + +export namespace APIAddGroupRelays { + export type Response = CR.GroupRelaysAdded | CR.GroupRelaysAddFailed | CR.ChatCmdError + + export function cmdString(self: APIAddGroupRelays): string { + return '/_add relays #' + self.groupId + ' ' + self.relayIds.join(',') + } +} + +// Clear relay rejection for a channel (relay operator). +// Network usage: background. +export interface APIAllowRelayGroup { + groupId: number // int64 +} + +export namespace APIAllowRelayGroup { + export type Response = CR.RelayGroupAllowed | CR.ChatCmdError + + export function cmdString(self: APIAllowRelayGroup): string { + return '/_relay allow #' + self.groupId + } +} + // Update group profile. // Network usage: background. export interface APIUpdateGroupProfile { @@ -440,6 +500,8 @@ export namespace APIAddContact { export interface APIConnectPlan { userId: number // int64 connectionLink?: string + resolveKnown: boolean + linkOwnerSig?: T.LinkOwnerSig } export namespace APIConnectPlan { @@ -450,7 +512,7 @@ export namespace APIConnectPlan { } } -// Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link +// Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link. // Network usage: interactive. export interface APIConnect { userId: number // int64 @@ -462,7 +524,7 @@ export namespace APIConnect { export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError export function cmdString(self: APIConnect): string { - return '/_connect ' + self.userId + (self.preparedLink_ ? ' ' + self.preparedLink_.toString() : '') + return '/_connect ' + self.userId + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '') } } @@ -542,6 +604,23 @@ export namespace APIListGroups { } } +// Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases). +// Network usage: no. +export interface APIGetChats { + userId: number // int64 + pendingConnections: boolean + pagination: T.PaginationByTime + query: T.ChatListQuery +} + +export namespace APIGetChats { + export type Response = CR.ApiChats | CR.ChatCmdError + + export function cmdString(self: APIGetChats): string { + return '/_get chats ' + self.userId + (self.pendingConnections ? ' pcc=on' : '') + ' ' + T.PaginationByTime.cmdString(self.pagination) + ' ' + JSON.stringify(self.query) + } +} + // Delete chat. // Network usage: background. export interface APIDeleteChat { @@ -553,14 +632,59 @@ export namespace APIDeleteChat { export type Response = CR.ContactDeleted | CR.ContactConnectionDeleted | CR.GroupDeletedUser | CR.ChatCmdError export function cmdString(self: APIDeleteChat): string { - return '/_delete ' + self.chatRef.toString() + ' ' + self.chatDeleteMode.toString() + return '/_delete ' + T.ChatRef.cmdString(self.chatRef) + ' ' + T.ChatDeleteMode.cmdString(self.chatDeleteMode) + } +} + +// Set group custom data. +// Network usage: no. +export interface APISetGroupCustomData { + groupId: number // int64 + customData?: object +} + +export namespace APISetGroupCustomData { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APISetGroupCustomData): string { + return '/_set custom #' + self.groupId + (self.customData ? ' ' + JSON.stringify(self.customData) : '') + } +} + +// Set contact custom data. +// Network usage: no. +export interface APISetContactCustomData { + contactId: number // int64 + customData?: object +} + +export namespace APISetContactCustomData { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APISetContactCustomData): string { + return '/_set custom @' + self.contactId + (self.customData ? ' ' + JSON.stringify(self.customData) : '') + } +} + +// Set auto-accept member contacts. +// Network usage: no. +export interface APISetUserAutoAcceptMemberContacts { + userId: number // int64 + onOff: boolean +} + +export namespace APISetUserAutoAcceptMemberContacts { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APISetUserAutoAcceptMemberContacts): string { + return '/_set accept member contacts ' + self.userId + ' ' + (self.onOff ? 'on' : 'off') } } // User profile commands // Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). -// Get active user profile +// Get active user profile. // Network usage: no. export interface ShowActiveUser { } @@ -573,7 +697,7 @@ export namespace ShowActiveUser { } } -// Create new user profile +// Create new user profile. // Network usage: no. export interface CreateActiveUser { newUser: T.NewUser @@ -587,7 +711,7 @@ export namespace CreateActiveUser { } } -// Get all user profiles +// Get all user profiles. // Network usage: no. export interface ListUsers { } @@ -600,7 +724,7 @@ export namespace ListUsers { } } -// Set active user profile +// Set active user profile. // Network usage: no. export interface APISetActiveUser { userId: number // int64 @@ -660,3 +784,34 @@ export namespace APISetContactPrefs { return '/_set prefs @' + self.contactId + ' ' + JSON.stringify(self.preferences) } } + +// Chat management +// These commands should not be used with CLI-based bots + +// Start chat controller. +// Network usage: no. +export interface StartChat { + mainApp: boolean + enableSndFiles: boolean +} + +export namespace StartChat { + export type Response = CR.ChatStarted | CR.ChatRunning + + export function cmdString(_self: StartChat): string { + return '/_start' + } +} + +// Stop chat controller. +// Network usage: no. +export interface APIStopChat { +} + +export namespace APIStopChat { + export type Response = CR.ChatStopped + + export function cmdString(_self: APIStopChat): string { + return '/_stop' + } +} diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index d7a0419bbe..cc19305913 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -29,6 +29,8 @@ export type ChatEvent = | CEvt.MemberAcceptedByOther | CEvt.MemberBlockedForAll | CEvt.GroupMemberUpdated + | CEvt.GroupLinkDataUpdated + | CEvt.GroupRelayUpdated | CEvt.RcvFileDescrReady | CEvt.RcvFileComplete | CEvt.SndFileCompleteXFTP @@ -46,6 +48,9 @@ export type ChatEvent = | CEvt.JoinedGroupMemberConnecting | CEvt.SentGroupInvitation | CEvt.GroupLinkConnecting + | CEvt.HostConnected + | CEvt.HostDisconnected + | CEvt.SubscriptionStatus | CEvt.MessageError | CEvt.ChatError | CEvt.ChatErrors @@ -77,6 +82,8 @@ export namespace CEvt { | "memberAcceptedByOther" | "memberBlockedForAll" | "groupMemberUpdated" + | "groupLinkDataUpdated" + | "groupRelayUpdated" | "rcvFileDescrReady" | "rcvFileComplete" | "sndFileCompleteXFTP" @@ -94,6 +101,9 @@ export namespace CEvt { | "joinedGroupMemberConnecting" | "sentGroupInvitation" | "groupLinkConnecting" + | "hostConnected" + | "hostDisconnected" + | "subscriptionStatus" | "messageError" | "chatError" | "chatErrors" @@ -207,6 +217,7 @@ export namespace CEvt { fromGroup: T.GroupInfo toGroup: T.GroupInfo member_?: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface JoinedGroupMember extends Interface { @@ -224,6 +235,7 @@ export namespace CEvt { member: T.GroupMember fromRole: T.GroupMemberRole toRole: T.GroupMemberRole + msgSigned?: T.MsgSigStatus } export interface DeletedMember extends Interface { @@ -233,6 +245,7 @@ export namespace CEvt { byMember: T.GroupMember deletedMember: T.GroupMember withMessages: boolean + msgSigned?: T.MsgSigStatus } export interface LeftMember extends Interface { @@ -240,6 +253,7 @@ export namespace CEvt { user: T.User groupInfo: T.GroupInfo member: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface DeletedMemberUser extends Interface { @@ -248,6 +262,7 @@ export namespace CEvt { groupInfo: T.GroupInfo member: T.GroupMember withMessages: boolean + msgSigned?: T.MsgSigStatus } export interface GroupDeleted extends Interface { @@ -255,6 +270,7 @@ export namespace CEvt { user: T.User groupInfo: T.GroupInfo member: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface ConnectedToGroupMember extends Interface { @@ -280,6 +296,7 @@ export namespace CEvt { byMember: T.GroupMember member: T.GroupMember blocked: boolean + msgSigned?: T.MsgSigStatus } export interface GroupMemberUpdated extends Interface { @@ -290,6 +307,23 @@ export namespace CEvt { toMember: T.GroupMember } + export interface GroupLinkDataUpdated extends Interface { + type: "groupLinkDataUpdated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + groupRelays: T.GroupRelay[] + relaysChanged: boolean + } + + export interface GroupRelayUpdated extends Interface { + type: "groupRelayUpdated" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + groupRelay: T.GroupRelay + } + export interface RcvFileDescrReady extends Interface { type: "rcvFileDescrReady" user: T.User @@ -411,6 +445,25 @@ export namespace CEvt { hostMember: T.GroupMember } + export interface HostConnected extends Interface { + type: "hostConnected" + protocol: string + transportHost: string + } + + export interface HostDisconnected extends Interface { + type: "hostDisconnected" + protocol: string + transportHost: string + } + + export interface SubscriptionStatus extends Interface { + type: "subscriptionStatus" + server: string + subscriptionStatus: T.SubscriptionStatus + connections: string[] + } + export interface MessageError extends Interface { type: "messageError" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 2ea67432f5..0fcf0e6eca 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -10,6 +10,9 @@ export type ChatResponse = | CR.ChatItemReaction | CR.ChatItemUpdated | CR.ChatItemsDeleted + | CR.ChatRunning + | CR.ChatStarted + | CR.ChatStopped | CR.CmdOk | CR.ChatCmdError | CR.ConnectionPlan @@ -24,6 +27,12 @@ export type ChatResponse = | CR.GroupLinkCreated | CR.GroupLinkDeleted | CR.GroupCreated + | CR.PublicGroupCreated + | CR.PublicGroupCreationFailed + | CR.GroupRelays + | CR.GroupRelaysAdded + | CR.GroupRelaysAddFailed + | CR.RelayGroupAllowed | CR.GroupMembers | CR.GroupUpdated | CR.GroupsList @@ -49,6 +58,7 @@ export type ChatResponse = | CR.UserProfileUpdated | CR.UserProfileNoChange | CR.UsersList + | CR.ApiChats export namespace CR { export type Tag = @@ -58,6 +68,9 @@ export namespace CR { | "chatItemReaction" | "chatItemUpdated" | "chatItemsDeleted" + | "chatRunning" + | "chatStarted" + | "chatStopped" | "cmdOk" | "chatCmdError" | "connectionPlan" @@ -72,6 +85,12 @@ export namespace CR { | "groupLinkCreated" | "groupLinkDeleted" | "groupCreated" + | "publicGroupCreated" + | "publicGroupCreationFailed" + | "groupRelays" + | "groupRelaysAdded" + | "groupRelaysAddFailed" + | "relayGroupAllowed" | "groupMembers" | "groupUpdated" | "groupsList" @@ -97,6 +116,7 @@ export namespace CR { | "userProfileUpdated" | "userProfileNoChange" | "usersList" + | "apiChats" interface Interface { type: Tag @@ -140,6 +160,18 @@ export namespace CR { timed: boolean } + export interface ChatRunning extends Interface { + type: "chatRunning" + } + + export interface ChatStarted extends Interface { + type: "chatStarted" + } + + export interface ChatStopped extends Interface { + type: "chatStopped" + } + export interface CmdOk extends Interface { type: "cmdOk" user_?: T.User @@ -199,6 +231,7 @@ export namespace CR { type: "groupDeletedUser" user: T.User groupInfo: T.GroupInfo + msgSigned: boolean } export interface GroupLink extends Interface { @@ -227,6 +260,47 @@ export namespace CR { groupInfo: T.GroupInfo } + export interface PublicGroupCreated extends Interface { + type: "publicGroupCreated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + groupRelays: T.GroupRelay[] + } + + export interface PublicGroupCreationFailed extends Interface { + type: "publicGroupCreationFailed" + user: T.User + addRelayResults: T.AddRelayResult[] + } + + export interface GroupRelays extends Interface { + type: "groupRelays" + user: T.User + groupInfo: T.GroupInfo + groupRelays: T.GroupRelay[] + } + + export interface GroupRelaysAdded extends Interface { + type: "groupRelaysAdded" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + groupRelays: T.GroupRelay[] + } + + export interface GroupRelaysAddFailed extends Interface { + type: "groupRelaysAddFailed" + user: T.User + addRelayResults: T.AddRelayResult[] + } + + export interface RelayGroupAllowed extends Interface { + type: "relayGroupAllowed" + user: T.User + groupInfo: T.GroupInfo + } + export interface GroupMembers extends Interface { type: "groupMembers" user: T.User @@ -239,12 +313,13 @@ export namespace CR { fromGroup: T.GroupInfo toGroup: T.GroupInfo member_?: T.GroupMember + msgSigned: boolean } export interface GroupsList extends Interface { type: "groupsList" user: T.User - groups: T.GroupInfoSummary[] + groups: T.GroupInfo[] } export interface Invitation extends Interface { @@ -273,6 +348,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] blocked: boolean + msgSigned: boolean } export interface MembersRoleUser extends Interface { @@ -281,6 +357,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] toRole: T.GroupMemberRole + msgSigned: boolean } export interface NewChatItems extends Interface { @@ -374,6 +451,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] withMessages: boolean + msgSigned: boolean } export interface UserProfileUpdated extends Interface { @@ -393,4 +471,10 @@ export namespace CR { type: "usersList" users: T.UserInfo[] } + + export interface ApiChats extends Interface { + type: "apiChats" + user: T.User + chats: T.AChat[] + } } diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 3a056293c5..7e618e05c8 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -17,6 +17,11 @@ export interface AChatItem { chatItem: ChatItem } +export interface AddRelayResult { + relay: UserChatRelay + relayError?: ChatError +} + export interface AddressSettings { businessAddress: boolean autoAccept?: AutoAccept @@ -65,6 +70,7 @@ export type AgentErrorType = | AgentErrorType.RCP | AgentErrorType.BROKER | AgentErrorType.AGENT + | AgentErrorType.NOTICE | AgentErrorType.INTERNAL | AgentErrorType.CRITICAL | AgentErrorType.INACTIVE @@ -82,6 +88,7 @@ export namespace AgentErrorType { | "RCP" | "BROKER" | "AGENT" + | "NOTICE" | "INTERNAL" | "CRITICAL" | "INACTIVE" @@ -152,6 +159,13 @@ export namespace AgentErrorType { agentErr: SMPAgentError } + export interface NOTICE extends Interface { + type: "NOTICE" + server: string + preset: boolean + expiresAt?: string // ISO-8601 timestamp + } + export interface INTERNAL extends Interface { type: "INTERNAL" internalErr: string @@ -174,6 +188,7 @@ export interface AutoAccept { export interface BlockingInfo { reason: BlockingReason + notice?: ClientNotice } export enum BlockingReason { @@ -268,6 +283,7 @@ export type CIContent = | CIContent.RcvCall | CIContent.RcvIntegrityError | CIContent.RcvDecryptionError + | CIContent.RcvMsgError | CIContent.RcvGroupInvitation | CIContent.SndGroupInvitation | CIContent.RcvDirectEvent @@ -302,6 +318,7 @@ export namespace CIContent { | "rcvCall" | "rcvIntegrityError" | "rcvDecryptionError" + | "rcvMsgError" | "rcvGroupInvitation" | "sndGroupInvitation" | "rcvDirectEvent" @@ -373,6 +390,11 @@ export namespace CIContent { msgCount: number // word32 } + export interface RcvMsgError extends Interface { + type: "rcvMsgError" + rcvMsgError: RcvMsgError + } + export interface RcvGroupInvitation extends Interface { type: "rcvGroupInvitation" groupInvitation: CIGroupInvitation @@ -505,6 +527,7 @@ export enum CIDeleteMode { Broadcast = "broadcast", Internal = "internal", InternalMark = "internalMark", + History = "history", } export type CIDeleted = CIDeleted.Deleted | CIDeleted.Blocked | CIDeleted.BlockedByAdmin | CIDeleted.Moderated @@ -544,11 +567,19 @@ export type CIDirection = | CIDirection.DirectRcv | CIDirection.GroupSnd | CIDirection.GroupRcv + | CIDirection.ChannelRcv | CIDirection.LocalSnd | CIDirection.LocalRcv export namespace CIDirection { - export type Tag = "directSnd" | "directRcv" | "groupSnd" | "groupRcv" | "localSnd" | "localRcv" + export type Tag = + | "directSnd" + | "directRcv" + | "groupSnd" + | "groupRcv" + | "channelRcv" + | "localSnd" + | "localRcv" interface Interface { type: Tag @@ -571,6 +602,10 @@ export namespace CIDirection { groupMember: GroupMember } + export interface ChannelRcv extends Interface { + type: "channelRcv" + } + export interface LocalSnd extends Interface { type: "localSnd" } @@ -768,10 +803,12 @@ export interface CIMeta { itemTimed?: CITimed itemLive?: boolean userMention: boolean + hasLink: boolean deletable: boolean editable: boolean forwardedByMember?: number // int64 showGroupAsSender: boolean + msgSigned?: MsgSigStatus createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp } @@ -941,6 +978,7 @@ export namespace ChatError { export interface ErrorAgent extends Interface { type: "errorAgent" agentError: AgentErrorType + agentConnId: string connectionEntity_?: ConnectionEntity } @@ -958,6 +996,7 @@ export type ChatErrorType = | ChatErrorType.UserUnknown | ChatErrorType.ActiveUserExists | ChatErrorType.UserExists + | ChatErrorType.ChatRelayExists | ChatErrorType.DifferentActiveUser | ChatErrorType.CantDeleteActiveUser | ChatErrorType.CantDeleteLastUser @@ -997,7 +1036,6 @@ export type ChatErrorType = | ChatErrorType.FileCancelled | ChatErrorType.FileCancel | ChatErrorType.FileAlreadyExists - | ChatErrorType.FileRead | ChatErrorType.FileWrite | ChatErrorType.FileSend | ChatErrorType.FileRcvChunk @@ -1023,6 +1061,7 @@ export type ChatErrorType = | ChatErrorType.ConnectionIncognitoChangeProhibited | ChatErrorType.ConnectionUserChangeProhibited | ChatErrorType.PeerChatVRangeIncompatible + | ChatErrorType.RelayTestError | ChatErrorType.InternalError | ChatErrorType.Exception @@ -1035,6 +1074,7 @@ export namespace ChatErrorType { | "userUnknown" | "activeUserExists" | "userExists" + | "chatRelayExists" | "differentActiveUser" | "cantDeleteActiveUser" | "cantDeleteLastUser" @@ -1074,7 +1114,6 @@ export namespace ChatErrorType { | "fileCancelled" | "fileCancel" | "fileAlreadyExists" - | "fileRead" | "fileWrite" | "fileSend" | "fileRcvChunk" @@ -1100,6 +1139,7 @@ export namespace ChatErrorType { | "connectionIncognitoChangeProhibited" | "connectionUserChangeProhibited" | "peerChatVRangeIncompatible" + | "relayTestError" | "internalError" | "exception" @@ -1139,6 +1179,10 @@ export namespace ChatErrorType { contactName: string } + export interface ChatRelayExists extends Interface { + type: "chatRelayExists" + } + export interface DifferentActiveUser extends Interface { type: "differentActiveUser" commandUserId: number // int64 @@ -1330,12 +1374,6 @@ export namespace ChatErrorType { filePath: string } - export interface FileRead extends Interface { - type: "fileRead" - filePath: string - message: string - } - export interface FileWrite extends Interface { type: "fileWrite" filePath: string @@ -1456,6 +1494,11 @@ export namespace ChatErrorType { type: "peerChatVRangeIncompatible" } + export interface RelayTestError extends Interface { + type: "relayTestError" + message: string + } + export interface InternalError extends Interface { type: "internalError" message: string @@ -1535,6 +1578,27 @@ export interface ChatItemDeletion { toChatItem?: AChatItem } +export type ChatListQuery = ChatListQuery.Filters | ChatListQuery.Search + +export namespace ChatListQuery { + export type Tag = "filters" | "search" + + interface Interface { + type: Tag + } + + export interface Filters extends Interface { + type: "filters" + favorite: boolean + unread: boolean + } + + export interface Search extends Interface { + type: "search" + search: string + } +} + export enum ChatPeerType { Human = "human", Bot = "bot", @@ -1549,7 +1613,7 @@ export interface ChatRef { export namespace ChatRef { export function cmdString(self: ChatRef): string { - return self.chatType.toString() + self.chatId + (self.chatScope ? self.chatScope.toString() : '') + return ChatType.cmdString(self.chatType) + self.chatId + (self.chatScope ? GroupChatScope.cmdString(self.chatScope) : '') } } @@ -1594,6 +1658,10 @@ export enum ChatWallpaperScale { Repeat = "repeat", } +export interface ClientNotice { + ttl?: number // int64 +} + export enum Color { Black = "black", Red = "red", @@ -1680,6 +1748,11 @@ export namespace CommandErrorType { } } +export interface CommentsGroupPreference { + enable: GroupFeatureEnabled + duration?: number // int +} + export interface ComposedMessage { fileSource?: CryptoFile quotedItemId?: number // int64 @@ -1687,15 +1760,69 @@ export interface ComposedMessage { mentions: {[key: string]: number} // string : int64 } -export enum ConnStatus { - New = "new", - Prepared = "prepared", - Joined = "joined", - Requested = "requested", - Accepted = "accepted", - Snd_ready = "snd-ready", - Ready = "ready", - Deleted = "deleted", +export type ConnStatus = + | ConnStatus.New + | ConnStatus.Prepared + | ConnStatus.Joined + | ConnStatus.Requested + | ConnStatus.Accepted + | ConnStatus.SndReady + | ConnStatus.Ready + | ConnStatus.Deleted + | ConnStatus.Failed + +export namespace ConnStatus { + export type Tag = + | "new" + | "prepared" + | "joined" + | "requested" + | "accepted" + | "sndReady" + | "ready" + | "deleted" + | "failed" + + interface Interface { + type: Tag + } + + export interface New extends Interface { + type: "new" + } + + export interface Prepared extends Interface { + type: "prepared" + } + + export interface Joined extends Interface { + type: "joined" + } + + export interface Requested extends Interface { + type: "requested" + } + + export interface Accepted extends Interface { + type: "accepted" + } + + export interface SndReady extends Interface { + type: "sndReady" + } + + export interface Ready extends Interface { + type: "ready" + } + + export interface Deleted extends Interface { + type: "deleted" + } + + export interface Failed extends Interface { + type: "failed" + connError: string + } } export enum ConnType { @@ -1734,17 +1861,10 @@ export interface Connection { export type ConnectionEntity = | ConnectionEntity.RcvDirectMsgConnection | ConnectionEntity.RcvGroupMsgConnection - | ConnectionEntity.SndFileConnection - | ConnectionEntity.RcvFileConnection | ConnectionEntity.UserContactConnection export namespace ConnectionEntity { - export type Tag = - | "rcvDirectMsgConnection" - | "rcvGroupMsgConnection" - | "sndFileConnection" - | "rcvFileConnection" - | "userContactConnection" + export type Tag = "rcvDirectMsgConnection" | "rcvGroupMsgConnection" | "userContactConnection" interface Interface { type: Tag @@ -1763,18 +1883,6 @@ export namespace ConnectionEntity { groupMember: GroupMember } - export interface SndFileConnection extends Interface { - type: "sndFileConnection" - entityConnection: Connection - sndFileTransfer: SndFileTransfer - } - - export interface RcvFileConnection extends Interface { - type: "rcvFileConnection" - entityConnection: Connection - rcvFileTransfer: RcvFileTransfer - } - export interface UserContactConnection extends Interface { type: "userContactConnection" entityConnection: Connection @@ -1861,7 +1969,6 @@ export interface Contact { localDisplayName: string profile: LocalProfile activeConn?: Connection - viaGroup?: number // int64 contactUsed: boolean contactStatus: ContactStatus chatSettings: ChatSettings @@ -1906,6 +2013,7 @@ export namespace ContactAddressPlan { export interface Ok extends Interface { type: "ok" contactSLinkData_?: ContactShortLinkData + ownerVerification?: OwnerVerification } export interface OwnLink extends Interface { @@ -2002,7 +2110,13 @@ export interface CryptoFileArgs { fileNonce: string } +export interface DroppedMsg { + brokerTs: string // ISO-8601 timestamp + attempts: number // int +} + export interface E2EInfo { + public?: boolean pqEnabled?: boolean } @@ -2239,6 +2353,7 @@ export type Format = | Format.StrikeThrough | Format.Snippet | Format.Secret + | Format.Small | Format.Colored | Format.Uri | Format.HyperLink @@ -2255,6 +2370,7 @@ export namespace Format { | "strikeThrough" | "snippet" | "secret" + | "small" | "colored" | "uri" | "hyperLink" @@ -2288,6 +2404,10 @@ export namespace Format { type: "secret" } + export interface Small extends Interface { + type: "small" + } + export interface Colored extends Interface { type: "colored" color: Color @@ -2345,7 +2465,9 @@ export interface FullGroupPreferences { simplexLinks: RoleGroupPreference reports: GroupPreference history: GroupPreference + support: SupportGroupPreference sessions: RoleGroupPreference + comments: CommentsGroupPreference commands: ChatBotCommand[] } @@ -2417,7 +2539,9 @@ export enum GroupFeature { SimplexLinks = "simplexLinks", Reports = "reports", History = "history", + Support = "support", Sessions = "sessions", + Comments = "comments", } export enum GroupFeatureEnabled { @@ -2427,6 +2551,8 @@ export enum GroupFeatureEnabled { export interface GroupInfo { groupId: number // int64 + useRelays: boolean + relayOwnStatus?: RelayStatus localDisplayName: string groupProfile: GroupProfile localAlias: string @@ -2443,13 +2569,16 @@ export interface GroupInfo { chatItemTTL?: number // int64 uiThemes?: UIThemeEntityOverrides customData?: object + groupSummary: GroupSummary membersRequireAttention: number // int viaGroupLinkUri?: string + groupKeys?: GroupKeys } -export interface GroupInfoSummary { - groupInfo: GroupInfo - groupSummary: GroupSummary +export interface GroupKeys { + publicGroupId: string + groupRootKey: GroupRootKey + memberPrivKey: string } export interface GroupLink { @@ -2461,15 +2590,27 @@ export interface GroupLink { acceptMemberRole: GroupMemberRole } +export interface GroupLinkOwner { + memberId: string + memberKey: string +} + export type GroupLinkPlan = | GroupLinkPlan.Ok | GroupLinkPlan.OwnLink | GroupLinkPlan.ConnectingConfirmReconnect | GroupLinkPlan.ConnectingProhibit | GroupLinkPlan.Known + | GroupLinkPlan.NoRelays export namespace GroupLinkPlan { - export type Tag = "ok" | "ownLink" | "connectingConfirmReconnect" | "connectingProhibit" | "known" + export type Tag = + | "ok" + | "ownLink" + | "connectingConfirmReconnect" + | "connectingProhibit" + | "known" + | "noRelays" interface Interface { type: Tag @@ -2477,7 +2618,9 @@ export namespace GroupLinkPlan { export interface Ok extends Interface { type: "ok" + groupSLinkInfo_?: GroupShortLinkInfo groupSLinkData_?: GroupShortLinkData + ownerVerification?: OwnerVerification } export interface OwnLink extends Interface { @@ -2497,12 +2640,21 @@ export namespace GroupLinkPlan { export interface Known extends Interface { type: "known" groupInfo: GroupInfo + groupUpdated: boolean + ownerVerification?: OwnerVerification + linkOwners: GroupLinkOwner[] + } + + export interface NoRelays extends Interface { + type: "noRelays" + groupSLinkData_?: GroupShortLinkData } } export interface GroupMember { groupMemberId: number // int64 groupId: number // int64 + indexInGroup: number // int64 memberId: string memberRole: GroupMemberRole memberCategory: GroupMemberCategory @@ -2520,6 +2672,8 @@ export interface GroupMember { createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp supportChat?: GroupSupportChat + memberPubKey?: string + relayLink?: string } export interface GroupMemberAdmission { @@ -2540,6 +2694,7 @@ export interface GroupMemberRef { } export enum GroupMemberRole { + Relay = "relay", Observer = "observer", Author = "author", Member = "member", @@ -2584,7 +2739,9 @@ export interface GroupPreferences { simplexLinks?: RoleGroupPreference reports?: GroupPreference history?: GroupPreference + support?: SupportGroupPreference sessions?: RoleGroupPreference + comments?: CommentsGroupPreference commands?: ChatBotCommand[] } @@ -2594,16 +2751,53 @@ export interface GroupProfile { shortDescr?: string description?: string image?: string + publicGroup?: PublicGroupProfile groupPreferences?: GroupPreferences memberAdmission?: GroupMemberAdmission } +export interface GroupRelay { + groupRelayId: number // int64 + groupMemberId: number // int64 + userChatRelay: UserChatRelay + relayStatus: RelayStatus + relayLink?: string +} + +export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public + +export namespace GroupRootKey { + export type Tag = "private" | "public" + + interface Interface { + type: Tag + } + + export interface Private extends Interface { + type: "private" + rootPrivKey: string + } + + export interface Public extends Interface { + type: "public" + rootPubKey: string + } +} + export interface GroupShortLinkData { groupProfile: GroupProfile + publicGroupData?: PublicGroupData +} + +export interface GroupShortLinkInfo { + direct: boolean + groupRelays: string[] + publicGroupId?: string } export interface GroupSummary { - currentMembers: number // int + currentMembers: number // int64 + publicMemberCount?: number // int64 } export interface GroupSupportChat { @@ -2614,6 +2808,11 @@ export interface GroupSupportChat { lastMsgFromMemberTs?: string // ISO-8601 timestamp } +export enum GroupType { + Channel = "channel", + Group = "group", +} + export enum HandshakeError { PARSE = "PARSE", IDENTITY = "IDENTITY", @@ -2642,6 +2841,7 @@ export namespace InvitationLinkPlan { export interface Ok extends Interface { type: "ok" contactSLinkData_?: ContactShortLinkData + ownerVerification?: OwnerVerification } export interface OwnLink extends Interface { @@ -2711,6 +2911,12 @@ export namespace LinkContent { } } +export interface LinkOwnerSig { + ownerId?: string + chatBinding: string + ownerSig: string +} + export interface LinkPreview { uri: string title: string @@ -2828,6 +3034,7 @@ export namespace MsgContent { type: "chat" text: string chatLink: MsgChatLink + ownerSig?: LinkOwnerSig } export interface Unknown extends Interface { @@ -2916,6 +3123,11 @@ export enum MsgReceiptStatus { BadMsgHash = "badMsgHash", } +export enum MsgSigStatus { + Verified = "verified", + SignedNoKey = "signedNoKey", +} + export type NetworkError = | NetworkError.ConnectError | NetworkError.TLSError @@ -2968,6 +3180,7 @@ export namespace NetworkError { export interface NewUser { profile?: Profile pastTimestamp: boolean + userChatRelay: boolean } export interface NoteFolder { @@ -2980,6 +3193,44 @@ export interface NoteFolder { unread: boolean } +export type OwnerVerification = OwnerVerification.Verified | OwnerVerification.Failed + +export namespace OwnerVerification { + export type Tag = "verified" | "failed" + + interface Interface { + type: Tag + } + + export interface Verified extends Interface { + type: "verified" + } + + export interface Failed extends Interface { + type: "failed" + reason: string + } +} + +export type PaginationByTime = PaginationByTime.Last + +export namespace PaginationByTime { + export type Tag = "last" + + interface Interface { + type: Tag + } + + export interface Last extends Interface { + type: "last" + count: number // int + } + + export function cmdString(self: PaginationByTime): string { + return 'count=' + self.count + } +} + export interface PendingContactConnection { pccConnId: number // int64 pccAgentConnId: string @@ -3091,6 +3342,16 @@ export namespace ProxyError { } } +export interface PublicGroupData { + publicMemberCount: number // int64 +} + +export interface PublicGroupProfile { + groupType: GroupType + groupLink: string + publicGroupId: string +} + export type RCErrorType = | RCErrorType.Internal | RCErrorType.Identity @@ -3277,12 +3538,6 @@ export interface RcvFileDescr { fileDescrComplete: boolean } -export interface RcvFileInfo { - filePath: string - connId?: number // int64 - agentConnId?: string -} - export type RcvFileStatus = | RcvFileStatus.New | RcvFileStatus.Accepted @@ -3303,22 +3558,22 @@ export namespace RcvFileStatus { export interface Accepted extends Interface { type: "accepted" - fileInfo: RcvFileInfo + filePath: string } export interface Connected extends Interface { type: "connected" - fileInfo: RcvFileInfo + filePath: string } export interface Complete extends Interface { type: "complete" - fileInfo: RcvFileInfo + filePath: string } export interface Cancelled extends Interface { type: "cancelled" - fileInfo_?: RcvFileInfo + filePath_?: string } } @@ -3352,6 +3607,7 @@ export type RcvGroupEvent = | RcvGroupEvent.MemberCreatedContact | RcvGroupEvent.MemberProfileUpdated | RcvGroupEvent.NewMemberPendingReview + | RcvGroupEvent.MsgBadSignature export namespace RcvGroupEvent { export type Tag = @@ -3371,6 +3627,7 @@ export namespace RcvGroupEvent { | "memberCreatedContact" | "memberProfileUpdated" | "newMemberPendingReview" + | "msgBadSignature" interface Interface { type: Tag @@ -3455,6 +3712,46 @@ export namespace RcvGroupEvent { export interface NewMemberPendingReview extends Interface { type: "newMemberPendingReview" } + + export interface MsgBadSignature extends Interface { + type: "msgBadSignature" + } +} + +export type RcvMsgError = RcvMsgError.Dropped | RcvMsgError.ParseError + +export namespace RcvMsgError { + export type Tag = "dropped" | "parseError" + + interface Interface { + type: Tag + } + + export interface Dropped extends Interface { + type: "dropped" + attempts: number // int + } + + export interface ParseError extends Interface { + type: "parseError" + parseError: string + } +} + +export interface RelayProfile { + displayName: string + fullName: string + shortDescr?: string + image?: string +} + +export enum RelayStatus { + New = "new", + Invited = "invited", + Accepted = "accepted", + Active = "active", + Inactive = "inactive", + Rejected = "rejected", } export enum ReportReason { @@ -3518,6 +3815,7 @@ export namespace SMPAgentError { export interface A_DUPLICATE extends Interface { type: "A_DUPLICATE" + droppedMsg_?: DroppedMsg } export interface A_QUEUE extends Interface { @@ -3737,6 +4035,7 @@ export namespace SrvError { export type StoreError = | StoreError.DuplicateName | StoreError.UserNotFound + | StoreError.RelayUserNotFound | StoreError.UserNotFoundByName | StoreError.UserNotFoundByContactId | StoreError.UserNotFoundByGroupId @@ -3756,11 +4055,15 @@ export type StoreError = | StoreError.GroupNotFoundByName | StoreError.GroupMemberNameNotFound | StoreError.GroupMemberNotFound + | StoreError.GroupMemberNotFoundByIndex + | StoreError.MemberRelationsVectorNotFound | StoreError.GroupHostMemberNotFound | StoreError.GroupMemberNotFoundByMemberId | StoreError.MemberContactGroupMemberNotFound + | StoreError.InvalidMemberRelationUpdate | StoreError.GroupWithoutUser | StoreError.DuplicateGroupMember + | StoreError.DuplicateMemberId | StoreError.GroupAlreadyJoined | StoreError.GroupInvitationNotFound | StoreError.NoteFolderAlreadyExists @@ -3782,7 +4085,6 @@ export type StoreError = | StoreError.ConnectionNotFoundById | StoreError.ConnectionNotFoundByMemberId | StoreError.PendingConnectionNotFound - | StoreError.IntroNotFound | StoreError.UniqueID | StoreError.LargeMsg | StoreError.InternalError @@ -3810,13 +4112,22 @@ export type StoreError = | StoreError.ProhibitedDeleteUser | StoreError.OperatorNotFound | StoreError.UsageConditionsNotFound + | StoreError.UserChatRelayNotFound + | StoreError.GroupRelayNotFound + | StoreError.GroupRelayNotFoundByMemberId | StoreError.InvalidQuote | StoreError.InvalidMention + | StoreError.InvalidDeliveryTask + | StoreError.DeliveryTaskNotFound + | StoreError.InvalidDeliveryJob + | StoreError.DeliveryJobNotFound + | StoreError.WorkItemError export namespace StoreError { export type Tag = | "duplicateName" | "userNotFound" + | "relayUserNotFound" | "userNotFoundByName" | "userNotFoundByContactId" | "userNotFoundByGroupId" @@ -3836,11 +4147,15 @@ export namespace StoreError { | "groupNotFoundByName" | "groupMemberNameNotFound" | "groupMemberNotFound" + | "groupMemberNotFoundByIndex" + | "memberRelationsVectorNotFound" | "groupHostMemberNotFound" | "groupMemberNotFoundByMemberId" | "memberContactGroupMemberNotFound" + | "invalidMemberRelationUpdate" | "groupWithoutUser" | "duplicateGroupMember" + | "duplicateMemberId" | "groupAlreadyJoined" | "groupInvitationNotFound" | "noteFolderAlreadyExists" @@ -3862,7 +4177,6 @@ export namespace StoreError { | "connectionNotFoundById" | "connectionNotFoundByMemberId" | "pendingConnectionNotFound" - | "introNotFound" | "uniqueID" | "largeMsg" | "internalError" @@ -3890,8 +4204,16 @@ export namespace StoreError { | "prohibitedDeleteUser" | "operatorNotFound" | "usageConditionsNotFound" + | "userChatRelayNotFound" + | "groupRelayNotFound" + | "groupRelayNotFoundByMemberId" | "invalidQuote" | "invalidMention" + | "invalidDeliveryTask" + | "deliveryTaskNotFound" + | "invalidDeliveryJob" + | "deliveryJobNotFound" + | "workItemError" interface Interface { type: Tag @@ -3906,6 +4228,10 @@ export namespace StoreError { userId: number // int64 } + export interface RelayUserNotFound extends Interface { + type: "relayUserNotFound" + } + export interface UserNotFoundByName extends Interface { type: "userNotFoundByName" contactName: string @@ -3999,6 +4325,16 @@ export namespace StoreError { groupMemberId: number // int64 } + export interface GroupMemberNotFoundByIndex extends Interface { + type: "groupMemberNotFoundByIndex" + groupMemberIndex: number // int64 + } + + export interface MemberRelationsVectorNotFound extends Interface { + type: "memberRelationsVectorNotFound" + groupMemberId: number // int64 + } + export interface GroupHostMemberNotFound extends Interface { type: "groupHostMemberNotFound" groupId: number // int64 @@ -4014,6 +4350,10 @@ export namespace StoreError { contactId: number // int64 } + export interface InvalidMemberRelationUpdate extends Interface { + type: "invalidMemberRelationUpdate" + } + export interface GroupWithoutUser extends Interface { type: "groupWithoutUser" } @@ -4022,6 +4362,10 @@ export namespace StoreError { type: "duplicateGroupMember" } + export interface DuplicateMemberId extends Interface { + type: "duplicateMemberId" + } + export interface GroupAlreadyJoined extends Interface { type: "groupAlreadyJoined" } @@ -4123,10 +4467,6 @@ export namespace StoreError { connId: number // int64 } - export interface IntroNotFound extends Interface { - type: "introNotFound" - } - export interface UniqueID extends Interface { type: "uniqueID" } @@ -4262,6 +4602,21 @@ export namespace StoreError { type: "usageConditionsNotFound" } + export interface UserChatRelayNotFound extends Interface { + type: "userChatRelayNotFound" + chatRelayId: number // int64 + } + + export interface GroupRelayNotFound extends Interface { + type: "groupRelayNotFound" + groupRelayId: number // int64 + } + + export interface GroupRelayNotFoundByMemberId extends Interface { + type: "groupRelayNotFoundByMemberId" + groupMemberId: number // int64 + } + export interface InvalidQuote extends Interface { type: "invalidQuote" } @@ -4269,6 +4624,66 @@ export namespace StoreError { export interface InvalidMention extends Interface { type: "invalidMention" } + + export interface InvalidDeliveryTask extends Interface { + type: "invalidDeliveryTask" + taskId: number // int64 + } + + export interface DeliveryTaskNotFound extends Interface { + type: "deliveryTaskNotFound" + taskId: number // int64 + } + + export interface InvalidDeliveryJob extends Interface { + type: "invalidDeliveryJob" + jobId: number // int64 + } + + export interface DeliveryJobNotFound extends Interface { + type: "deliveryJobNotFound" + jobId: number // int64 + } + + export interface WorkItemError extends Interface { + type: "workItemError" + errContext: string + } +} + +export type SubscriptionStatus = + | SubscriptionStatus.Active + | SubscriptionStatus.Pending + | SubscriptionStatus.Removed + | SubscriptionStatus.NoSub + +export namespace SubscriptionStatus { + export type Tag = "active" | "pending" | "removed" | "noSub" + + interface Interface { + type: Tag + } + + export interface Active extends Interface { + type: "active" + } + + export interface Pending extends Interface { + type: "pending" + } + + export interface Removed extends Interface { + type: "removed" + subError: string + } + + export interface NoSub extends Interface { + type: "noSub" + } +} + +export interface SupportGroupPreference { + enable: GroupFeatureEnabled } export enum SwitchPhase { @@ -4381,6 +4796,18 @@ export interface User { autoAcceptMemberContacts: boolean userMemberProfileUpdatedAt?: string // ISO-8601 timestamp uiThemes?: UIThemeEntityOverrides + userChatRelay: boolean +} + +export interface UserChatRelay { + chatRelayId: number // int64 + address: string + relayProfile: RelayProfile + domains: string[] + preset: boolean + tested?: boolean + enabled: boolean + deleted: boolean } export interface UserContact { diff --git a/packages/simplex-chat-client/typescript/README.md b/packages/simplex-chat-client/typescript/README.md index c1756dc82c..a67e822de2 100644 --- a/packages/simplex-chat-client/typescript/README.md +++ b/packages/simplex-chat-client/typescript/README.md @@ -1,4 +1,8 @@ -# SimpleX Chat JavaScript client +# SimpleX Chat JavaScript WebRTC client + +**THIS PACKAGE IS DEPRECATED** + +Use [SimpleX Chat Node.js library](https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-nodejs#readme) instead of this package. This is a TypeScript library that defines WebSocket API client for [SimpleX Chat terminal CLI](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/CLI.md) that should be run as a WebSockets server on any port: @@ -24,7 +28,7 @@ Please share your use cases and implementations. ## Quick start ``` -npm i simplex-chat +npm i @simplex-chat/webrtc-client@6.5.0-beta.3 npm run build ``` diff --git a/packages/simplex-chat-client/typescript/package.json b/packages/simplex-chat-client/typescript/package.json index b721dbcce2..c9d6165336 100644 --- a/packages/simplex-chat-client/typescript/package.json +++ b/packages/simplex-chat-client/typescript/package.json @@ -1,6 +1,6 @@ { - "name": "simplex-chat", - "version": "0.3.0", + "name": "@simplex-chat/webrtc-client", + "version": "6.5.0-beta.3", "description": "SimpleX Chat client", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -38,12 +38,12 @@ "bugs": { "url": "https://github.com/simplex-chat/simplex-chat/issues" }, - "homepage": "https://github.com/simplex-chat/simplex-chat/packages/simplex-chat-client/typescript#readme", + "homepage": "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/typescript#readme", "dependencies": { + "@simplex-chat/types": "^0.3.0", "isomorphic-ws": "^4.0.1" }, "devDependencies": { - "@simplex-chat/types": "^0.1.0", "@types/jest": "^27.5.1", "@types/node": "^18.11.18", "@typescript-eslint/eslint-plugin": "^5.23.0", diff --git a/packages/simplex-chat-client/typescript/tests/client.test.ts b/packages/simplex-chat-client/typescript/tests/client.test.ts index 77dca8fa5a..1c5c4d4baa 100644 --- a/packages/simplex-chat-client/typescript/tests/client.test.ts +++ b/packages/simplex-chat-client/typescript/tests/client.test.ts @@ -8,8 +8,7 @@ import {ChatEvent, CEvt, T} from "@simplex-chat/types" describe.skip("ChatClient (expects SimpleX Chat server with a user, without contacts, on localhost:5225)", () => { test("connect, send message to themselves, delete contact", async () => { const c = await ChatClient.create("ws://localhost:5225") - assert.strictEqual((await c.msgQ.dequeue()).type, "contactSubSummary") - assert.strictEqual((await c.msgQ.dequeue()).type, "userContactSubSummary") + assert.strictEqual((await c.msgQ.dequeue()).type, "connSubSummary") assert.strictEqual((await c.msgQ.dequeue()).type, "terminalEvent") assert.strictEqual((await c.msgQ.dequeue()).type, "terminalEvent") const user = await c.apiGetActiveUser() @@ -25,7 +24,7 @@ describe.skip("ChatClient (expects SimpleX Chat server with a user, without cont const r2 = await c.msgQ.dequeue() assert.strictEqual(r1.type, "contactConnecting") assert.strictEqual(r2.type, "contactConnected") - const contact1 = (r1 as CEvt.ContactConnected).contact + const contact1 = (r1 as CEvt.ContactConnecting).contact // const contact2 = (r2 as C.CRContactConnected).contact const r3 = await c.apiSendTextMessage(T.ChatType.Direct, contact1.contactId, "hello") assert(r3[0].chatItem.content.type === "sndMsgContent" && r3[0].chatItem.content.msgContent.text === "hello") diff --git a/packages/simplex-chat-nodejs/.gitignore b/packages/simplex-chat-nodejs/.gitignore new file mode 100644 index 0000000000..322e38bfda --- /dev/null +++ b/packages/simplex-chat-nodejs/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +package-lock.json +.vscode +build/ +libs/ +dist/ +coverage/ +tmp/ diff --git a/packages/simplex-chat-nodejs/.npmignore b/packages/simplex-chat-nodejs/.npmignore new file mode 100644 index 0000000000..26fdd85dff --- /dev/null +++ b/packages/simplex-chat-nodejs/.npmignore @@ -0,0 +1,3 @@ +libs/ +build/ +node_modules/ diff --git a/packages/simplex-chat-nodejs/LICENSE b/packages/simplex-chat-nodejs/LICENSE new file mode 100644 index 0000000000..0ad25db4bd --- /dev/null +++ b/packages/simplex-chat-nodejs/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md new file mode 100644 index 0000000000..739b41b34e --- /dev/null +++ b/packages/simplex-chat-nodejs/README.md @@ -0,0 +1,130 @@ +# SimpleX Chat Node.js library + +This library replaced now deprecated [SimpleX Chat WebRTC TypeScript client](https://www.npmjs.com/package/@simplex-chat/webrtc-client). + +## Use cases + +- chat bots: you can implement any logic of connecting with and communicating with SimpleX Chat users. Using chat groups a chat bot can connect SimpleX Chat users with each other. +- control of the equipment: e.g. servers or home automation. SimpleX Chat provides secure and authorised connections, so this is more secure than using rest APIs. +- any scenarios of scripted message sending. +- chat and chat-based interfaces. + +Please share your use cases and implementations. + +## Quick start: a simple bot + +``` +npm i simplex-chat@6.5.1 +``` + +Simple bot that replies with squares of numbers you send to it: + +```javascript +(async () => { + const {bot} = await import("simplex-chat") + // if you are running from this GitHub repo: + // const {bot} = await import("../dist/index.js") + const [chat, _user, _address] = await bot.run({ + profile: {displayName: "Squaring bot example", fullName: ""}, + dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, + options: { + addressSettings: {welcomeMessage: "Send a number, I will square it.", + }, + onMessage: async (ci, content) => { + const n = +content.text + const reply = typeof n === "number" && !isNaN(n) + ? `${n} * ${n} = ${n * n}` + : `this is not a number` + await chat.apiSendTextReply(ci, reply) + } + }) +})() +``` + +If you installed this package as dependency, you can run this example with: + +```sh +node ./node_modules/simplex-chat/examples/squaring-bot-readme.js +``` + +If you run it on Mac, the first time it will take 20-30 seconds for MacOS to verify the library. + +If you cloned this repository, you can: + +``` +cd ./packages/simplex-chat-nodejs +npm install +npm run build +node ./examples/squaring-bot-readme.js +``` + +There is an example with more options in [./examples/squaring-bot.ts](./examples/squaring-bot.ts). + +You can run it with: `npx ts-node ./examples/squaring-bot.ts` + +## PostgreSQL backend + +By default, the package uses SQLite. To use PostgreSQL instead: + +```bash +npm install simplex-chat --simplex_backend=postgres +``` + +Or persist the setting in `.npmrc`: + +```ini +simplex_backend=postgres +``` + +### Prerequisites (PostgreSQL) + +- `libpq5` must be installed on the host system (`apt install libpq5` on Debian/Ubuntu) +- PostgreSQL backend is only available for Linux x86_64 +- A PostgreSQL server accessible via connection string + +### Passing PostgreSQL connection + +The `DbConfig` type is a discriminated union — pick the variant that matches +the backend you installed: + +```ts +// SQLite (default) +dbOpts: {type: "sqlite", filePrefix: "./data/bot"} +// optional: encryptionKey: "" + +// PostgreSQL +dbOpts: { + type: "postgres", + connectionString: "postgres://user:pass@host/db", + // schemaPrefix: "bot", // optional — defaults to "simplex_v1" +} +``` + +## Documentation + +The library docs are [here](./docs/README.md). + +Library provides these modules: +- [bot](./docs/Namespace.bot.md): a simple declarative API to run a chat-bot with a single function call. It automates creating and updating of the bot profile, address and bot commands shown in the app UI. +- [api](./docs/Namespace.api.md): an API to send chat commands and receive chat events to/from chat core. You need to use it in bot event handlers, and for any other use cases. +- [core](./docs/Namespace.core.md): a low level API to the core library - the same that is used in desktop clients. You are unlikely to ever need to use this module directly. +- [util](./docs/Namespace.util.md): useful functions for chat events and types. + + +This library uses [@simplex-chat/types](https://www.npmjs.com/package/@simplex-chat/types) package with auto-generated [bot API types](../../bots/api/README.md). + +## Supported chat functions + +Library provides types and functions to: + +- create and change user profile (although, in most cases you can do it manually, via SimpleX Chat terminal app). +- create and accept invitations or connect with the contacts. +- create and manage long-term user address, accepting connection requests automatically. +- send, receive, delete and update messages, and add message reactions. +- create, join and manage group. +- send and receive files. +- etc. + +## License + +[AGPL v3](./LICENSE) diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp new file mode 100644 index 0000000000..09c63cecba --- /dev/null +++ b/packages/simplex-chat-nodejs/binding.gyp @@ -0,0 +1,50 @@ +{ + "targets": [ + { + "target_name": "simplex", + "sources": [ "cpp/simplex.cc" ], + "include_dirs": [ + " +#include +#include +#include +#include +#include "simplex.h" + +namespace simplex { + +using namespace Napi; + +void haskell_init() { +#ifdef _WIN32 + // non-moving GC is broken on windows with GHC 9.4-9.6.3 + int argc = 5; + const char *argv[] = { + "simplex", + "+RTS", // requires `hs_init_with_rtsopts` + "-A64m", // chunk size for new allocations + "-H64m", // initial heap size + "--install-signal-handlers=no", + nullptr}; +#else + int argc = 6; + const char *argv[] = { + "simplex", + "+RTS", // requires `hs_init_with_rtsopts` + "-A64m", // chunk size for new allocations + "-H64m", // initial heap size + "-xn", // non-moving GC + "--install-signal-handlers=no", + nullptr}; +#endif + char **pargv = const_cast(argv); + hs_init_with_rtsopts(&argc, &pargv); +} + +class ResultAsyncWorker : public AsyncWorker { + public: + using ExecuteFn = std::function; + using ResultProcessor = std::function; + + ResultAsyncWorker(Function& callback, ExecuteFn execute_fn, ResultProcessor result_processor = nullptr) + : AsyncWorker(callback), execute_fn_(std::move(execute_fn)), result_processor_(std::move(result_processor)) {} + + void Execute() override { + execute_fn_(this); + } + + void OnOK() override { + HandleScope scope(Env()); + if (result_processor_) { + result_processor_(this, Env()); + } else { + Callback().Call({Env().Null(), String::New(Env(), result_)}); + } + } + + void OnError(const Error& e) override { + HandleScope scope(Env()); + Callback().Call({e.Value(), Env().Undefined()}); + } + + void SetResult(std::string result) { + result_ = std::move(result); + } + + void SetWorkerError(const std::string& msg) { + SetError(msg); + } + + const std::string& GetStringResult() const { + return result_; + } + + void SetCtrl(uintptr_t ctrl) { + ctrl_ = ctrl; + } + + uintptr_t GetCtrl() const { + return ctrl_; + } + + protected: + std::string result_; + uintptr_t ctrl_ = 0; + + private: + ExecuteFn execute_fn_; + ResultProcessor result_processor_; +}; + +class BinaryAsyncWorker : public AsyncWorker { + public: + using ExecuteFn = std::function; + + BinaryAsyncWorker(Function& callback, ExecuteFn execute_fn) + : AsyncWorker(callback), execute_fn_(std::move(execute_fn)) {} + + void Execute() override { + execute_fn_(this); + } + + void OnOK() override { + HandleScope scope(Env()); + if (original_buf == nullptr || binary_len == 0) { + Callback().Call({Env().Null(), Env().Undefined()}); + return; + } + char* data_ptr = original_buf + 5; + auto finalizer = [](Napi::Env env, char* finalize_data, char* orig) { + free(orig); + }; + Napi::Buffer buffer = Napi::Buffer::New(Env(), data_ptr, binary_len, finalizer, original_buf); + Callback().Call({Env().Null(), buffer}); + } + + void OnError(const Error& e) override { + HandleScope scope(Env()); + Callback().Call({e.Value(), Env().Undefined()}); + } + + void SetWorkerError(const std::string& msg) { + SetError(msg); + } + + char* original_buf = nullptr; + size_t binary_len = 0; + + private: + ExecuteFn execute_fn_; +}; + +// Helper for converting chat_ctrl pointer to BigInt +Napi::BigInt ToChatCtrlBigInt(Napi::Env env, uintptr_t ctrl) { + return Napi::BigInt::New(env, static_cast(ctrl)); +} + +// Helper for converting BigInt to chat_ctrl pointer +chat_ctrl FromChatCtrlBigInt(const Napi::Value& value) { + Napi::Env env = value.Env(); + if (!value.IsBigInt()) { + Napi::TypeError::New(env, "Expected BigInt for ctrl").ThrowAsJavaScriptException(); + return nullptr; + } + Napi::BigInt big = value.As(); + bool lossless; + uint64_t val = big.Uint64Value(&lossless); + if (!lossless) { + Napi::TypeError::New(env, "BigInt too large for ctrl").ThrowAsJavaScriptException(); + return nullptr; + } + return reinterpret_cast(val); +} + +// Helper for handling common C result patterns (no empty check) +void HandleCResult(ResultAsyncWorker* worker, char* c_res, const std::string& func_name) { + if (c_res == nullptr) { + worker->SetWorkerError(func_name + " failed"); + return; + } + std::string res = c_res; + free(c_res); + worker->SetResult(res); +} + +Napi::Promise CreatePromiseAndCallback(Env env, Function& cb_out) { + Promise::Deferred deferred = Promise::Deferred::New(env); + cb_out = Function::New(env, [deferred](const CallbackInfo& args) { + if (!args[0].IsNull() && !args[0].IsUndefined()) { + deferred.Reject(args[0]); + } else { + deferred.Resolve(args[1]); + } + }); + return deferred.Promise(); +} + +// Common result processors +ResultAsyncWorker::ResultProcessor MigrateResultProcessor() { + return [](ResultAsyncWorker* worker, Napi::Env env) { + Napi::Array arr = Napi::Array::New(env, 2); + arr.Set(0u, ToChatCtrlBigInt(env, worker->GetCtrl())); + arr.Set(1u, Napi::String::New(env, worker->GetStringResult())); + worker->Callback().Call({env.Null(), arr}); + }; +} + +// Refactored functions using common patterns + +Value ChatMigrateInit(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { + TypeError::New(env, "Expected three string arguments").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::string path = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string confirm = args[2].As().Utf8Value(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [path, key, confirm](ResultAsyncWorker* worker) { + chat_ctrl ctrl = nullptr; + char* c_res = chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); + worker->SetCtrl(reinterpret_cast(ctrl)); + HandleCResult(worker, c_res, "chat_migrate_init"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn), MigrateResultProcessor()); + worker->Queue(); + + return promise; +} + +Value ChatCloseStore(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 1 || !args[0].IsBigInt()) { + TypeError::New(env, "Expected bigint (ctrl)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [ctrl](ResultAsyncWorker* worker) { + char* c_res = chat_close_store(ctrl); + HandleCResult(worker, c_res, "chat_close_store"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->Queue(); + + return promise; +} + +Value ChatSendCmd(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsString()) { + TypeError::New(env, "Expected bigint (ctrl) and string (cmd)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); + std::string cmd = args[1].As().Utf8Value(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [ctrl, cmd](ResultAsyncWorker* worker) { + char* c_res = chat_send_cmd(ctrl, cmd.c_str()); + HandleCResult(worker, c_res, "chat_send_cmd"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->Queue(); + + return promise; +} + +Value ChatRecvMsgWait(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsNumber()) { + TypeError::New(env, "Expected bigint (ctrl), number (wait)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); + int wait = static_cast(args[1].As().Int32Value()); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [ctrl, wait](ResultAsyncWorker* worker) { + char* c_res = chat_recv_msg_wait(ctrl, wait); + HandleCResult(worker, c_res, "chat_recv_msg_wait"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->Queue(); + + return promise; +} + +Value ChatWriteFile(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsArrayBuffer()) { + TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); + std::string path = args[1].As().Utf8Value(); + ArrayBuffer ab = args[2].As(); + char* data = static_cast(ab.Data()); + size_t len = ab.ByteLength(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [ctrl, path, ab, data, len](ResultAsyncWorker* worker) { + (void)ab; // to keep ArrayBuffer alive + char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast(len)); + HandleCResult(worker, c_res, "chat_write_file"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->Queue(); + + return promise; +} + +Value ChatReadFile(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { + TypeError::New(env, "Expected three strings (path, key, nonce)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::string path = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string nonce = args[2].As().Utf8Value(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [path, key, nonce](BinaryAsyncWorker* worker) { + char* buf = chat_read_file(path.c_str(), key.c_str(), nonce.c_str()); + if (buf == nullptr) { + worker->SetWorkerError("chat_read_file failed"); + return; + } + char status = buf[0]; + if (status == 1) { + std::string err = buf + 1; + free(buf); + worker->SetWorkerError(err); + return; + } else if (status == 0) { + uint32_t len = *(uint32_t*)(buf + 1); + worker->original_buf = buf; + worker->binary_len = len; + } else { + free(buf); + worker->SetWorkerError("Unexpected status from chat_read_file"); + return; + } + }; + + BinaryAsyncWorker* worker = new BinaryAsyncWorker(cb, std::move(execute_fn)); + worker->Queue(); + + return promise; +} + +Value ChatEncryptFile(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsString()) { + TypeError::New(env, "Expected bigint (ctrl), two strings (fromPath, toPath)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); + std::string fromPath = args[1].As().Utf8Value(); + std::string toPath = args[2].As().Utf8Value(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [ctrl, fromPath, toPath](ResultAsyncWorker* worker) { + char* c_res = chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()); + HandleCResult(worker, c_res, "chat_encrypt_file"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->Queue(); + + return promise; +} + +Value ChatDecryptFile(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsString()) { + TypeError::New(env, "Expected four strings (fromPath, key, nonce, toPath)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::string fromPath = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string nonce = args[2].As().Utf8Value(); + std::string toPath = args[3].As().Utf8Value(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [fromPath, key, nonce, toPath](ResultAsyncWorker* worker) { + char* c_res = chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()); + HandleCResult(worker, c_res, "chat_decrypt_file"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->Queue(); + + return promise; +} + +Object Init(Env env, Object exports) { + haskell_init(); + exports.Set("chat_migrate_init", Function::New(env, ChatMigrateInit)); + exports.Set("chat_close_store", Function::New(env, ChatCloseStore)); + exports.Set("chat_send_cmd", Function::New(env, ChatSendCmd)); + exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait)); + exports.Set("chat_write_file", Function::New(env, ChatWriteFile)); + exports.Set("chat_read_file", Function::New(env, ChatReadFile)); + exports.Set("chat_encrypt_file", Function::New(env, ChatEncryptFile)); + exports.Set("chat_decrypt_file", Function::New(env, ChatDecryptFile)); + return exports; +} + +NODE_API_MODULE(simplex, Init) + +} diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h new file mode 100644 index 0000000000..8e579626ed --- /dev/null +++ b/packages/simplex-chat-nodejs/cpp/simplex.h @@ -0,0 +1,46 @@ +// +// simplex.h +// SimpleX +// +// Created by Evgeny on 30/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +#ifndef SimpleX_h +#define SimpleX_h + +extern "C" void hs_init(int argc, char **argv[]); +extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]); + +typedef long* chat_ctrl; + +// the last parameter is used to return the pointer to chat controller +extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); +extern "C" char *chat_close_store(chat_ctrl ctrl); +extern "C" char *chat_reopen_store(chat_ctrl ctrl); +extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); +extern "C" char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); +extern "C" char *chat_parse_markdown(const char *str); +extern "C" char *chat_parse_server(const char *str); +extern "C" char *chat_password_hash(const char *pwd, const char *salt); +extern "C" char *chat_valid_name(const char *name); +extern "C" int chat_json_length(const char *str); +extern "C" char *chat_encrypt_media(chat_ctrl ctrl, const char *key, const char *frame, const int len); +extern "C" char *chat_decrypt_media(const char *key, const char *frame, const int len); + +// chat_write_file returns null-terminated string with JSON of WriteFileResult +extern "C" char *chat_write_file(chat_ctrl ctrl, const char *path, const char *data, const int len); + +// chat_read_file returns a buffer with: +// result status (1 byte), then if +// status == 0 (success): buffer length (uint32, 4 bytes), buffer of specified length. +// status == 1 (error): null-terminated error message string. +extern "C" char *chat_read_file(const char *path, const char *key, const char *nonce); + +// chat_encrypt_file returns null-terminated string with JSON of WriteFileResult +extern "C" char *chat_encrypt_file(chat_ctrl ctrl, const char *fromPath, const char *toPath); + +// chat_decrypt_file returns null-terminated string with the error message +extern "C" char *chat_decrypt_file(const char *fromPath, const char *key, const char *nonce, const char *toPath); + +#endif /* simplex_h */ \ No newline at end of file diff --git a/packages/simplex-chat-nodejs/docs/Namespace.api.md b/packages/simplex-chat-nodejs/docs/Namespace.api.md new file mode 100644 index 0000000000..5771b8450e --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/Namespace.api.md @@ -0,0 +1,33 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / api + +# api + +An API to send chat commands and receive chat events to/from chat core. +You need to use it in bot event handlers, and for any other use cases. + +## Enumerations + +- [ConnReqType](api.Enumeration.ConnReqType.md) + +## Classes + +- [ChatApi](api.Class.ChatApi.md) +- [ChatCommandError](api.Class.ChatCommandError.md) + +## Interfaces + +- [BotAddressSettings](api.Interface.BotAddressSettings.md) + +## Type Aliases + +- [DbConfig](api.TypeAlias.DbConfig.md) +- [EventSubscriberFunc](api.TypeAlias.EventSubscriberFunc.md) +- [EventSubscribers](api.TypeAlias.EventSubscribers.md) + +## Variables + +- [defaultBotAddressSettings](api.Variable.defaultBotAddressSettings.md) diff --git a/packages/simplex-chat-nodejs/docs/Namespace.bot.md b/packages/simplex-chat-nodejs/docs/Namespace.bot.md new file mode 100644 index 0000000000..4b9171d0f7 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/Namespace.bot.md @@ -0,0 +1,23 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / bot + +# bot + +A simple declarative API to run a chat-bot with a single function call. +It automates creating and updating of the bot profile, address and bot commands shown in the app UI. + +## Interfaces + +- [BotConfig](bot.Interface.BotConfig.md) +- [BotOptions](bot.Interface.BotOptions.md) + +## Type Aliases + +- [BotDbOpts](bot.TypeAlias.BotDbOpts.md) + +## Functions + +- [run](bot.Function.run.md) diff --git a/packages/simplex-chat-nodejs/docs/Namespace.core.md b/packages/simplex-chat-nodejs/docs/Namespace.core.md new file mode 100644 index 0000000000..82b0d9ffba --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/Namespace.core.md @@ -0,0 +1,48 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / core + +# core + +A low level API to the core library - the same that is used in desktop clients. +You are unlikely to ever need to use this module directly. + +## Namespaces + +- [DBMigrationError](core.Namespace.DBMigrationError.md) +- [MigrationError](core.Namespace.MigrationError.md) +- [MTRError](core.Namespace.MTRError.md) + +## Enumerations + +- [MigrationConfirmation](core.Enumeration.MigrationConfirmation.md) + +## Classes + +- [ChatAPIError](core.Class.ChatAPIError.md) +- [ChatInitError](core.Class.ChatInitError.md) + +## Interfaces + +- [APIResult](core.Interface.APIResult.md) +- [CryptoArgs](core.Interface.CryptoArgs.md) +- [UpMigration](core.Interface.UpMigration.md) + +## Type Aliases + +- [DBMigrationError](core.TypeAlias.DBMigrationError.md) +- [MigrationError](core.TypeAlias.MigrationError.md) +- [MTRError](core.TypeAlias.MTRError.md) + +## Functions + +- [chatCloseStore](core.Function.chatCloseStore.md) +- [chatDecryptFile](core.Function.chatDecryptFile.md) +- [chatEncryptFile](core.Function.chatEncryptFile.md) +- [chatMigrateInit](core.Function.chatMigrateInit.md) +- [chatReadFile](core.Function.chatReadFile.md) +- [chatRecvMsgWait](core.Function.chatRecvMsgWait.md) +- [chatSendCmd](core.Function.chatSendCmd.md) +- [chatWriteFile](core.Function.chatWriteFile.md) diff --git a/packages/simplex-chat-nodejs/docs/Namespace.util.md b/packages/simplex-chat-nodejs/docs/Namespace.util.md new file mode 100644 index 0000000000..a6e7d9c7f3 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/Namespace.util.md @@ -0,0 +1,25 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / util + +# util + +Useful functions for chat events and types. + +## Interfaces + +- [BotCommand](util.Interface.BotCommand.md) + +## Functions + +- [botAddressSettings](util.Function.botAddressSettings.md) +- [chatInfoName](util.Function.chatInfoName.md) +- [chatInfoRef](util.Function.chatInfoRef.md) +- [ciBotCommand](util.Function.ciBotCommand.md) +- [ciContentText](util.Function.ciContentText.md) +- [contactAddressStr](util.Function.contactAddressStr.md) +- [fromLocalProfile](util.Function.fromLocalProfile.md) +- [reactionText](util.Function.reactionText.md) +- [senderName](util.Function.senderName.md) diff --git a/packages/simplex-chat-nodejs/docs/README.md b/packages/simplex-chat-nodejs/docs/README.md new file mode 100644 index 0000000000..f954730ed6 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/README.md @@ -0,0 +1,12 @@ +**simplex-chat** + +*** + +# simplex-chat + +## Namespaces + +- [api](Namespace.api.md) +- [bot](Namespace.bot.md) +- [core](Namespace.core.md) +- [util](Namespace.util.md) diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md new file mode 100644 index 0000000000..b81677b976 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md @@ -0,0 +1,1752 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [api](Namespace.api.md) / ChatApi + +# Class: ChatApi + +Defined in: [src/api.ts:97](../src/api.ts#L97) + +Main API class for interacting with the chat core library. + +## Properties + +### ctrl\_ + +> `protected` **ctrl\_**: `bigint` \| `undefined` + +Defined in: [src/api.ts:103](../src/api.ts#L103) + +## Accessors + +### ctrl + +#### Get Signature + +> **get** **ctrl**(): `bigint` + +Defined in: [src/api.ts:329](../src/api.ts#L329) + +Chat controller reference + +##### Returns + +`bigint` + +*** + +### initialized + +#### Get Signature + +> **get** **initialized**(): `boolean` + +Defined in: [src/api.ts:315](../src/api.ts#L315) + +Chat controller is initialized + +##### Returns + +`boolean` + +*** + +### started + +#### Get Signature + +> **get** **started**(): `boolean` + +Defined in: [src/api.ts:322](../src/api.ts#L322) + +Chat controller is started + +##### Returns + +`boolean` + +## Methods + +### apiAcceptContactRequest() + +> **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\> + +Defined in: [src/api.ts:731](../src/api.ts#L731) + +Accept contact request. +Network usage: interactive. + +#### Parameters + +##### contactReqId + +`number` + +#### Returns + +`Promise`\<`Contact`\> + +*** + +### apiAcceptMember() + +> **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> + +Defined in: [src/api.ts:551](../src/api.ts#L551) + +Accept group member. Requires Admin role. +Network usage: background. + +#### Parameters + +##### groupId + +`number` + +##### groupMemberId + +`number` + +##### memberRole + +`GroupMemberRole` + +#### Returns + +`Promise`\<`GroupMember`\> + +*** + +### apiAddMember() + +> **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\> + +Defined in: [src/api.ts:531](../src/api.ts#L531) + +Add contact to group. Requires bot to have Admin role. +Network usage: interactive. + +#### Parameters + +##### groupId + +`number` + +##### contactId + +`number` + +##### memberRole + +`GroupMemberRole` + +#### Returns + +`Promise`\<`GroupMember`\> + +*** + +### apiBlockMembersForAll() + +> **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\> + +Defined in: [src/api.ts:571](../src/api.ts#L571) + +Block members. Requires Moderator role. +Network usage: background. + +#### Parameters + +##### groupId + +`number` + +##### groupMemberIds + +`number`[] + +##### blocked + +`boolean` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiCancelFile() + +> **apiCancelFile**(`fileId`): `Promise`\<`void`\> + +Defined in: [src/api.ts:521](../src/api.ts#L521) + +Cancel file. +Network usage: background. + +#### Parameters + +##### fileId + +`number` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiChatItemReaction() + +> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ChatItemDeletion`[]\> + +Defined in: [src/api.ts:495](../src/api.ts#L495) + +Add/remove message reaction. +Network usage: background. + +#### Parameters + +##### chatType + +`ChatType` + +##### chatId + +`number` + +##### chatItemId + +`number` + +##### add + +`boolean` + +##### reaction + +`MsgReaction` + +#### Returns + +`Promise`\<`ChatItemDeletion`[]\> + +*** + +### apiConnect() + +> **apiConnect**(`userId`, `incognito`, `preparedLink?`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> + +Defined in: [src/api.ts:700](../src/api.ts#L700) + +Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link +Network usage: interactive. + +#### Parameters + +##### userId + +`number` + +##### incognito + +`boolean` + +##### preparedLink? + +`CreatedConnLink` + +#### Returns + +`Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> + +*** + +### apiConnectActiveUser() + +> **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> + +Defined in: [src/api.ts:709](../src/api.ts#L709) + +Connect via SimpleX link as string in the active user profile. +Network usage: interactive. + +#### Parameters + +##### connLink + +`string` + +#### Returns + +`Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> + +*** + +### apiConnectPlan() + +> **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> + +Defined in: [src/api.ts:690](../src/api.ts#L690) + +Determine SimpleX link type and if the bot is already connected via this link. +Network usage: interactive. + +#### Parameters + +##### userId + +`number` + +##### connectionLink + +`string` + +#### Returns + +`Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> + +*** + +### apiCreateActiveUser() + +> **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\> + +Defined in: [src/api.ts:849](../src/api.ts#L849) + +Create new user profile +Network usage: no. + +#### Parameters + +##### profile? + +`Profile` + +#### Returns + +`Promise`\<`User`\> + +*** + +### apiCreateGroupLink() + +> **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\> + +Defined in: [src/api.ts:631](../src/api.ts#L631) + +Create group link. +Network usage: interactive. + +#### Parameters + +##### groupId + +`number` + +##### memberRole + +`GroupMemberRole` + +#### Returns + +`Promise`\<`string`\> + +*** + +### apiCreateLink() + +> **apiCreateLink**(`userId`): `Promise`\<`string`\> + +Defined in: [src/api.ts:677](../src/api.ts#L677) + +Create 1-time invitation link. +Network usage: interactive. + +#### Parameters + +##### userId + +`number` + +#### Returns + +`Promise`\<`string`\> + +*** + +### apiCreateMemberContact() + +> **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\> + +Defined in: [src/api.ts:915](../src/api.ts#L915) + +Create a direct message contact with a group member. +Returns the created contact. +Network usage: interactive. + +#### Parameters + +##### groupId + +`number` + +##### groupMemberId + +`number` + +#### Returns + +`Promise`\<`Contact`\> + +*** + +### apiCreateUserAddress() + +> **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\> + +Defined in: [src/api.ts:346](../src/api.ts#L346) + +Create bot address. +Network usage: interactive. + +#### Parameters + +##### userId + +`number` + +#### Returns + +`Promise`\<`CreatedConnLink`\> + +*** + +### apiDeleteChat() + +> **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\> + +Defined in: [src/api.ts:771](../src/api.ts#L771) + +Delete chat. +Network usage: background. + +#### Parameters + +##### chatType + +`ChatType` + +##### chatId + +`number` + +##### deleteMode? + +`ChatDeleteMode` = `...` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiDeleteChatItems() + +> **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\> + +Defined in: [src/api.ts:470](../src/api.ts#L470) + +Delete message. +Network usage: background. + +#### Parameters + +##### chatType + +`ChatType` + +##### chatId + +`number` + +##### chatItemIds + +`number`[] + +##### deleteMode + +`CIDeleteMode` + +#### Returns + +`Promise`\<`ChatItemDeletion`[]\> + +*** + +### apiDeleteGroupLink() + +> **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\> + +Defined in: [src/api.ts:653](../src/api.ts#L653) + +Delete group link. +Network usage: background. + +#### Parameters + +##### groupId + +`number` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiDeleteMemberChatItem() + +> **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\> + +Defined in: [src/api.ts:485](../src/api.ts#L485) + +Moderate message. Requires Moderator role (and higher than message author's). +Network usage: background. + +#### Parameters + +##### groupId + +`number` + +##### chatItemIds + +`number`[] + +#### Returns + +`Promise`\<`ChatItemDeletion`[]\> + +*** + +### apiDeleteUser() + +> **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\> + +Defined in: [src/api.ts:879](../src/api.ts#L879) + +Delete user profile. +Network usage: background. + +#### Parameters + +##### userId + +`number` + +##### delSMPQueues + +`boolean` + +##### viewPwd? + +`string` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiDeleteUserAddress() + +> **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\> + +Defined in: [src/api.ts:356](../src/api.ts#L356) + +Deletes a user address. +Network usage: background. + +#### Parameters + +##### userId + +`number` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiGetActiveUser() + +> **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\> + +Defined in: [src/api.ts:829](../src/api.ts#L829) + +Get active user profile +Network usage: no. + +#### Returns + +`Promise`\<`User` \| `undefined`\> + +*** + +### apiGetChat() + +> **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\> + +Defined in: [src/api.ts:819](../src/api.ts#L819) + +Get chat items. +Network usage: no. + +#### Parameters + +##### chatType + +`ChatType` + +##### chatId + +`number` + +##### count + +`number` + +#### Returns + +`Promise`\<`any`\> + +*** + +### apiGetGroupLink() + +> **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\> + +Defined in: [src/api.ts:662](../src/api.ts#L662) + +Get group link. +Network usage: no. + +#### Parameters + +##### groupId + +`number` + +#### Returns + +`Promise`\<`GroupLink`\> + +*** + +### apiGetGroupLinkStr() + +> **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\> + +Defined in: [src/api.ts:668](../src/api.ts#L668) + +#### Parameters + +##### groupId + +`number` + +#### Returns + +`Promise`\<`string`\> + +*** + +### apiGetUserAddress() + +> **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\> + +Defined in: [src/api.ts:366](../src/api.ts#L366) + +Get bot address and settings. +Network usage: no. + +#### Parameters + +##### userId + +`number` + +#### Returns + +`Promise`\<`UserContactLink` \| `undefined`\> + +*** + +### apiJoinGroup() + +> **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\> + +Defined in: [src/api.ts:541](../src/api.ts#L541) + +Join group. +Network usage: interactive. + +#### Parameters + +##### groupId + +`number` + +#### Returns + +`Promise`\<`GroupInfo`\> + +*** + +### apiLeaveGroup() + +> **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\> + +Defined in: [src/api.ts:591](../src/api.ts#L591) + +Leave group. +Network usage: background. + +#### Parameters + +##### groupId + +`number` + +#### Returns + +`Promise`\<`GroupInfo`\> + +*** + +### apiListContacts() + +> **apiListContacts**(`userId`): `Promise`\<`Contact`[]\> + +Defined in: [src/api.ts:751](../src/api.ts#L751) + +Get contacts. +Network usage: no. + +#### Parameters + +##### userId + +`number` + +#### Returns + +`Promise`\<`Contact`[]\> + +*** + +### apiListGroups() + +> **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\> + +Defined in: [src/api.ts:761](../src/api.ts#L761) + +Get groups. +Network usage: no. + +#### Parameters + +##### userId + +`number` + +##### contactId? + +`number` + +##### search? + +`string` + +#### Returns + +`Promise`\<`GroupInfo`[]\> + +*** + +### apiListMembers() + +> **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\> + +Defined in: [src/api.ts:601](../src/api.ts#L601) + +Get group members. +Network usage: no. + +#### Parameters + +##### groupId + +`number` + +#### Returns + +`Promise`\<`GroupMember`[]\> + +*** + +### apiListUsers() + +> **apiListUsers**(): `Promise`\<`UserInfo`[]\> + +Defined in: [src/api.ts:859](../src/api.ts#L859) + +Get all user profiles +Network usage: no. + +#### Returns + +`Promise`\<`UserInfo`[]\> + +*** + +### apiNewGroup() + +> **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\> + +Defined in: [src/api.ts:611](../src/api.ts#L611) + +Create group. +Network usage: no. + +#### Parameters + +##### userId + +`number` + +##### groupProfile + +`GroupProfile` + +#### Returns + +`Promise`\<`GroupInfo`\> + +*** + +### apiReceiveFile() + +> **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\> + +Defined in: [src/api.ts:511](../src/api.ts#L511) + +Receive file. +Network usage: no. + +#### Parameters + +##### fileId + +`number` + +#### Returns + +`Promise`\<`AChatItem`\> + +*** + +### apiRejectContactRequest() + +> **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\> + +Defined in: [src/api.ts:741](../src/api.ts#L741) + +Reject contact request. The user who sent the request is **not notified**. +Network usage: no. + +#### Parameters + +##### contactReqId + +`number` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiRemoveMembers() + +> **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\> + +Defined in: [src/api.ts:581](../src/api.ts#L581) + +Remove members. Requires Admin role. +Network usage: background. + +#### Parameters + +##### groupId + +`number` + +##### memberIds + +`number`[] + +##### withMessages? + +`boolean` = `false` + +#### Returns + +`Promise`\<`GroupMember`[]\> + +*** + +### apiSendMemberContactInvitation() + +> **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\> + +Defined in: [src/api.ts:926](../src/api.ts#L926) + +Send a direct message invitation to a group member contact. +The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact). +Network usage: interactive. + +#### Parameters + +##### contactId + +`number` + +##### message? + +`string` \| `MsgContent` + +#### Returns + +`Promise`\<`Contact`\> + +*** + +### apiSendMessages() + +> **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\> + +Defined in: [src/api.ts:415](../src/api.ts#L415) + +Send messages. +Network usage: background. + +#### Parameters + +##### chat + +`ChatInfo` \| `ChatRef` \| \[`ChatType`, `number`\] + +##### messages + +`ComposedMessage`[] + +##### liveMessage? + +`boolean` = `false` + +#### Returns + +`Promise`\<`AChatItem`[]\> + +*** + +### apiSendTextMessage() + +> **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\> + +Defined in: [src/api.ts:437](../src/api.ts#L437) + +Send text message. +Network usage: background. + +#### Parameters + +##### chat + +`ChatInfo` \| `ChatRef` \| \[`ChatType`, `number`\] + +##### text + +`string` + +##### inReplyTo? + +`number` + +#### Returns + +`Promise`\<`AChatItem`[]\> + +*** + +### apiSendTextReply() + +> **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\> + +Defined in: [src/api.ts:445](../src/api.ts#L445) + +Send text message in reply to received message. +Network usage: background. + +#### Parameters + +##### chatItem + +`AChatItem` + +##### text + +`string` + +#### Returns + +`Promise`\<`AChatItem`[]\> + +*** + +### apiSetActiveUser() + +> **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\> + +Defined in: [src/api.ts:869](../src/api.ts#L869) + +Set active user profile +Network usage: no. + +#### Parameters + +##### userId + +`number` + +##### viewPwd? + +`string` + +#### Returns + +`Promise`\<`User`\> + +*** + +### apiSetAddressSettings() + +> **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\> + +Defined in: [src/api.ts:398](../src/api.ts#L398) + +Set bot address settings. +Network usage: interactive. + +#### Parameters + +##### userId + +`number` + +##### \_\_namedParameters + +[`BotAddressSettings`](api.Interface.BotAddressSettings.md) + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiSetAutoAcceptMemberContacts() + +> **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\> + +Defined in: [src/api.ts:808](../src/api.ts#L808) + +Set auto-accept member contacts. +Network usage: no. + +#### Parameters + +##### userId + +`number` + +##### onOff + +`boolean` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiSetContactCustomData() + +> **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\> + +Defined in: [src/api.ts:798](../src/api.ts#L798) + +Set contact custom data. +Network usage: no. + +#### Parameters + +##### contactId + +`number` + +##### customData? + +`object` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiSetContactPrefs() + +> **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\> + +Defined in: [src/api.ts:905](../src/api.ts#L905) + +Configure chat preference overrides for the contact. +Network usage: background. + +#### Parameters + +##### contactId + +`number` + +##### preferences + +`Preferences` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiSetGroupCustomData() + +> **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\> + +Defined in: [src/api.ts:788](../src/api.ts#L788) + +Set group custom data. +Network usage: no. + +#### Parameters + +##### groupId + +`number` + +##### customData? + +`object` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiSetGroupLinkMemberRole() + +> **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\> + +Defined in: [src/api.ts:644](../src/api.ts#L644) + +Set member role for group link. +Network usage: no. + +#### Parameters + +##### groupId + +`number` + +##### memberRole + +`GroupMemberRole` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiSetMembersRole() + +> **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\> + +Defined in: [src/api.ts:561](../src/api.ts#L561) + +Set members role. Requires Admin role. +Network usage: background. + +#### Parameters + +##### groupId + +`number` + +##### groupMemberIds + +`number`[] + +##### memberRole + +`GroupMemberRole` + +#### Returns + +`Promise`\<`void`\> + +*** + +### apiSetProfileAddress() + +> **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\> + +Defined in: [src/api.ts:384](../src/api.ts#L384) + +Add address to bot profile. +Network usage: interactive. + +#### Parameters + +##### userId + +`number` + +##### enable + +`boolean` + +#### Returns + +`Promise`\<`UserProfileUpdateSummary`\> + +*** + +### apiUpdateChatItem() + +> **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\> + +Defined in: [src/api.ts:453](../src/api.ts#L453) + +Update message. +Network usage: background. + +#### Parameters + +##### chatType + +`ChatType` + +##### chatId + +`number` + +##### chatItemId + +`number` + +##### msgContent + +`MsgContent` + +##### liveMessage + +`false` + +#### Returns + +`Promise`\<`ChatItem`\> + +*** + +### apiUpdateGroupProfile() + +> **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\> + +Defined in: [src/api.ts:621](../src/api.ts#L621) + +Update group profile. +Network usage: background. + +#### Parameters + +##### groupId + +`number` + +##### groupProfile + +`GroupProfile` + +#### Returns + +`Promise`\<`GroupInfo`\> + +*** + +### apiUpdateProfile() + +> **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\> + +Defined in: [src/api.ts:889](../src/api.ts#L889) + +Update user profile. +Network usage: background. + +#### Parameters + +##### userId + +`number` + +##### profile + +`Profile` + +#### Returns + +`Promise`\<`UserProfileUpdateSummary` \| `undefined`\> + +*** + +### close() + +> **close**(): `Promise`\<`void`\> + +Defined in: [src/api.ts:148](../src/api.ts#L148) + +Close chat database. +Usually doesn't need to be called in chat bots. + +#### Returns + +`Promise`\<`void`\> + +*** + +### off() + +> **off**\<`K`\>(`event`, `subscriber?`): `void` + +Defined in: [src/api.ts:287](../src/api.ts#L287) + +Unsubscribe all or a specific handler from a specific event. + +#### Type Parameters + +##### K + +`K` *extends* `Tag` + +#### Parameters + +##### event + +`K` + +The event type to unsubscribe from. + +##### subscriber? + +[`EventSubscriberFunc`](api.TypeAlias.EventSubscriberFunc.md)\<`K`\> \| `undefined` + +An optional subscriber function for the event. + +#### Returns + +`void` + +*** + +### offAny() + +> **offAny**(`receiver?`): `void` + +Defined in: [src/api.ts:303](../src/api.ts#L303) + +Unsubscribe all or a specific handler from any events. + +#### Parameters + +##### receiver? + +[`EventSubscriberFunc`](api.TypeAlias.EventSubscriberFunc.md)\<`Tag`\> \| `undefined` + +An optional subscriber function for the event. + +#### Returns + +`void` + +*** + +### on() + +#### Call Signature + +> **on**\<`K`\>(`subscribers`): `void` + +Defined in: [src/api.ts:197](../src/api.ts#L197) + +Subscribe multiple event handlers at once. + +##### Type Parameters + +###### K + +`K` *extends* `Tag` + +##### Parameters + +###### subscribers + +[`EventSubscribers`](api.TypeAlias.EventSubscribers.md) + +An object mapping event types (CEvt.Tag) to their subscriber functions. + +##### Returns + +`void` + +##### Throws + +If the same function is subscribed to event. + +#### Call Signature + +> **on**\<`K`\>(`event`, `subscriber`): `void` + +Defined in: [src/api.ts:205](../src/api.ts#L205) + +Subscribe a handler to a specific event. + +##### Type Parameters + +###### K + +`K` *extends* `Tag` + +##### Parameters + +###### event + +`K` + +The event type to subscribe to. + +###### subscriber + +[`EventSubscriberFunc`](api.TypeAlias.EventSubscriberFunc.md)\<`K`\> + +The subscriber function for the event. + +##### Returns + +`void` + +##### Throws + +If the same function is subscribed to event. + +*** + +### onAny() + +> **onAny**(`receiver`): `void` + +Defined in: [src/api.ts:228](../src/api.ts#L228) + +Subscribe a handler to any event. + +#### Parameters + +##### receiver + +[`EventSubscriberFunc`](api.TypeAlias.EventSubscriberFunc.md)\<`Tag`\> + +The receiver function for any event. + +#### Returns + +`void` + +#### Throws + +If the same function is subscribed to event. + +*** + +### once() + +> **once**\<`K`\>(`event`, `subscriber`): `void` + +Defined in: [src/api.ts:239](../src/api.ts#L239) + +Subscribe a handler to a specific event to be delivered one time. + +#### Type Parameters + +##### K + +`K` *extends* `Tag` + +#### Parameters + +##### event + +`K` + +The event type to subscribe to. + +##### subscriber + +[`EventSubscriberFunc`](api.TypeAlias.EventSubscriberFunc.md)\<`K`\> + +The subscriber function for the event. + +#### Returns + +`void` + +#### Throws + +If the same function is subscribed to event. + +*** + +### recvChatEvent() + +> **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\> + +Defined in: [src/api.ts:338](../src/api.ts#L338) + +#### Parameters + +##### wait? + +`number` = `5_000_000` + +#### Returns + +`Promise`\<`ChatEvent` \| `undefined`\> + +*** + +### sendChatCmd() + +> **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\> + +Defined in: [src/api.ts:334](../src/api.ts#L334) + +#### Parameters + +##### cmd + +`string` + +#### Returns + +`Promise`\<`ChatResponse`\> + +*** + +### startChat() + +> **startChat**(): `Promise`\<`void`\> + +Defined in: [src/api.ts:122](../src/api.ts#L122) + +Start chat controller. Must be called with the existing user profile. + +#### Returns + +`Promise`\<`void`\> + +*** + +### stopChat() + +> **stopChat**(): `Promise`\<`void`\> + +Defined in: [src/api.ts:136](../src/api.ts#L136) + +Stop chat controller. +Must be called before closing the database. +Usually doesn't need to be called in chat bots. + +#### Returns + +`Promise`\<`void`\> + +*** + +### wait() + +#### Call Signature + +> **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\> + +Defined in: [src/api.ts:247](../src/api.ts#L247) + +Waits for specific event, with an optional predicate. +Returns `undefined` on timeout if specified. + +##### Type Parameters + +###### K + +`K` *extends* `Tag` + +##### Parameters + +###### event + +`K` + +##### Returns + +`Promise`\<`ChatEvent` & `object`\> + +#### Call Signature + +> **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\> + +Defined in: [src/api.ts:248](../src/api.ts#L248) + +Waits for specific event, with an optional predicate. +Returns `undefined` on timeout if specified. + +##### Type Parameters + +###### K + +`K` *extends* `Tag` + +##### Parameters + +###### event + +`K` + +###### predicate + +((`event`) => `boolean`) \| `undefined` + +##### Returns + +`Promise`\<`ChatEvent` & `object`\> + +#### Call Signature + +> **wait**\<`K`\>(`event`, `timeout`): `Promise`\ + +Defined in: [src/api.ts:249](../src/api.ts#L249) + +Waits for specific event, with an optional predicate. +Returns `undefined` on timeout if specified. + +##### Type Parameters + +###### K + +`K` *extends* `Tag` + +##### Parameters + +###### event + +`K` + +###### timeout + +`number` + +##### Returns + +`Promise`\ + +#### Call Signature + +> **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\ + +Defined in: [src/api.ts:250](../src/api.ts#L250) + +Waits for specific event, with an optional predicate. +Returns `undefined` on timeout if specified. + +##### Type Parameters + +###### K + +`K` *extends* `Tag` + +##### Parameters + +###### event + +`K` + +###### predicate + +((`event`) => `boolean`) \| `undefined` + +###### timeout + +`number` + +##### Returns + +`Promise`\ + +*** + +### init() + +> `static` **init**(`db`, `confirm?`): `Promise`\<`ChatApi`\> + +Defined in: [src/api.ts:110](../src/api.ts#L110) + +Initializes the ChatApi. + +#### Parameters + +##### db + +[`DbConfig`](api.TypeAlias.DbConfig.md) + +Database configuration (sqlite or postgres). + +##### confirm? + +[`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) = `core.MigrationConfirmation.YesUp` + +Migration confirmation mode. + +#### Returns + +`Promise`\<`ChatApi`\> diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatCommandError.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatCommandError.md new file mode 100644 index 0000000000..5ec1d40540 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatCommandError.md @@ -0,0 +1,205 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [api](Namespace.api.md) / ChatCommandError + +# Class: ChatCommandError + +Defined in: [src/api.ts:5](../src/api.ts#L5) + +## Extends + +- `Error` + +## Constructors + +### Constructor + +> **new ChatCommandError**(`message`, `response`): `ChatCommandError` + +Defined in: [src/api.ts:6](../src/api.ts#L6) + +#### Parameters + +##### message + +`string` + +##### response + +`ChatResponse` + +#### Returns + +`ChatCommandError` + +#### Overrides + +`Error.constructor` + +## Properties + +### message + +> **message**: `string` + +Defined in: [src/api.ts:6](../src/api.ts#L6) + +#### Inherited from + +`Error.message` + +*** + +### name + +> **name**: `string` + +Defined in: [node\_modules/typescript/lib/lib.es5.d.ts:1076](../node_modules/typescript/lib/lib.es5.d.ts#L1076) + +#### Inherited from + +`Error.name` + +*** + +### response + +> **response**: `ChatResponse` + +Defined in: [src/api.ts:6](../src/api.ts#L6) + +*** + +### stack? + +> `optional` **stack?**: `string` + +Defined in: [node\_modules/typescript/lib/lib.es5.d.ts:1078](../node_modules/typescript/lib/lib.es5.d.ts#L1078) + +#### Inherited from + +`Error.stack` + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +Defined in: [node\_modules/@types/node/globals.d.ts:67](../node_modules/@types/node/globals.d.ts#L67) + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +`Error.stackTraceLimit` + +## Methods + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Defined in: [node\_modules/@types/node/globals.d.ts:51](../node_modules/@types/node/globals.d.ts#L51) + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +`Error.captureStackTrace` + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +Defined in: [node\_modules/@types/node/globals.d.ts:55](../node_modules/@types/node/globals.d.ts#L55) + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +`Error.prepareStackTrace` diff --git a/packages/simplex-chat-nodejs/docs/api.Enumeration.ConnReqType.md b/packages/simplex-chat-nodejs/docs/api.Enumeration.ConnReqType.md new file mode 100644 index 0000000000..dcabb85b67 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/api.Enumeration.ConnReqType.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [api](Namespace.api.md) / ConnReqType + +# Enumeration: ConnReqType + +Defined in: [src/api.ts:15](../src/api.ts#L15) + +Connection request types. + +## Enumeration Members + +### Contact + +> **Contact**: `"contact"` + +Defined in: [src/api.ts:17](../src/api.ts#L17) + +*** + +### Invitation + +> **Invitation**: `"invitation"` + +Defined in: [src/api.ts:16](../src/api.ts#L16) diff --git a/packages/simplex-chat-nodejs/docs/api.Interface.BotAddressSettings.md b/packages/simplex-chat-nodejs/docs/api.Interface.BotAddressSettings.md new file mode 100644 index 0000000000..23c5e35326 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/api.Interface.BotAddressSettings.md @@ -0,0 +1,60 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [api](Namespace.api.md) / BotAddressSettings + +# Interface: BotAddressSettings + +Defined in: [src/api.ts:23](../src/api.ts#L23) + +Bot address settings. + +## Properties + +### autoAccept? + +> `optional` **autoAccept?**: `boolean` + +Defined in: [src/api.ts:28](../src/api.ts#L28) + +Automatically accept contact requests. + +#### Default + +```ts +true +``` + +*** + +### businessAddress? + +> `optional` **businessAddress?**: `boolean` + +Defined in: [src/api.ts:41](../src/api.ts#L41) + +Business contact address. +For all requests business chats will be created where other participants can be added. + +#### Default + +```ts +false +``` + +*** + +### welcomeMessage? + +> `optional` **welcomeMessage?**: `string` \| `MsgContent` + +Defined in: [src/api.ts:34](../src/api.ts#L34) + +Optional welcome message to show before connection to the users. + +#### Default + +```ts +undefined (no welcome message) +``` diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md new file mode 100644 index 0000000000..7fe255327c --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md @@ -0,0 +1,64 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [api](Namespace.api.md) / DbConfig + +# Type Alias: DbConfig + +> **DbConfig** = \{ `encryptionKey?`: `string`; `filePrefix`: `string`; `type`: `"sqlite"`; \} \| \{ `connectionString`: `string`; `schemaPrefix?`: `string`; `type`: `"postgres"`; \} + +Defined in: [src/api.ts:65](../src/api.ts#L65) + +Database configuration. The native library is built against exactly one +backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this +type makes the caller state which one they are targeting so field names +can't lie about their meaning. + +## Union Members + +### Type Literal + +\{ `encryptionKey?`: `string`; `filePrefix`: `string`; `type`: `"sqlite"`; \} + +#### encryptionKey? + +> `optional` **encryptionKey?**: `string` + +Optional SQLCipher encryption key. Empty/omitted = unencrypted. + +#### filePrefix + +> **filePrefix**: `string` + +File prefix — two schema files are named `_chat.db` and `_agent.db`. + +#### type + +> **type**: `"sqlite"` + +SQLite backend (default). + +*** + +### Type Literal + +\{ `connectionString`: `string`; `schemaPrefix?`: `string`; `type`: `"postgres"`; \} + +#### connectionString + +> **connectionString**: `string` + +PostgreSQL connection string (e.g. `postgres://user:pass@host/db`). + +#### schemaPrefix? + +> `optional` **schemaPrefix?**: `string` + +Schema prefix used to namespace tables. Defaults to `"simplex_v1"` when omitted. + +#### type + +> **type**: `"postgres"` + +PostgreSQL backend (Linux x86_64 only, libpq5 required). diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md new file mode 100644 index 0000000000..bf40b3165c --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [api](Namespace.api.md) / EventSubscriberFunc + +# Type Alias: EventSubscriberFunc\ + +> **EventSubscriberFunc**\<`K`\> = (`event`) => `void` \| `Promise`\<`void`\> + +Defined in: [src/api.ts:50](../src/api.ts#L50) + +## Type Parameters + +### K + +`K` *extends* `CEvt.Tag` + +## Parameters + +### event + +`ChatEvent` & `object` + +## Returns + +`void` \| `Promise`\<`void`\> diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscribers.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscribers.md new file mode 100644 index 0000000000..3b63d11bd4 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscribers.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [api](Namespace.api.md) / EventSubscribers + +# Type Alias: EventSubscribers + +> **EventSubscribers** = `{ [K in CEvt.Tag]?: EventSubscriberFunc }` + +Defined in: [src/api.ts:52](../src/api.ts#L52) diff --git a/packages/simplex-chat-nodejs/docs/api.Variable.defaultBotAddressSettings.md b/packages/simplex-chat-nodejs/docs/api.Variable.defaultBotAddressSettings.md new file mode 100644 index 0000000000..f076ee0fad --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/api.Variable.defaultBotAddressSettings.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [api](Namespace.api.md) / defaultBotAddressSettings + +# Variable: defaultBotAddressSettings + +> `const` **defaultBotAddressSettings**: [`BotAddressSettings`](api.Interface.BotAddressSettings.md) + +Defined in: [src/api.ts:44](../src/api.ts#L44) diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md new file mode 100644 index 0000000000..3c33e6c7d4 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [bot](Namespace.bot.md) / run + +# Function: run() + +> **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> + +Defined in: [src/bot.ts:47](../src/bot.ts#L47) + +## Parameters + +### \_\_namedParameters + +[`BotConfig`](bot.Interface.BotConfig.md) + +## Returns + +`Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md new file mode 100644 index 0000000000..4624b1608b --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -0,0 +1,75 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [bot](Namespace.bot.md) / BotConfig + +# Interface: BotConfig + +Defined in: [src/bot.ts:35](../src/bot.ts#L35) + +## Properties + +### dbOpts + +> **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md) + +Defined in: [src/bot.ts:37](../src/bot.ts#L37) + +*** + +### events? + +> `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md) + +Defined in: [src/bot.ts:44](../src/bot.ts#L44) + +*** + +### onCommands? + +> `optional` **onCommands?**: `object` + +Defined in: [src/bot.ts:41](../src/bot.ts#L41) + +#### Index Signature + +\[`key`: `string`\]: ((`chatItem`, `command`) => `void` \| `Promise`\<`void`\>) \| `undefined` + +*** + +### onMessage? + +> `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\> + +Defined in: [src/bot.ts:39](../src/bot.ts#L39) + +#### Parameters + +##### chatItem + +`AChatItem` + +##### content + +`MsgContent` + +#### Returns + +`void` \| `Promise`\<`void`\> + +*** + +### options + +> **options**: [`BotOptions`](bot.Interface.BotOptions.md) + +Defined in: [src/bot.ts:38](../src/bot.ts#L38) + +*** + +### profile + +> **profile**: `Profile` + +Defined in: [src/bot.ts:36](../src/bot.ts#L36) diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md new file mode 100644 index 0000000000..eee56b879a --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md @@ -0,0 +1,81 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [bot](Namespace.bot.md) / BotOptions + +# Interface: BotOptions + +Defined in: [src/bot.ts:11](../src/bot.ts#L11) + +## Properties + +### addressSettings? + +> `optional` **addressSettings?**: [`BotAddressSettings`](api.Interface.BotAddressSettings.md) + +Defined in: [src/bot.ts:15](../src/bot.ts#L15) + +*** + +### allowFiles? + +> `optional` **allowFiles?**: `boolean` + +Defined in: [src/bot.ts:16](../src/bot.ts#L16) + +*** + +### commands? + +> `optional` **commands?**: `ChatBotCommand`[] + +Defined in: [src/bot.ts:17](../src/bot.ts#L17) + +*** + +### createAddress? + +> `optional` **createAddress?**: `boolean` + +Defined in: [src/bot.ts:12](../src/bot.ts#L12) + +*** + +### logContacts? + +> `optional` **logContacts?**: `boolean` + +Defined in: [src/bot.ts:19](../src/bot.ts#L19) + +*** + +### logNetwork? + +> `optional` **logNetwork?**: `boolean` + +Defined in: [src/bot.ts:20](../src/bot.ts#L20) + +*** + +### updateAddress? + +> `optional` **updateAddress?**: `boolean` + +Defined in: [src/bot.ts:13](../src/bot.ts#L13) + +*** + +### updateProfile? + +> `optional` **updateProfile?**: `boolean` + +Defined in: [src/bot.ts:14](../src/bot.ts#L14) + +*** + +### useBotProfile? + +> `optional` **useBotProfile?**: `boolean` + +Defined in: [src/bot.ts:18](../src/bot.ts#L18) diff --git a/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md b/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md new file mode 100644 index 0000000000..b035f41355 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md @@ -0,0 +1,17 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [bot](Namespace.bot.md) / BotDbOpts + +# Type Alias: BotDbOpts + +> **BotDbOpts** = [`DbConfig`](api.TypeAlias.DbConfig.md) & `object` + +Defined in: [src/bot.ts:7](../src/bot.ts#L7) + +## Type Declaration + +### confirmMigrations? + +> `optional` **confirmMigrations?**: [`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md new file mode 100644 index 0000000000..5bd0722f0c --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md @@ -0,0 +1,205 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / ChatAPIError + +# Class: ChatAPIError + +Defined in: [src/core.ts:92](../src/core.ts#L92) + +## Extends + +- `Error` + +## Constructors + +### Constructor + +> **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError` + +Defined in: [src/core.ts:93](../src/core.ts#L93) + +#### Parameters + +##### message + +`string` + +##### chatError? + +`ChatError` \| `undefined` + +#### Returns + +`ChatAPIError` + +#### Overrides + +`Error.constructor` + +## Properties + +### chatError + +> **chatError**: `ChatError` \| `undefined` = `undefined` + +Defined in: [src/core.ts:93](../src/core.ts#L93) + +*** + +### message + +> **message**: `string` + +Defined in: [src/core.ts:93](../src/core.ts#L93) + +#### Inherited from + +`Error.message` + +*** + +### name + +> **name**: `string` + +Defined in: [node\_modules/typescript/lib/lib.es5.d.ts:1076](../node_modules/typescript/lib/lib.es5.d.ts#L1076) + +#### Inherited from + +`Error.name` + +*** + +### stack? + +> `optional` **stack?**: `string` + +Defined in: [node\_modules/typescript/lib/lib.es5.d.ts:1078](../node_modules/typescript/lib/lib.es5.d.ts#L1078) + +#### Inherited from + +`Error.stack` + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +Defined in: [node\_modules/@types/node/globals.d.ts:67](../node_modules/@types/node/globals.d.ts#L67) + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +`Error.stackTraceLimit` + +## Methods + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Defined in: [node\_modules/@types/node/globals.d.ts:51](../node_modules/@types/node/globals.d.ts#L51) + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +`Error.captureStackTrace` + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +Defined in: [node\_modules/@types/node/globals.d.ts:55](../node_modules/@types/node/globals.d.ts#L55) + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +`Error.prepareStackTrace` diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md new file mode 100644 index 0000000000..0feceae4fd --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md @@ -0,0 +1,205 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / ChatInitError + +# Class: ChatInitError + +Defined in: [src/core.ts:116](../src/core.ts#L116) + +## Extends + +- `Error` + +## Constructors + +### Constructor + +> **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError` + +Defined in: [src/core.ts:117](../src/core.ts#L117) + +#### Parameters + +##### message + +`string` + +##### dbMigrationError + +[`DBMigrationError`](core.TypeAlias.DBMigrationError.md) + +#### Returns + +`ChatInitError` + +#### Overrides + +`Error.constructor` + +## Properties + +### dbMigrationError + +> **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md) + +Defined in: [src/core.ts:117](../src/core.ts#L117) + +*** + +### message + +> **message**: `string` + +Defined in: [src/core.ts:117](../src/core.ts#L117) + +#### Inherited from + +`Error.message` + +*** + +### name + +> **name**: `string` + +Defined in: [node\_modules/typescript/lib/lib.es5.d.ts:1076](../node_modules/typescript/lib/lib.es5.d.ts#L1076) + +#### Inherited from + +`Error.name` + +*** + +### stack? + +> `optional` **stack?**: `string` + +Defined in: [node\_modules/typescript/lib/lib.es5.d.ts:1078](../node_modules/typescript/lib/lib.es5.d.ts#L1078) + +#### Inherited from + +`Error.stack` + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +Defined in: [node\_modules/@types/node/globals.d.ts:67](../node_modules/@types/node/globals.d.ts#L67) + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +`Error.stackTraceLimit` + +## Methods + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Defined in: [node\_modules/@types/node/globals.d.ts:51](../node_modules/@types/node/globals.d.ts#L51) + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +`Error.captureStackTrace` + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +Defined in: [node\_modules/@types/node/globals.d.ts:55](../node_modules/@types/node/globals.d.ts#L55) + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +`Error.prepareStackTrace` diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md new file mode 100644 index 0000000000..02cf84b763 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md @@ -0,0 +1,41 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / ErrorMigration + +# Interface: ErrorMigration + +Defined in: [src/core.ts:144](../src/core.ts#L144) + +## Extends + +- `Interface` + +## Properties + +### dbFile + +> **dbFile**: `string` + +Defined in: [src/core.ts:146](../src/core.ts#L146) + +*** + +### migrationError + +> **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md) + +Defined in: [src/core.ts:147](../src/core.ts#L147) + +*** + +### type + +> **type**: `"errorMigration"` + +Defined in: [src/core.ts:145](../src/core.ts#L145) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md new file mode 100644 index 0000000000..18e2429081 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md @@ -0,0 +1,33 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / ErrorNotADatabase + +# Interface: ErrorNotADatabase + +Defined in: [src/core.ts:139](../src/core.ts#L139) + +## Extends + +- `Interface` + +## Properties + +### dbFile + +> **dbFile**: `string` + +Defined in: [src/core.ts:141](../src/core.ts#L141) + +*** + +### type + +> **type**: `"errorNotADatabase"` + +Defined in: [src/core.ts:140](../src/core.ts#L140) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md new file mode 100644 index 0000000000..4d85b04197 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md @@ -0,0 +1,41 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / ErrorSQL + +# Interface: ErrorSQL + +Defined in: [src/core.ts:150](../src/core.ts#L150) + +## Extends + +- `Interface` + +## Properties + +### dbFile + +> **dbFile**: `string` + +Defined in: [src/core.ts:152](../src/core.ts#L152) + +*** + +### migrationSQLError + +> **migrationSQLError**: `string` + +Defined in: [src/core.ts:153](../src/core.ts#L153) + +*** + +### type + +> **type**: `"errorSQL"` + +Defined in: [src/core.ts:151](../src/core.ts#L151) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md new file mode 100644 index 0000000000..34dea63aed --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md @@ -0,0 +1,25 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / InvalidConfirmation + +# Interface: InvalidConfirmation + +Defined in: [src/core.ts:135](../src/core.ts#L135) + +## Extends + +- `Interface` + +## Properties + +### type + +> **type**: `"invalidConfirmation"` + +Defined in: [src/core.ts:136](../src/core.ts#L136) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md new file mode 100644 index 0000000000..a3ef341601 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / Tag + +# Type Alias: Tag + +> **Tag** = `"invalidConfirmation"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` + +Defined in: [src/core.ts:129](../src/core.ts#L129) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md new file mode 100644 index 0000000000..7dfd4991bf --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md @@ -0,0 +1,43 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / MigrationConfirmation + +# Enumeration: MigrationConfirmation + +Defined in: [src/core.ts:101](../src/core.ts#L101) + +Migration confirmation mode + +## Enumeration Members + +### Console + +> **Console**: `"console"` + +Defined in: [src/core.ts:104](../src/core.ts#L104) + +*** + +### Error + +> **Error**: `"error"` + +Defined in: [src/core.ts:105](../src/core.ts#L105) + +*** + +### YesUp + +> **YesUp**: `"yesUp"` + +Defined in: [src/core.ts:102](../src/core.ts#L102) + +*** + +### YesUpDown + +> **YesUpDown**: `"yesUpDown"` + +Defined in: [src/core.ts:103](../src/core.ts#L103) diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md new file mode 100644 index 0000000000..deeb3213fd --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md @@ -0,0 +1,23 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatCloseStore + +# Function: chatCloseStore() + +> **chatCloseStore**(`ctrl`): `Promise`\<`void`\> + +Defined in: [src/core.ts:17](../src/core.ts#L17) + +Close chat store + +## Parameters + +### ctrl + +`bigint` + +## Returns + +`Promise`\<`void`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md new file mode 100644 index 0000000000..434aeeaae8 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md @@ -0,0 +1,31 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatDecryptFile + +# Function: chatDecryptFile() + +> **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\> + +Defined in: [src/core.ts:73](../src/core.ts#L73) + +Decrypt file + +## Parameters + +### fromPath + +`string` + +### \_\_namedParameters + +[`CryptoArgs`](core.Interface.CryptoArgs.md) + +### toPath + +`string` + +## Returns + +`Promise`\<`void`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md new file mode 100644 index 0000000000..6aa0ad2923 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md @@ -0,0 +1,31 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatEncryptFile + +# Function: chatEncryptFile() + +> **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> + +Defined in: [src/core.ts:65](../src/core.ts#L65) + +Encrypt file + +## Parameters + +### ctrl + +`bigint` + +### fromPath + +`string` + +### toPath + +`string` + +## Returns + +`Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md new file mode 100644 index 0000000000..9116026f56 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md @@ -0,0 +1,31 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatMigrateInit + +# Function: chatMigrateInit() + +> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`): `Promise`\<`bigint`\> + +Defined in: [src/core.ts:7](../src/core.ts#L7) + +Initialize chat controller + +## Parameters + +### dbPath + +`string` + +### dbKey + +`string` + +### confirm + +[`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) + +## Returns + +`Promise`\<`bigint`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md new file mode 100644 index 0000000000..27de43e63c --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatReadFile + +# Function: chatReadFile() + +> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`ArrayBuffer`\> + +Defined in: [src/core.ts:58](../src/core.ts#L58) + +Read buffer from encrypted file + +## Parameters + +### path + +`string` + +### \_\_namedParameters + +[`CryptoArgs`](core.Interface.CryptoArgs.md) + +## Returns + +`Promise`\<`ArrayBuffer`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md new file mode 100644 index 0000000000..9bf44d6523 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatRecvMsgWait + +# Function: chatRecvMsgWait() + +> **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\> + +Defined in: [src/core.ts:37](../src/core.ts#L37) + +Receive chat event + +## Parameters + +### ctrl + +`bigint` + +### wait + +`number` + +## Returns + +`Promise`\<`ChatEvent` \| `undefined`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md new file mode 100644 index 0000000000..2dfcba45b4 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatSendCmd + +# Function: chatSendCmd() + +> **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\> + +Defined in: [src/core.ts:25](../src/core.ts#L25) + +Send chat command as string + +## Parameters + +### ctrl + +`bigint` + +### cmd + +`string` + +## Returns + +`Promise`\<`ChatResponse`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md new file mode 100644 index 0000000000..3b1d770fbb --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md @@ -0,0 +1,31 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatWriteFile + +# Function: chatWriteFile() + +> **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> + +Defined in: [src/core.ts:50](../src/core.ts#L50) + +Write buffer to encrypted file + +## Parameters + +### ctrl + +`bigint` + +### path + +`string` + +### buffer + +`ArrayBuffer` + +## Returns + +`Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md new file mode 100644 index 0000000000..8d18997ec4 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md @@ -0,0 +1,31 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / APIResult + +# Interface: APIResult\ + +Defined in: [src/core.ts:87](../src/core.ts#L87) + +## Type Parameters + +### R + +`R` + +## Properties + +### error? + +> `optional` **error?**: `ChatError` + +Defined in: [src/core.ts:89](../src/core.ts#L89) + +*** + +### result? + +> `optional` **result?**: `R` + +Defined in: [src/core.ts:88](../src/core.ts#L88) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md new file mode 100644 index 0000000000..eddcb0bc5a --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / CryptoArgs + +# Interface: CryptoArgs + +Defined in: [src/core.ts:111](../src/core.ts#L111) + +File encryption key and nonce + +## Properties + +### fileKey + +> **fileKey**: `string` + +Defined in: [src/core.ts:112](../src/core.ts#L112) + +*** + +### fileNonce + +> **fileNonce**: `string` + +Defined in: [src/core.ts:113](../src/core.ts#L113) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md new file mode 100644 index 0000000000..32f6c267aa --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md @@ -0,0 +1,25 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / UpMigration + +# Interface: UpMigration + +Defined in: [src/core.ts:185](../src/core.ts#L185) + +## Properties + +### upName + +> **upName**: `string` + +Defined in: [src/core.ts:186](../src/core.ts#L186) + +*** + +### withDown + +> **withDown**: `boolean` + +Defined in: [src/core.ts:187](../src/core.ts#L187) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md new file mode 100644 index 0000000000..8dab81a3a3 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md @@ -0,0 +1,33 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [MTRError](core.Namespace.MTRError.md) / MTREDifferent + +# Interface: MTREDifferent + +Defined in: [src/core.ts:206](../src/core.ts#L206) + +## Extends + +- `Interface` + +## Properties + +### downMigrations + +> **downMigrations**: `string`[] + +Defined in: [src/core.ts:208](../src/core.ts#L208) + +*** + +### type + +> **type**: `"different"` + +Defined in: [src/core.ts:207](../src/core.ts#L207) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md new file mode 100644 index 0000000000..1de634e40e --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md @@ -0,0 +1,33 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [MTRError](core.Namespace.MTRError.md) / MTRENoDown + +# Interface: MTRENoDown + +Defined in: [src/core.ts:201](../src/core.ts#L201) + +## Extends + +- `Interface` + +## Properties + +### type + +> **type**: `"noDown"` + +Defined in: [src/core.ts:202](../src/core.ts#L202) + +#### Overrides + +`Interface.type` + +*** + +### upMigrations + +> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md) + +Defined in: [src/core.ts:203](../src/core.ts#L203) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md new file mode 100644 index 0000000000..768baa0068 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [MTRError](core.Namespace.MTRError.md) / Tag + +# Type Alias: Tag + +> **Tag** = `"noDown"` \| `"different"` + +Defined in: [src/core.ts:195](../src/core.ts#L195) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md new file mode 100644 index 0000000000..a2742cbff2 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md @@ -0,0 +1,33 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [MigrationError](core.Namespace.MigrationError.md) / MEDowngrade + +# Interface: MEDowngrade + +Defined in: [src/core.ts:174](../src/core.ts#L174) + +## Extends + +- `Interface` + +## Properties + +### downMigrations + +> **downMigrations**: `string`[] + +Defined in: [src/core.ts:176](../src/core.ts#L176) + +*** + +### type + +> **type**: `"downgrade"` + +Defined in: [src/core.ts:175](../src/core.ts#L175) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md new file mode 100644 index 0000000000..08fe7d56e3 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md @@ -0,0 +1,33 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [MigrationError](core.Namespace.MigrationError.md) / MEUpgrade + +# Interface: MEUpgrade + +Defined in: [src/core.ts:169](../src/core.ts#L169) + +## Extends + +- `Interface` + +## Properties + +### type + +> **type**: `"upgrade"` + +Defined in: [src/core.ts:170](../src/core.ts#L170) + +#### Overrides + +`Interface.type` + +*** + +### upMigrations + +> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md) + +Defined in: [src/core.ts:171](../src/core.ts#L171) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md new file mode 100644 index 0000000000..cd811a5747 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md @@ -0,0 +1,33 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [MigrationError](core.Namespace.MigrationError.md) / MigrationError + +# Interface: MigrationError + +Defined in: [src/core.ts:179](../src/core.ts#L179) + +## Extends + +- `Interface` + +## Properties + +### mtrError + +> **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md) + +Defined in: [src/core.ts:181](../src/core.ts#L181) + +*** + +### type + +> **type**: `"migrationError"` + +Defined in: [src/core.ts:180](../src/core.ts#L180) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md new file mode 100644 index 0000000000..5ef6e70b08 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [MigrationError](core.Namespace.MigrationError.md) / Tag + +# Type Alias: Tag + +> **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"` + +Defined in: [src/core.ts:163](../src/core.ts#L163) diff --git a/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md new file mode 100644 index 0000000000..95ddfa5b24 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md @@ -0,0 +1,18 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / DBMigrationError + +# DBMigrationError + +## Interfaces + +- [ErrorMigration](core.DBMigrationError.Interface.ErrorMigration.md) +- [ErrorNotADatabase](core.DBMigrationError.Interface.ErrorNotADatabase.md) +- [ErrorSQL](core.DBMigrationError.Interface.ErrorSQL.md) +- [InvalidConfirmation](core.DBMigrationError.Interface.InvalidConfirmation.md) + +## Type Aliases + +- [Tag](core.DBMigrationError.TypeAlias.Tag.md) diff --git a/packages/simplex-chat-nodejs/docs/core.Namespace.MTRError.md b/packages/simplex-chat-nodejs/docs/core.Namespace.MTRError.md new file mode 100644 index 0000000000..70baca917a --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Namespace.MTRError.md @@ -0,0 +1,16 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / MTRError + +# MTRError + +## Interfaces + +- [MTREDifferent](core.MTRError.Interface.MTREDifferent.md) +- [MTRENoDown](core.MTRError.Interface.MTRENoDown.md) + +## Type Aliases + +- [Tag](core.MTRError.TypeAlias.Tag.md) diff --git a/packages/simplex-chat-nodejs/docs/core.Namespace.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.Namespace.MigrationError.md new file mode 100644 index 0000000000..cc66142dbc --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Namespace.MigrationError.md @@ -0,0 +1,17 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / MigrationError + +# MigrationError + +## Interfaces + +- [MEDowngrade](core.MigrationError.Interface.MEDowngrade.md) +- [MEUpgrade](core.MigrationError.Interface.MEUpgrade.md) +- [MigrationError](core.MigrationError.Interface.MigrationError.md) + +## Type Aliases + +- [Tag](core.MigrationError.TypeAlias.Tag.md) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md new file mode 100644 index 0000000000..6473b3ef60 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / DBMigrationError + +# Type Alias: DBMigrationError + +> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) + +Defined in: [src/core.ts:122](../src/core.ts#L122) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md new file mode 100644 index 0000000000..11aa5b7c24 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / MTRError + +# Type Alias: MTRError + +> **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md) + +Defined in: [src/core.ts:190](../src/core.ts#L190) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md new file mode 100644 index 0000000000..c15b679769 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / MigrationError + +# Type Alias: MigrationError + +> **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md) + +Defined in: [src/core.ts:157](../src/core.ts#L157) diff --git a/packages/simplex-chat-nodejs/docs/util.Function.botAddressSettings.md b/packages/simplex-chat-nodejs/docs/util.Function.botAddressSettings.md new file mode 100644 index 0000000000..7fa5d81b7a --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.botAddressSettings.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / botAddressSettings + +# Function: botAddressSettings() + +> **botAddressSettings**(`__namedParameters`): [`BotAddressSettings`](api.Interface.BotAddressSettings.md) + +Defined in: [src/util.ts:48](../src/util.ts#L48) + +## Parameters + +### \_\_namedParameters + +`UserContactLink` + +## Returns + +[`BotAddressSettings`](api.Interface.BotAddressSettings.md) diff --git a/packages/simplex-chat-nodejs/docs/util.Function.chatInfoName.md b/packages/simplex-chat-nodejs/docs/util.Function.chatInfoName.md new file mode 100644 index 0000000000..dd68403c40 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.chatInfoName.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / chatInfoName + +# Function: chatInfoName() + +> **chatInfoName**(`cInfo`): `string` + +Defined in: [src/util.ts:18](../src/util.ts#L18) + +## Parameters + +### cInfo + +`ChatInfo` + +## Returns + +`string` diff --git a/packages/simplex-chat-nodejs/docs/util.Function.chatInfoRef.md b/packages/simplex-chat-nodejs/docs/util.Function.chatInfoRef.md new file mode 100644 index 0000000000..dccb2a9d29 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.chatInfoRef.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / chatInfoRef + +# Function: chatInfoRef() + +> **chatInfoRef**(`cInfo`): `ChatRef` \| `undefined` + +Defined in: [src/util.ts:4](../src/util.ts#L4) + +## Parameters + +### cInfo + +`ChatInfo` + +## Returns + +`ChatRef` \| `undefined` diff --git a/packages/simplex-chat-nodejs/docs/util.Function.ciBotCommand.md b/packages/simplex-chat-nodejs/docs/util.Function.ciBotCommand.md new file mode 100644 index 0000000000..bc7a66f163 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.ciBotCommand.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / ciBotCommand + +# Function: ciBotCommand() + +> **ciBotCommand**(`chatItem`): [`BotCommand`](util.Interface.BotCommand.md) \| `undefined` + +Defined in: [src/util.ts:78](../src/util.ts#L78) + +## Parameters + +### chatItem + +`ChatItem` + +## Returns + +[`BotCommand`](util.Interface.BotCommand.md) \| `undefined` diff --git a/packages/simplex-chat-nodejs/docs/util.Function.ciContentText.md b/packages/simplex-chat-nodejs/docs/util.Function.ciContentText.md new file mode 100644 index 0000000000..7ab0bc540c --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.ciContentText.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / ciContentText + +# Function: ciContentText() + +> **ciContentText**(`__namedParameters`): `string` \| `undefined` + +Defined in: [src/util.ts:64](../src/util.ts#L64) + +## Parameters + +### \_\_namedParameters + +`ChatItem` + +## Returns + +`string` \| `undefined` diff --git a/packages/simplex-chat-nodejs/docs/util.Function.contactAddressStr.md b/packages/simplex-chat-nodejs/docs/util.Function.contactAddressStr.md new file mode 100644 index 0000000000..3f6c7b9562 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.contactAddressStr.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / contactAddressStr + +# Function: contactAddressStr() + +> **contactAddressStr**(`link`): `string` + +Defined in: [src/util.ts:44](../src/util.ts#L44) + +## Parameters + +### link + +`CreatedConnLink` + +## Returns + +`string` diff --git a/packages/simplex-chat-nodejs/docs/util.Function.fromLocalProfile.md b/packages/simplex-chat-nodejs/docs/util.Function.fromLocalProfile.md new file mode 100644 index 0000000000..c32081b6cf --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.fromLocalProfile.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / fromLocalProfile + +# Function: fromLocalProfile() + +> **fromLocalProfile**(`__namedParameters`): `Profile` + +Defined in: [src/util.ts:56](../src/util.ts#L56) + +## Parameters + +### \_\_namedParameters + +`LocalProfile` + +## Returns + +`Profile` diff --git a/packages/simplex-chat-nodejs/docs/util.Function.reactionText.md b/packages/simplex-chat-nodejs/docs/util.Function.reactionText.md new file mode 100644 index 0000000000..896dc74a12 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.reactionText.md @@ -0,0 +1,21 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / reactionText + +# Function: reactionText() + +> **reactionText**(`reaction`): `string` + +Defined in: [src/util.ts:89](../src/util.ts#L89) + +## Parameters + +### reaction + +`ACIReaction` + +## Returns + +`string` diff --git a/packages/simplex-chat-nodejs/docs/util.Function.senderName.md b/packages/simplex-chat-nodejs/docs/util.Function.senderName.md new file mode 100644 index 0000000000..6bacad97f6 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Function.senderName.md @@ -0,0 +1,25 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / senderName + +# Function: senderName() + +> **senderName**(`cInfo`, `chatDir`): `string` + +Defined in: [src/util.ts:37](../src/util.ts#L37) + +## Parameters + +### cInfo + +`ChatInfo` + +### chatDir + +`CIDirection` + +## Returns + +`string` diff --git a/packages/simplex-chat-nodejs/docs/util.Interface.BotCommand.md b/packages/simplex-chat-nodejs/docs/util.Interface.BotCommand.md new file mode 100644 index 0000000000..21c3ad72ba --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/util.Interface.BotCommand.md @@ -0,0 +1,25 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [util](Namespace.util.md) / BotCommand + +# Interface: BotCommand + +Defined in: [src/util.ts:72](../src/util.ts#L72) + +## Properties + +### keyword + +> **keyword**: `string` + +Defined in: [src/util.ts:73](../src/util.ts#L73) + +*** + +### params + +> **params**: `string` + +Defined in: [src/util.ts:74](../src/util.ts#L74) diff --git a/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js b/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js new file mode 100644 index 0000000000..8899e9bf15 --- /dev/null +++ b/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js @@ -0,0 +1,17 @@ +(async () => { + const {bot} = await import("../dist/index.js") + const [chat, _user, _address] = await bot.run({ + profile: {displayName: "Squaring bot example", fullName: ""}, + dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, + options: { + addressSettings: {welcomeMessage: "Send a number, I will square it."}, + }, + onMessage: async (ci, content) => { + const n = +content.text + const reply = typeof n === "number" && !isNaN(n) + ? `${n} * ${n} = ${n * n}` + : `this is not a number` + await chat.apiSendTextReply(ci, reply) + } + }) +})() diff --git a/packages/simplex-chat-nodejs/examples/squaring-bot.ts b/packages/simplex-chat-nodejs/examples/squaring-bot.ts new file mode 100644 index 0000000000..dbb58d90dc --- /dev/null +++ b/packages/simplex-chat-nodejs/examples/squaring-bot.ts @@ -0,0 +1,42 @@ +import {T} from "@simplex-chat/types" +import {bot, util} from "../dist" + +(async () => { + const welcomeMessage = "Hello! I am a simple squaring bot.\n\nIf you send me a number, I will calculate its square." + const [chat, _user, _address] = await bot.run({ + profile: {displayName: "Squaring bot example", fullName: ""}, + dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, + options: { + addressSettings: {autoAccept: true, welcomeMessage, businessAddress: false}, + commands: [ // commands to show in client UI + {type: "command", keyword: "help", label: "Send welcome message"}, + {type: "command", keyword: "info", label: "More information (not implemented)"} + ], + logContacts: true, + logNetwork: false + }, + onMessage: async (ci, content) => { + const n = +content.text + const reply = typeof n === "number" && !isNaN(n) + ? `${n} * ${n} = ${n * n}` + : `this is not a number` + await chat.apiSendTextReply(ci, reply) + }, + onCommands: { // command handlers can be different from commands to be shown in client UI + "help": async (ci: T.AChatItem, _cmd: util.BotCommand) => { + await chat.apiSendTextMessage(ci.chatInfo, welcomeMessage) + }, + // fallback handler that will be called for all other commands + "": async (ci: T.AChatItem, _cmd: util.BotCommand) => { + await chat.apiSendTextReply(ci, "This command is not supported") + } + }, + // If you use `onMessage` and subscribe to "newChatItems" event, exclude content messages from processing + // If you use `onCommands` and subscribe to "newChatItems" event, exclude commands from processing + events: { + "chatItemReaction": ({added, reaction}) => { + console.log(`${util.senderName(reaction.chatInfo, reaction.chatReaction.chatDir)} ${added ? "added" : "removed"} reaction ${util.reactionText(reaction)}`) + } + }, + }) +})() diff --git a/packages/simplex-chat-nodejs/jest.config.js b/packages/simplex-chat-nodejs/jest.config.js new file mode 100644 index 0000000000..18c5ddf0e5 --- /dev/null +++ b/packages/simplex-chat-nodejs/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + preset: "ts-jest", + maxWorkers: 1, + testEnvironment: "node", + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: 'tests/tsconfig.json' + }] + } +} diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json new file mode 100644 index 0000000000..5166283e75 --- /dev/null +++ b/packages/simplex-chat-nodejs/package.json @@ -0,0 +1,58 @@ +{ + "name": "simplex-chat", + "version": "6.5.2", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "src", + "cpp", + "dist", + "binding.gyp", + "tsconfig.json", + "docs", + "examples" + ], + "scripts": { + "preinstall": "node src/download-libs.js", + "install": "node-gyp configure; node-gyp rebuild --release", + "install-tools": "npm install -g node-gyp", + "configure": "node-gyp configure; mkdir libs 2> /dev/null | true", + "build": "node-gyp rebuild && tsc && cp ./src/simplex.* ./dist", + "run": "node src/index.js", + "build-run": "node-gyp build && node src/index.js", + "test": "jest", + "docs": "typedoc" + }, + "dependencies": { + "@simplex-chat/types": "^0.7.0", + "extract-zip": "^2.0.1", + "fast-deep-equal": "^3.1.3", + "node-addon-api": "^8.5.0" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^25.0.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", + "typedoc": "^0.28.15", + "typedoc-plugin-markdown": "^4.9.0", + "typescript": "^5.9.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/simplex-chat/simplex-chat.git" + }, + "keywords": [ + "messenger", + "chat", + "privacy", + "security" + ], + "author": "SimpleX Chat", + "license": "AGPL-3.0", + "bugs": { + "url": "https://github.com/simplex-chat/simplex-chat/issues" + }, + "homepage": "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-nodejs#readme", + "description": "SimpleX Chat Node.js library for chat bots" +} diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts new file mode 100644 index 0000000000..0d3339df9a --- /dev/null +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -0,0 +1,958 @@ +import {CC, CEvt, ChatEvent, ChatResponse, T} from "@simplex-chat/types" +import * as core from "./core" +import * as util from "./util" + +export class ChatCommandError extends Error { + constructor(public message: string, public response: ChatResponse) { + super(message) + } +} + +/** + * Connection request types. + * @enum {string} + */ +export enum ConnReqType { + Invitation = "invitation", + Contact = "contact", +} + +/** + * Bot address settings. + */ +export interface BotAddressSettings { + /** + * Automatically accept contact requests. + * @default true + */ + autoAccept?: boolean + + /** + * Optional welcome message to show before connection to the users. + * @default undefined (no welcome message) + */ + welcomeMessage?: T.MsgContent | string | undefined + + /** + * Business contact address. + * For all requests business chats will be created where other participants can be added. + * @default false + */ + businessAddress?: boolean +} + +export const defaultBotAddressSettings: BotAddressSettings = { + autoAccept: true, + welcomeMessage: undefined, + businessAddress: false +} + +export type EventSubscriberFunc = (event: ChatEvent & {type: K}) => void | Promise + +export type EventSubscribers = {[K in CEvt.Tag]?: EventSubscriberFunc} + +interface EventSubscriber { + subscriber: EventSubscriberFunc + once: boolean +} + +/** + * Database configuration. The native library is built against exactly one + * backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this + * type makes the caller state which one they are targeting so field names + * can't lie about their meaning. + */ +export type DbConfig = + | { + /** SQLite backend (default). */ + type: "sqlite" + /** File prefix — two schema files are named `_chat.db` and `_agent.db`. */ + filePrefix: string + /** Optional SQLCipher encryption key. Empty/omitted = unencrypted. */ + encryptionKey?: string + } + | { + /** PostgreSQL backend (Linux x86_64 only, libpq5 required). */ + type: "postgres" + /** Schema prefix used to namespace tables. Defaults to `"simplex_v1"` when omitted. */ + schemaPrefix?: string + /** PostgreSQL connection string (e.g. `postgres://user:pass@host/db`). */ + connectionString: string + } + +function dbConfigToMigrateArgs(db: DbConfig): [string, string] { + switch (db.type) { + case "sqlite": + return [db.filePrefix, db.encryptionKey ?? ""] + case "postgres": + return [db.schemaPrefix ?? "", db.connectionString] + default: + throw new Error(`Invalid DbConfig: ${JSON.stringify(db satisfies never)}`) + } +} + +/** + * Main API class for interacting with the chat core library. + */ +export class ChatApi { + private receiveEvents = false + private eventsLoop: Promise | undefined = undefined + private subscribers: {[K in CEvt.Tag]?: EventSubscriber[]} = {} + private receivers: EventSubscriberFunc[] = [] + + private constructor(protected ctrl_: bigint | undefined) {} + + /** + * Initializes the ChatApi. + * @param {DbConfig} db - Database configuration (sqlite or postgres). + * @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode. + */ + static async init( + db: DbConfig, + confirm = core.MigrationConfirmation.YesUp + ): Promise { + const [path, key] = dbConfigToMigrateArgs(db) + const ctrl = await core.chatMigrateInit(path, key, confirm) + return new ChatApi(ctrl) + } + + /** + * Start chat controller. Must be called with the existing user profile. + */ + async startChat(): Promise { + this.receiveEvents = true + this.eventsLoop = this.runEventsLoop() + const r = await this.sendChatCmd(CC.StartChat.cmdString({mainApp: true, enableSndFiles: true})) + if (r.type !== "chatStarted" && r.type !== "chatRunning") { + throw new ChatCommandError("error starting chat", r) + } + } + + /** + * Stop chat controller. + * Must be called before closing the database. + * Usually doesn't need to be called in chat bots. + */ + async stopChat(): Promise { + const r = await this.sendChatCmd("/_stop") + if (r.type !== "chatStopped") throw new ChatCommandError("error starting chat", r) + this.receiveEvents = false + if (this.eventsLoop) await this.eventsLoop + this.eventsLoop = undefined + } + + /** + * Close chat database. + * Usually doesn't need to be called in chat bots. + */ + async close(): Promise { + this.receiveEvents = false + if (this.eventsLoop) await this.eventsLoop + this.eventsLoop = undefined + await core.chatCloseStore(this.ctrl) + this.ctrl_ = undefined + } + + private async runEventsLoop(): Promise { + while (this.receiveEvents) { + try { + const event = await this.recvChatEvent() + if (!event) continue + const subs = this.subscribers[event.type] + if (subs) { + for (const {subscriber, once} of [...subs]) { + try { + const p = (subscriber as EventSubscriberFunc)(event) + if (p instanceof Promise) await p + } catch(e) { + console.log(`${event.type} event processing error`, e) + } + if (once) this.off(event.type, subscriber as EventSubscriberFunc) + } + } + for (const r of [...this.receivers]) { + try { + const p = r(event) + if (p instanceof Promise) await p + } catch(e) { + console.log(`${event.type} event processing error`, e) + } + } + } catch(err) { + const e = err as core.ChatAPIError + if ("chatError" in e) { + console.log("Chat error", e.chatError) + } else { + console.log("Invalid event", e) + } + } + } + } + + /** + * Subscribe multiple event handlers at once. + * @param subscribers - An object mapping event types (CEvt.Tag) to their subscriber functions. + * @throws {Error} If the same function is subscribed to event. + */ + on(subscribers: EventSubscribers): void + + /** + * Subscribe a handler to a specific event. + * @param {CEvt.Tag} event - The event type to subscribe to. + * @param subscriber - The subscriber function for the event. + * @throws {Error} If the same function is subscribed to event. + */ + on(event: K, subscriber: EventSubscriberFunc): void + on(events: K | EventSubscribers, subscriber?: EventSubscriberFunc): void { + if (typeof events === "string" && subscriber) { + this.on_(events, subscriber) + } else { + const eventEntries = Object.entries(events) as [CEvt.Tag, EventSubscriberFunc | undefined][] + for (const [event, subscriber] of eventEntries) { + if (subscriber) this.on_(event, subscriber) + } + } + } + + private on_(event: K, subscriber: EventSubscriberFunc, once: boolean = false): void { + const subs: EventSubscriber[] = this.subscribers[event] || (this.subscribers[event] = []) + if (subs.some(s => s.subscriber === subscriber)) throw Error(`this function is already subscribed to ${event}`) + subs.push({subscriber, once}) + } + + /** + * Subscribe a handler to any event. + * @param receiver - The receiver function for any event. + * @throws {Error} If the same function is subscribed to event. + */ + onAny(receiver: EventSubscriberFunc): void { + if (this.receivers.some(s => s === receiver)) throw Error("this function is already subscribed") + this.receivers.push(receiver) + } + + /** + * Subscribe a handler to a specific event to be delivered one time. + * @param {CEvt.Tag} event - The event type to subscribe to. + * @param subscriber - The subscriber function for the event. + * @throws {Error} If the same function is subscribed to event. + */ + once(event: K, subscriber: EventSubscriberFunc): void { + this.on_(event, subscriber, true) + } + + /** + * Waits for specific event, with an optional predicate. + * Returns `undefined` on timeout if specified. + */ + wait(event: K): Promise + wait(event: K, predicate: ((event: ChatEvent & {type: K}) => boolean) | undefined): Promise + wait(event: K, timeout: number): Promise + wait(event: K, predicate: ((event: ChatEvent & {type: K}) => boolean) | undefined, timeout: number): Promise + wait( + event: K, + predicate: ((event: ChatEvent & {type: K}) => boolean) | undefined | number = undefined, // number for timeout + timeout: number = 0 // milliseconds, default - indefinite + ): Promise { + if (typeof predicate === "number") { + timeout = predicate + predicate = undefined + } + return new Promise((resolve, reject) => { + let done = false + const cleanup = () => { + done = true + this.off(event, subscriber) + } + const subscriber: EventSubscriberFunc = async (evt: ChatEvent & {type: K}) => { + if (done) return + if (predicate) { + try { if (!predicate(evt)) return } + catch(e) { cleanup(); reject(e); return } + } + cleanup() + resolve(evt) + } + this.on(event, subscriber) + if (timeout > 0) { + setTimeout(() => { if (!done) { cleanup(); resolve(undefined) } }, timeout) + } + }) + } + + /** + * Unsubscribe all or a specific handler from a specific event. + * @param {CEvt.Tag} event - The event type to unsubscribe from. + * @param subscriber - An optional subscriber function for the event. + */ + off(event: K, subscriber: EventSubscriberFunc | undefined = undefined): void { + if (subscriber) { + const subs = this.subscribers[event] + if (subs) { + const i = subs.findIndex(s => s.subscriber === subscriber) + if (i >= 0) subs.splice(i, 1) + } + } else { + delete this.subscribers[event] + } + } + + /** + * Unsubscribe all or a specific handler from any events. + * @param receiver - An optional subscriber function for the event. + */ + offAny(receiver: EventSubscriberFunc | undefined = undefined): void { + if (receiver) { + const i = this.receivers.findIndex(r => r === receiver) + if (i >= 0) this.receivers.splice(i, 1) + } else { + this.receivers = [] + } + } + + /** + * Chat controller is initialized + */ + get initialized(): boolean { + return typeof this.ctrl_ === "bigint" + } + + /** + * Chat controller is started + */ + get started(): boolean { + return this.receiveEvents && this.eventsLoop !== undefined + } + + /** + * Chat controller reference + */ + get ctrl(): bigint { + if (typeof this.ctrl_ === "bigint") return this.ctrl_ + else throw Error("chat api controller not initialized") + } + + async sendChatCmd(cmd: string): Promise { + return await core.chatSendCmd(this.ctrl, cmd) + } + + async recvChatEvent(wait: number = 5_000_000): Promise { + return await core.chatRecvMsgWait(this.ctrl, wait) + } + + /** + * Create bot address. + * Network usage: interactive. + */ + async apiCreateUserAddress(userId: number): Promise { + const r = await this.sendChatCmd(CC.APICreateMyAddress.cmdString({userId})) + if (r.type === "userContactLinkCreated") return r.connLinkContact + throw new ChatCommandError("error creating user address", r) + } + + /** + * Deletes a user address. + * Network usage: background. + */ + async apiDeleteUserAddress(userId: number): Promise { + const r = await this.sendChatCmd(CC.APIDeleteMyAddress.cmdString({userId})) + if (r.type === "userContactLinkDeleted") return + throw new ChatCommandError("error deleting user address", r) + } + + /** + * Get bot address and settings. + * Network usage: no. + */ + async apiGetUserAddress(userId: number): Promise { + try { + const r = await this.sendChatCmd(CC.APIShowMyAddress.cmdString({userId})) + switch (r.type) { + case "userContactLink": return r.contactLink + default: throw new ChatCommandError("error loading user address", r) + } + } catch (err) { + const e = err as any + if (e.chatError?.type === "errorStore" && e.chatError.storeError?.type === "userContactLinkNotFound") return undefined + throw e + } + } + + /** + * Add address to bot profile. + * Network usage: interactive. + */ + async apiSetProfileAddress(userId: number, enable: boolean): Promise { + const r = await this.sendChatCmd(CC.APISetProfileAddress.cmdString({userId, enable})) + switch (r.type) { + case "userProfileUpdated": + return r.updateSummary + default: + throw new ChatCommandError("error loading user address", r) + } + } + + /** + * Set bot address settings. + * Network usage: interactive. + */ + async apiSetAddressSettings(userId: number, {autoAccept, welcomeMessage, businessAddress}: BotAddressSettings): Promise { + const autoReply = welcomeMessage || defaultBotAddressSettings.welcomeMessage + const settings: T.AddressSettings = { + autoAccept: (autoAccept === undefined ? defaultBotAddressSettings.autoAccept : autoAccept) ? {acceptIncognito: false} : undefined, + autoReply: typeof autoReply === "string" ? {type: "text", text: autoReply} : autoReply, + businessAddress: businessAddress || defaultBotAddressSettings.businessAddress || false + } + const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings})) + if (r.type !== "userContactLinkUpdated") { + throw new ChatCommandError("error changing user contact address settings", r) + } + } + + /** + * Send messages. + * Network usage: background. + */ + async apiSendMessages(chat: [T.ChatType, number] | T.ChatRef | T.ChatInfo, messages: T.ComposedMessage[], liveMessage = false): Promise { + const sendRef = Array.isArray(chat) + ? {chatType: chat[0], chatId: chat[1]} + : "chatType" in chat + ? chat + : util.chatInfoRef(chat) + if (!sendRef) throw Error("apiSendMessages: can't send messages to this chat") + const r = await this.sendChatCmd( + CC.APISendMessages.cmdString({ + sendRef, + composedMessages: messages, + liveMessage + }) + ) + if (r.type === "newChatItems") return r.chatItems + throw new ChatCommandError("unexpected response", r) + } + + /** + * Send text message. + * Network usage: background. + */ + async apiSendTextMessage(chat: [T.ChatType, number] | T.ChatRef | T.ChatInfo, text: string, inReplyTo?: number): Promise { + return this.apiSendMessages(chat, [{msgContent: {type: "text", text}, mentions: {}, quotedItemId: inReplyTo}]) + } + + /** + * Send text message in reply to received message. + * Network usage: background. + */ + async apiSendTextReply(chatItem: T.AChatItem, text: string): Promise { + return this.apiSendTextMessage(chatItem.chatInfo, text, chatItem.chatItem.meta.itemId) + } + + /** + * Update message. + * Network usage: background. + */ + async apiUpdateChatItem(chatType: T.ChatType, chatId: number, chatItemId: number, msgContent: T.MsgContent, liveMessage: false): Promise { + const r = await this.sendChatCmd( + CC.APIUpdateChatItem.cmdString({ + chatRef: {chatType, chatId}, + chatItemId, + liveMessage, + updatedMessage: {msgContent, mentions: {}}, + }) + ) + if (r.type === "chatItemUpdated") return r.chatItem.chatItem + throw new ChatCommandError("error updating chat item", r) + } + + /** + * Delete message. + * Network usage: background. + */ + async apiDeleteChatItems( + chatType: T.ChatType, + chatId: number, + chatItemIds: number[], + deleteMode: T.CIDeleteMode + ): Promise { + const r = await this.sendChatCmd(CC.APIDeleteChatItem.cmdString({chatRef: {chatType, chatId}, chatItemIds, deleteMode})) + if (r.type === "chatItemsDeleted") return r.chatItemDeletions + throw new ChatCommandError("error deleting chat item", r) + } + + /** + * Moderate message. Requires Moderator role (and higher than message author's). + * Network usage: background. + */ + async apiDeleteMemberChatItem(groupId: number, chatItemIds: number[]): Promise { + const r = await this.sendChatCmd(CC.APIDeleteMemberChatItem.cmdString({groupId, chatItemIds})) + if (r.type === "chatItemsDeleted") return r.chatItemDeletions + throw new ChatCommandError("error deleting member chat item", r) + } + + /** + * Add/remove message reaction. + * Network usage: background. + */ + async apiChatItemReaction( + chatType: T.ChatType, + chatId: number, + chatItemId: number, + add: boolean, + reaction: T.MsgReaction + ) { + const r = await this.sendChatCmd(CC.APIChatItemReaction.cmdString({chatRef: {chatType, chatId}, chatItemId, add, reaction})) + if (r.type === "chatItemsDeleted") return r.chatItemDeletions + throw new ChatCommandError("error setting item reaction", r) + } + + /** + * Receive file. + * Network usage: no. + */ + async apiReceiveFile(fileId: number): Promise { + const r = await this.sendChatCmd(CC.ReceiveFile.cmdString({fileId, userApprovedRelays: true})) + if (r.type === "rcvFileAccepted") return r.chatItem + throw new ChatCommandError("error receiving file", r) + } + + /** + * Cancel file. + * Network usage: background. + */ + async apiCancelFile(fileId: number): Promise { + const r = await this.sendChatCmd(CC.CancelFile.cmdString({fileId})) + if (r.type === "sndFileCancelled" || r.type === "rcvFileCancelled") return + throw new ChatCommandError("error canceling file", r) + } + + /** + * Add contact to group. Requires bot to have Admin role. + * Network usage: interactive. + */ + async apiAddMember(groupId: number, contactId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMember.cmdString({groupId, contactId, memberRole})) + if (r.type === "sentGroupInvitation") return r.member + throw new ChatCommandError("error adding member", r) + } + + /** + * Join group. + * Network usage: interactive. + */ + async apiJoinGroup(groupId: number): Promise { + const r = await this.sendChatCmd(CC.APIJoinGroup.cmdString({groupId})) + if (r.type === "userAcceptedGroupSent") return r.groupInfo + throw new ChatCommandError("error joining group", r) + } + + /** + * Accept group member. Requires Admin role. + * Network usage: background. + */ + async apiAcceptMember(groupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAcceptMember.cmdString({groupId, groupMemberId, memberRole})) + if (r.type === "memberAccepted") return r.member + throw new ChatCommandError("error accepting member", r) + } + + /** + * Set members role. Requires Admin role. + * Network usage: background. + */ + async apiSetMembersRole(groupId: number, groupMemberIds: number[], memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIMembersRole.cmdString({groupId, groupMemberIds, memberRole})) + if (r.type === "membersRoleUser") return + throw new ChatCommandError("error setting members role", r) + } + + /** + * Block members. Requires Moderator role. + * Network usage: background. + */ + async apiBlockMembersForAll(groupId: number, groupMemberIds: number[], blocked: boolean): Promise { + const r = await this.sendChatCmd(CC.APIBlockMembersForAll.cmdString({groupId, groupMemberIds, blocked})) + if (r.type === "membersBlockedForAllUser") return + throw new ChatCommandError("error blocking members", r) + } + + /** + * Remove members. Requires Admin role. + * Network usage: background. + */ + async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false): Promise { + const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages})) + if (r.type === "userDeletedMembers") return r.members + throw new ChatCommandError("error removing member", r) + } + + /** + * Leave group. + * Network usage: background. + */ + async apiLeaveGroup(groupId: number): Promise { + const r = await this.sendChatCmd(CC.APILeaveGroup.cmdString({groupId})) + if (r.type === "leftMemberUser") return r.groupInfo + throw new ChatCommandError("error leaving group", r) + } + + /** + * Get group members. + * Network usage: no. + */ + async apiListMembers(groupId: number): Promise { + const r = await this.sendChatCmd(CC.APIListMembers.cmdString({groupId})) + if (r.type === "groupMembers") return r.group.members + throw new ChatCommandError("error getting group members", r) + } + + /** + * Create group. + * Network usage: no. + */ + async apiNewGroup(userId: number, groupProfile: T.GroupProfile): Promise { + const r = await this.sendChatCmd(CC.APINewGroup.cmdString({userId, groupProfile, incognito: false})) + if (r.type === "groupCreated") return r.groupInfo + throw new ChatCommandError("error creating group", r) + } + + /** + * Update group profile. + * Network usage: background. + */ + async apiUpdateGroupProfile(groupId: number, groupProfile: T.GroupProfile): Promise { + const r = await this.sendChatCmd(CC.APIUpdateGroupProfile.cmdString({groupId, groupProfile})) + if (r.type === "groupUpdated") return r.toGroup + throw new ChatCommandError("error updating group", r) + } + + /** + * Create group link. + * Network usage: interactive. + */ + async apiCreateGroupLink(groupId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APICreateGroupLink.cmdString({groupId, memberRole})) + if (r.type === "groupLinkCreated") { + const link = r.groupLink.connLinkContact + return link.connShortLink || link.connFullLink + } + throw new ChatCommandError("error creating group link", r) + } + + /** + * Set member role for group link. + * Network usage: no. + */ + async apiSetGroupLinkMemberRole(groupId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIGroupLinkMemberRole.cmdString({groupId, memberRole})) + if (r.type !== "groupLink") throw new ChatCommandError("error setting group link member role", r) + } + + /** + * Delete group link. + * Network usage: background. + */ + async apiDeleteGroupLink(groupId: number): Promise { + const r = await this.sendChatCmd(CC.APIDeleteGroupLink.cmdString({groupId})) + if (r.type !== "groupLinkDeleted") throw new ChatCommandError("error deleting group link", r) + } + + /** + * Get group link. + * Network usage: no. + */ + async apiGetGroupLink(groupId: number): Promise { + const r = await this.sendChatCmd(CC.APIGetGroupLink.cmdString({groupId})) + if (r.type === "groupLink") return r.groupLink + throw new ChatCommandError("error getting group link", r) + } + + async apiGetGroupLinkStr(groupId: number): Promise { + const link = (await this.apiGetGroupLink(groupId)).connLinkContact + return link.connShortLink || link.connFullLink + } + + /** + * Create 1-time invitation link. + * Network usage: interactive. + */ + async apiCreateLink(userId: number): Promise { + const r = await this.sendChatCmd(CC.APIAddContact.cmdString({userId, incognito: false})) + if (r.type === "invitation") { + const link = r.connLinkInvitation + return link.connShortLink || link.connFullLink + } + throw new ChatCommandError("error creating link", r) + } + + /** + * Determine SimpleX link type and if the bot is already connected via this link. + * Network usage: interactive. + */ + async apiConnectPlan(userId: number, connectionLink: string): Promise<[T.ConnectionPlan, T.CreatedConnLink]> { + const r = await this.sendChatCmd(CC.APIConnectPlan.cmdString({userId, connectionLink, resolveKnown: false})) + if (r.type === "connectionPlan") return [r.connectionPlan, r.connLink] + throw new ChatCommandError("error getting connect plan", r) + } + + /** + * Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link + * Network usage: interactive. + */ + async apiConnect(userId: number, incognito: boolean, preparedLink?: T.CreatedConnLink): Promise { + const r = await this.sendChatCmd(CC.APIConnect.cmdString({userId, incognito, preparedLink_: preparedLink})) + return this.handleConnectResult(r) + } + + /** + * Connect via SimpleX link as string in the active user profile. + * Network usage: interactive. + */ + async apiConnectActiveUser(connLink: string): Promise { + const r = await this.sendChatCmd(CC.Connect.cmdString({incognito: false, connLink_: connLink})) + return this.handleConnectResult(r) + } + + private handleConnectResult(r: ChatResponse): ConnReqType { + switch (r.type) { + case "sentConfirmation": + return ConnReqType.Invitation + case "sentInvitation": + return ConnReqType.Contact + case "contactAlreadyExists": + throw new ChatCommandError("contact already exists", r) + default: + throw new ChatCommandError("connection error", r) + } + } + + /** + * Accept contact request. + * Network usage: interactive. + */ + async apiAcceptContactRequest(contactReqId: number): Promise { + const r = await this.sendChatCmd(CC.APIAcceptContact.cmdString({contactReqId})) + if (r.type === "acceptingContactRequest") return r.contact + throw new ChatCommandError("error accepting contact request", r) + } + + /** + * Reject contact request. The user who sent the request is **not notified**. + * Network usage: no. + */ + async apiRejectContactRequest(contactReqId: number): Promise { + const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId})) + if (r.type === "contactRequestRejected") return + throw new ChatCommandError("error rejecting contact request", r) + } + + /** + * Get contacts. + * Network usage: no. + */ + async apiListContacts(userId: number): Promise { + const r = await this.sendChatCmd(CC.APIListContacts.cmdString({userId})) + if (r.type === "contactsList") return r.contacts + throw new ChatCommandError("error listing contacts", r) + } + + /** + * Get groups. + * Network usage: no. + */ + async apiListGroups(userId: number, contactId?: number, search?: string): Promise { + const r = await this.sendChatCmd(CC.APIListGroups.cmdString({userId, contactId_: contactId, search})) + if (r.type === "groupsList") return r.groups + throw new ChatCommandError("error listing groups", r) + } + + /** + * Get chat previews (paginated). + * Network usage: no. + * + * Prefer this over apiListContacts / apiListGroups for any scan: those + * methods load every record into memory in a single response and will fail + * on large databases. + */ + async apiGetChats( + userId: number, + pagination: T.PaginationByTime, + query: T.ChatListQuery = {type: "filters", favorite: false, unread: false}, + pendingConnections = false, + ): Promise { + const r = await this.sendChatCmd(CC.APIGetChats.cmdString({userId, pendingConnections, pagination, query})) + if (r.type === "apiChats") return r.chats + throw new ChatCommandError("error getting chats", r) + } + + /** + * Delete chat. + * Network usage: background. + */ + async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}): Promise { + const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode})) + switch (chatType) { + case T.ChatType.Direct: + if (r.type === "contactDeleted") return + break + case T.ChatType.Group: + if (r.type === "groupDeletedUser") return + break + } + throw new ChatCommandError("error deleting chat", r) + } + + /** + * Set group custom data. + * Network usage: no. + */ + async apiSetGroupCustomData(groupId: number, customData?: object): Promise { + const r = await this.sendChatCmd(CC.APISetGroupCustomData.cmdString({groupId, customData})) + if (r.type === "cmdOk") return + throw new ChatCommandError("error setting group custom data", r) + } + + /** + * Set contact custom data. + * Network usage: no. + */ + async apiSetContactCustomData(contactId: number, customData?: object): Promise { + const r = await this.sendChatCmd(CC.APISetContactCustomData.cmdString({contactId, customData})) + if (r.type === "cmdOk") return + throw new ChatCommandError("error setting contact custom data", r) + } + + /** + * Set auto-accept member contacts. + * Network usage: no. + */ + async apiSetAutoAcceptMemberContacts(userId: number, onOff: boolean): Promise { + const r = await this.sendChatCmd(CC.APISetUserAutoAcceptMemberContacts.cmdString({userId, onOff})) + if (r.type === "cmdOk") return + throw new ChatCommandError("error setting auto-accept member contacts", r) + } + + /** + * Get chat items. + * Network usage: no. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async apiGetChat(chatType: T.ChatType, chatId: number, count: number): Promise { + const r: any = await this.sendChatCmd(`/_get chat ${T.ChatType.cmdString(chatType)}${chatId} count=${count}`) + if (r.type === "apiChat") return r.chat + throw new ChatCommandError("error getting chat", r) + } + + /** + * Get active user profile + * Network usage: no. + */ + async apiGetActiveUser(): Promise { + try { + const r = await this.sendChatCmd(CC.ShowActiveUser.cmdString({})) + switch (r.type) { + case "activeUser": + return r.user + default: + throw new ChatCommandError("unexpected response", r) + } + } catch (err) { + const e = err as core.ChatAPIError + if (e.chatError?.type === "error" && e.chatError.errorType.type === "noActiveUser") return undefined + throw err + } + } + + /** + * Create new user profile + * Network usage: no. + */ + async apiCreateActiveUser(profile?: T.Profile): Promise { + const r = await this.sendChatCmd(CC.CreateActiveUser.cmdString({newUser: {profile, pastTimestamp: false, userChatRelay: false}})) + if (r.type === "activeUser") return r.user + throw new ChatCommandError("unexpected response", r) + } + + /** + * Get all user profiles + * Network usage: no. + */ + async apiListUsers(): Promise { + const r = await this.sendChatCmd(CC.ListUsers.cmdString({})) + if (r.type === "usersList") return r.users + throw new ChatCommandError("error listing users", r) + } + + /** + * Set active user profile + * Network usage: no. + */ + async apiSetActiveUser(userId: number, viewPwd?: string): Promise { + const r = await this.sendChatCmd(CC.APISetActiveUser.cmdString({userId, viewPwd})) + if (r.type === "activeUser") return r.user + throw new ChatCommandError("error setting active user", r) + } + + /** + * Delete user profile. + * Network usage: background. + */ + async apiDeleteUser(userId: number, delSMPQueues: boolean, viewPwd?: string): Promise { + const r = await this.sendChatCmd(CC.APIDeleteUser.cmdString({userId, delSMPQueues, viewPwd})) + if (r.type === "cmdOk") return + throw new ChatCommandError("error deleting user", r) + } + + /** + * Update user profile. + * Network usage: background. + */ + async apiUpdateProfile(userId: number, profile: T.Profile): Promise { + const r = await this.sendChatCmd(CC.APIUpdateProfile.cmdString({userId, profile})) + switch (r.type) { + case "userProfileNoChange": + return undefined + case "userProfileUpdated": + return r.updateSummary + default: + throw new ChatCommandError("error updating profile", r) + } + } + + /** + * Configure chat preference overrides for the contact. + * Network usage: background. + */ + async apiSetContactPrefs(contactId: number, preferences: T.Preferences): Promise { + const r = await this.sendChatCmd(CC.APISetContactPrefs.cmdString({contactId, preferences})) + if (r.type !== "contactPrefsUpdated") throw new ChatCommandError("error setting contact prefs", r) + } + + /** + * Create a direct message contact with a group member. + * Returns the created contact. + * Network usage: interactive. + */ + async apiCreateMemberContact(groupId: number, groupMemberId: number): Promise { + const r: any = await this.sendChatCmd(`/_create member contact #${groupId} ${groupMemberId}`) + if (r.type === "newMemberContact") return r.contact + throw new ChatCommandError("error creating member contact", r) + } + + /** + * Send a direct message invitation to a group member contact. + * The contact must have been created with {@link apiCreateMemberContact}. + * Network usage: interactive. + */ + async apiSendMemberContactInvitation(contactId: number, message?: T.MsgContent | string): Promise { + let cmd = `/_invite member contact @${contactId}` + if (message !== undefined) { + if (typeof message === "string") { + cmd += ` text ${message}` + } else { + cmd += ` json ${JSON.stringify(message)}` + } + } + const r: any = await this.sendChatCmd(cmd) + if (r.type === "newMemberContactSentInv") return r.contact + throw new ChatCommandError("error sending member contact invitation", r) + } +} diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts new file mode 100644 index 0000000000..f6cb753d27 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/bot.ts @@ -0,0 +1,214 @@ +import {T} from "@simplex-chat/types" +import * as api from "./api" +import * as core from "./core" +import * as util from "./util" +import equal = require("fast-deep-equal") + +export type BotDbOpts = api.DbConfig & { + confirmMigrations?: core.MigrationConfirmation +} + +export interface BotOptions { + createAddress?: boolean + updateAddress?: boolean + updateProfile?: boolean + addressSettings?: api.BotAddressSettings + allowFiles?: boolean + commands?: T.ChatBotCommand[] // commands to show in client UI + useBotProfile?: boolean // create profile not marked as a bot, with default preferences + logContacts?: boolean + logNetwork?: boolean +} + +const defaultOpts: Required = { + createAddress: true, + updateAddress: true, + updateProfile: true, + addressSettings: api.defaultBotAddressSettings, + allowFiles: false, + commands: [], + useBotProfile: true, + logContacts: true, + logNetwork: false +} + +export interface BotConfig { + profile: T.Profile, + dbOpts: BotDbOpts, + options: BotOptions, + onMessage?: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise, + // command handlers can be different from commands to be shown in client UI + onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)}, + // If you use `onMessage` and to subscribe "newChatItems" event, exclude content messages from processing + // If you use `onCommands` and to subscribe "newChatItems" event, exclude commands from processing + events?: api.EventSubscribers +} + +export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { + const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp) + const opts = fullOptions(options) + if (onMessage) subscribeMessages(bot, onMessage) + if (Object.keys(onCommands).length > 0) subscribeCommands(bot, onCommands) + if (Object.keys(events).length > 0) bot.on(events) + subscribeLogEvents(bot, opts) + const botProfile = mkBotProfile(profile, opts) + const user = await createBotUser(bot, botProfile) + await bot.startChat() + const address = await createOrUpdateAddress(bot, user, opts) + if (address) { + const addressLink = util.contactAddressStr(address.connLinkContact) + console.log(`Bot address: ${addressLink}`) + if (opts.useBotProfile) botProfile.contactLink = addressLink + } + await updateBotUserProfile(bot, user, botProfile, opts) + return [bot, user, address] +} + +function fullOptions(options: BotOptions): Required { + const opts = { + createAddress: options.createAddress ?? defaultOpts.createAddress, + updateAddress: options.updateAddress ?? defaultOpts.updateAddress, + updateProfile: options.updateProfile ?? defaultOpts.updateProfile, + addressSettings: options.addressSettings ?? defaultOpts.addressSettings, + allowFiles: options.allowFiles ?? defaultOpts.allowFiles, + commands: options.commands ?? defaultOpts.commands, + useBotProfile: options.useBotProfile ?? defaultOpts.useBotProfile, + logContacts: options.logContacts ?? defaultOpts.logContacts, + logNetwork: options.logNetwork ?? defaultOpts.logNetwork + } + const welcomeMessage = opts.addressSettings.welcomeMessage ?? defaultOpts.addressSettings.welcomeMessage + opts.addressSettings = { + autoAccept: opts.addressSettings.autoAccept ?? defaultOpts.addressSettings.autoAccept, + welcomeMessage: typeof welcomeMessage === "string" ? {type: "text", text: welcomeMessage} : welcomeMessage, + businessAddress: opts.addressSettings.businessAddress ?? defaultOpts.addressSettings.businessAddress + } + return opts +} + +function mkBotProfile(profile: T.Profile, opts: Required): T.Profile { + if (opts.useBotProfile) { + const prefs = profile.preferences || {} + if (prefs.files || prefs.calls || prefs.voice || prefs.commands) { + console.log("Option useBotProfile is enabled and profile preferences used for files, calls, voice or commands, exiting") + process.exit() + } + prefs.files = {allow: opts.allowFiles ? T.FeatureAllowed.Yes : T.FeatureAllowed.No} + prefs.calls = {allow: T.FeatureAllowed.No} + prefs.voice = {allow: T.FeatureAllowed.No} + prefs.commands = opts.commands + profile.preferences = prefs + profile.peerType = T.ChatPeerType.Bot + } else if (opts.commands.length > 0) { + console.log("Option useBotProfile is disabled and commands are passed, exiting") + process.exit() + } + return profile +} + +function subscribeMessages(bot: api.ChatApi, onMessage: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) { + bot.on("newChatItems", async ({chatItems}) => { + for (const ci of chatItems) { + if (ci.chatItem.content.type === "rcvMsgContent") { + try { + const p = onMessage(ci, ci.chatItem.content.msgContent) + if (p instanceof Promise) await p + } catch (e) { + console.log("message processing error", e) + } + } + } + }) +} + +function subscribeCommands(bot: api.ChatApi, commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)}) { + bot.on("newChatItems", async (evt) => { + for (const ci of evt.chatItems) { + const cmd = util.ciBotCommand(ci.chatItem) + if (cmd) { + const cmdFunc = commands[cmd.keyword] || commands[""] + if (cmdFunc) { + try { + const p = cmdFunc(ci, cmd) + if (p instanceof Promise) await p + } catch(e) { + console.log(`${cmd} command processing error`, e) + } + } + } + } + }) +} + +function subscribeLogEvents(bot: api.ChatApi, opts: Required) { + if (opts.logContacts) { + bot.on({ + "contactConnected": ({contact}) => console.log(`${contact.profile.displayName} connected`), + "contactDeletedByContact": ({contact}) => console.log(`${contact.profile.displayName} deleted connection with bot`) + }) + } + if (opts.logNetwork) { + bot.on({ + "hostConnected": ({transportHost}) => console.log(`connected server ${transportHost}`), + "hostDisconnected": ({transportHost}) => console.log(`diconnected server ${transportHost}`), + "subscriptionStatus": ({subscriptionStatus, connections}) => console.log(`${connections.length} subscription(s) ${subscriptionStatus.type}`) + }) + } +} + +async function createBotUser(bot: api.ChatApi, profile: T.Profile): Promise { + let user = await bot.apiGetActiveUser() + if (!user) { + console.log("No active user in database, creating...") + user = await bot.apiCreateActiveUser(profile) + } + console.log("Bot user: ", user.profile.displayName) + return user +} + +async function createOrUpdateAddress(bot: api.ChatApi, user: T.User, opts: Required): Promise { + const {userId} = user + let address = await bot.apiGetUserAddress(userId) + if (!address) { + if (opts.createAddress) { + console.log("Bot has no address, creating...") + await bot.apiCreateUserAddress(userId) + address = await bot.apiGetUserAddress(userId) + if (!address) { + console.log("Failed reading created user address, exiting") + process.exit() + } + } else { + console.log("Warning: bot has no address") + return + } + } + + const addressSettings = opts.addressSettings || defaultOpts.addressSettings + if (!equal(util.botAddressSettings(address), addressSettings)) { + if (opts.updateAddress) { + console.log("Bot address settings changed, updating...") + await bot.apiSetAddressSettings(userId, addressSettings) + } else { + console.log("Bot address settings changed") + } + } + + return address +} + +async function updateBotUserProfile(bot: api.ChatApi, user: T.User, profile: T.Profile, opts: Required): Promise { + const {userId} = user + if (!equal(util.fromLocalProfile(user.profile), profile)) { + if (opts.updateProfile) { + console.log("Bot profile changed, updating...") + const summary = await bot.apiUpdateProfile(userId, profile) + console.log( + summary + ? `Bot profile updated: ${summary.updateSuccesses} updated contact(s), ${summary.updateFailures} failed contact update(s).` + : "Unexpected: profile did not change!" + ) + } else { + console.log("Bot profile changed") + } + } +} diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts new file mode 100644 index 0000000000..949c2356af --- /dev/null +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -0,0 +1,210 @@ +import {ChatEvent, ChatResponse, T} from "@simplex-chat/types" +import * as simplex from "./simplex" + +/** + * Initialize chat controller + */ +export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation): Promise { + const [ctrl, res] = await simplex.chat_migrate_init(dbPath, dbKey, confirm) + const json = JSON.parse(res) + if (json.type === 'ok') return ctrl + throw new ChatInitError("Database or migration error (see dbMigrationError property)", json as DBMigrationError) +} + +/** + * Close chat store + */ +export async function chatCloseStore(ctrl: bigint): Promise { + const res = await simplex.chat_close_store(ctrl) + if (res !== "") throw new Error(res) +} + +/** + * Send chat command as string + */ +export async function chatSendCmd(ctrl: bigint, cmd: string): Promise { + const res = await simplex.chat_send_cmd(ctrl, cmd) + const json = JSON.parse(res) as APIResult + // console.log(cmd.slice(0, 16), json.result?.type || json.error) + if (typeof json.result === 'object') return json.result + if (typeof json.error === 'object') throw new ChatAPIError("Chat command error (see chatError property)", json.error as T.ChatError) + throw new ChatAPIError("Invalid chat command result") +} + +/** + * Receive chat event + */ +export async function chatRecvMsgWait(ctrl: bigint, wait: number): Promise { + const res = await simplex.chat_recv_msg_wait(ctrl, wait) + if (res === "") return undefined + const json = JSON.parse(res) as APIResult + // if (json.result) console.log("event", json.result.type) + if (typeof json.result === 'object') return json.result + if (typeof json.error === 'object') throw new ChatAPIError("Chat event error (see chatError property)", json.error as T.ChatError) + throw new ChatAPIError("Invalid chat event") +} + +/** + * Write buffer to encrypted file + */ +export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuffer): Promise { + const res = await simplex.chat_write_file(ctrl, path, buffer) + return cryptoArgsResult(res) +} + +/** + * Read buffer from encrypted file + */ +export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise { + return await simplex.chat_read_file(path, fileKey, fileNonce) +} + +/** + * Encrypt file + */ +export async function chatEncryptFile(ctrl: bigint, fromPath: string, toPath: string): Promise { + const res = await simplex.chat_encrypt_file(ctrl, fromPath, toPath) + return cryptoArgsResult(res) +} + +/** + * Decrypt file + */ +export async function chatDecryptFile(fromPath: string, {fileKey, fileNonce}: CryptoArgs, toPath: string): Promise { + const res = await simplex.chat_decrypt_file(fromPath, fileKey, fileNonce, toPath) + if (res !== "") throw new Error(res) +} + +function cryptoArgsResult(res: string): CryptoArgs { + const json = JSON.parse(res) + switch (json.type) { + case "result": return json.cryptoArgs as CryptoArgs + case "error": throw Error(json.writeError) + default: throw Error("unexpected chat_write_file result: " + res) + } +} + +export interface APIResult { + result?: R + error?: T.ChatError +} + +export class ChatAPIError extends Error { + constructor(public message: string, public chatError: T.ChatError | undefined = undefined) { + super(message) + } +} + +/** + * Migration confirmation mode + */ +export enum MigrationConfirmation { + YesUp = "yesUp", + YesUpDown = "yesUpDown", + Console = "console", + Error = "error" +} + +/** + * File encryption key and nonce + */ +export interface CryptoArgs { + fileKey: string + fileNonce: string +} + +export class ChatInitError extends Error { + constructor(public message: string, public dbMigrationError: DBMigrationError) { + super(message) + } +} + +export type DBMigrationError = + | DBMigrationError.InvalidConfirmation + | DBMigrationError.ErrorNotADatabase // invalid/corrupt database file or incorrect encryption key + | DBMigrationError.ErrorMigration + | DBMigrationError.ErrorSQL + +export namespace DBMigrationError { + export type Tag = "invalidConfirmation" | "errorNotADatabase" | "errorMigration" | "errorSQL" + + interface Interface { + type: Tag + } + + export interface InvalidConfirmation extends Interface { + type: "invalidConfirmation" + } + + export interface ErrorNotADatabase extends Interface { + type: "errorNotADatabase" + dbFile: string + } + + export interface ErrorMigration extends Interface { + type: "errorMigration" + dbFile: string + migrationError: MigrationError + } + + export interface ErrorSQL extends Interface { + type: "errorSQL" + dbFile: string + migrationSQLError: string + } +} + +export type MigrationError = + | MigrationError.MEUpgrade + | MigrationError.MEDowngrade + | MigrationError.MigrationError + +export namespace MigrationError { + export type Tag = "upgrade" | "downgrade" | "migrationError" + + interface Interface { + type: Tag + } + + export interface MEUpgrade extends Interface { + type: "upgrade" + upMigrations: UpMigration + } + + export interface MEDowngrade extends Interface { + type: "downgrade" + downMigrations: string[] + } + + export interface MigrationError extends Interface { + type: "migrationError" + mtrError: MTRError + } +} + +export interface UpMigration { + upName: string + withDown: boolean +} + +export type MTRError = + | MTRError.MTRENoDown + | MTRError.MTREDifferent + +export namespace MTRError { + export type Tag = "noDown" | "different" + + interface Interface { + type: Tag + } + + export interface MTRENoDown extends Interface { + type: "noDown" + upMigrations: UpMigration + } + + export interface MTREDifferent extends Interface { + type: "different" + downMigrations: string[] + } +} diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js new file mode 100644 index 0000000000..db042d48a2 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -0,0 +1,239 @@ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const extract = require('extract-zip'); + +const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; +const RELEASE_TAG = 'v6.5.2'; +const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); + +if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { + console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`); + process.exit(1); +} + +if (BACKEND === 'postgres' && (process.platform !== 'linux' || process.arch !== 'x64')) { + console.error(`✗ SIMPLEX_BACKEND=postgres is only supported on Linux x86_64.`); + process.exit(1); +} + +const ROOT_DIR = process.cwd(); // Root of the package being installed +const LIBS_DIR = path.join(ROOT_DIR, 'libs') +const INSTALLED_FILE = path.join(LIBS_DIR, 'installed.txt'); + +// Detect platform and architecture +function getPlatformInfo() { + const platform = process.platform; + const arch = process.arch; + + let platformName; + let archName; + + if (platform === 'linux') { + platformName = 'linux'; + } else if (platform === 'darwin') { + platformName = 'macos'; + } else if (platform === 'win32') { + platformName = 'windows'; + } else { + throw new Error(`Unsupported platform: ${platform}`); + } + + if (arch === 'x64') { + archName = 'x86_64'; + } else if (arch === 'arm64') { + archName = 'aarch64'; + } else { + throw new Error(`Unsupported architecture: ${arch}`); + } + + return { platformName, archName }; +} + +// Cleanup on libs version mismatch +function cleanLibsDirectory() { + if (fs.existsSync(LIBS_DIR)) { + console.log('Cleaning old libraries...'); + fs.rmSync(LIBS_DIR, { recursive: true, force: true }); + fs.mkdirSync(LIBS_DIR, { recursive: true }); + console.log('✓ Old libraries removed'); + } +} + +// Check if libraries are already installed with the correct version +function isAlreadyInstalled() { + if (!fs.existsSync(INSTALLED_FILE)) { + return false; + } + + try { + const installedVersion = fs.readFileSync(INSTALLED_FILE, 'utf-8').trim(); + const expectedVersion = `${RELEASE_TAG}:${BACKEND}`; + if (installedVersion === expectedVersion) { + console.log(`✓ Libraries version ${RELEASE_TAG}:${BACKEND} already installed`); + return true; + } else { + console.log(`Version mismatch: installed ${installedVersion}, need ${expectedVersion}`); + cleanLibsDirectory(); + return false; + } + } catch (err) { + console.warn(`Could not read installed.txt: ${err.message}`); + return false; + } +} + +async function install() { + try { + // Check if already installed + if (isAlreadyInstalled()) { + return; + } + + const { platformName, archName } = getPlatformInfo(); + const repoName = GITHUB_REPO.split('/')[1]; + const backendSuffix = BACKEND === 'postgres' ? '-postgres' : ''; + const zipFilename = `${repoName}-${platformName}-${archName}${backendSuffix}.zip`; + const ZIP_URL = `https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${zipFilename}`; + const ZIP_PATH = path.join(ROOT_DIR, zipFilename); + const TEMP_EXTRACT_DIR = path.join(ROOT_DIR, '.temp-extract'); + + console.log(`Detected: ${platformName} ${archName}`); + console.log(`Backend: ${BACKEND}`); + console.log(`Downloading: ${zipFilename}`); + + // Create libs directory + if (!fs.existsSync(LIBS_DIR)) { + fs.mkdirSync(LIBS_DIR, { recursive: true }); + } + + // Download zip with error handling + await downloadFile(ZIP_URL, ZIP_PATH); + + // Extract to temporary directory + console.log('Extracting to temporary directory...'); + if (!fs.existsSync(TEMP_EXTRACT_DIR)) { + fs.mkdirSync(TEMP_EXTRACT_DIR, { recursive: true }); + } + await extract(ZIP_PATH, { dir: TEMP_EXTRACT_DIR }); + + // Move libs folder contents to final location + console.log('Moving libraries to libs/...'); + const libsSourcePath = path.join(TEMP_EXTRACT_DIR, 'libs'); + + if (fs.existsSync(libsSourcePath)) { + // Copy all files from libs folder to LIBS_DIR + const files = fs.readdirSync(libsSourcePath); + files.forEach(file => { + const src = path.join(libsSourcePath, file); + const dest = path.join(LIBS_DIR, file); + + if (fs.statSync(src).isDirectory()) { + copyDirSync(src, dest); + } else { + fs.copyFileSync(src, dest); + } + }); + } else { + throw new Error('libs folder not found in zip archive'); + } + + // Write installed.txt with version + fs.writeFileSync(INSTALLED_FILE, `${RELEASE_TAG}:${BACKEND}`, 'utf-8'); + console.log(`✓ Wrote version ${RELEASE_TAG}:${BACKEND} to installed.txt`); + + // Cleanup + fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true }); + fs.unlinkSync(ZIP_PATH); + console.log('✓ Installation complete'); + } catch (err) { + console.error('✗ Failed:', err.message); + process.exit(1); + } +} + +// Helper function to recursively copy directories +function copyDirSync(src, dest) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + const files = fs.readdirSync(src); + files.forEach(file => { + const srcFile = path.join(src, file); + const destFile = path.join(dest, file); + if (fs.statSync(srcFile).isDirectory()) { + copyDirSync(srcFile, destFile); + } else { + fs.copyFileSync(srcFile, destFile); + } + }); +} + +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + + https.get(url, { headers: { 'User-Agent': 'Node.js' } }, (response) => { + // Handle redirects + if (response.statusCode === 302 || response.statusCode === 301) { + file.destroy(); + fs.unlink(dest, () => {}); + return downloadFile(response.headers.location, dest) + .then(resolve) + .catch(reject); + } + + // Handle 404 + if (response.statusCode === 404) { + file.destroy(); + fs.unlink(dest, () => {}); + reject(new Error( + `Release artifact not found (404). Check:\n` + + ` - Repository exists: ${url.split('/releases')[0]}\n` + + ` - Release tag exists: ${RELEASE_TAG}\n` + + ` - Artifact filename is correct` + )); + return; + } + + // Handle 403 + if (response.statusCode === 403) { + file.destroy(); + fs.unlink(dest, () => {}); + reject(new Error( + `Access denied (403). The repository may be private.\n` + + `Set GITHUB_TOKEN environment variable for private repos.` + )); + return; + } + + // Handle other HTTP errors + if (response.statusCode < 200 || response.statusCode >= 300) { + file.destroy(); + fs.unlink(dest, () => {}); + reject(new Error( + `HTTP ${response.statusCode}: Failed to download from ${url}` + )); + return; + } + + response.pipe(file); + + file.on('finish', () => { + file.close(); + resolve(); + }); + + file.on('error', (err) => { + fs.unlink(dest, () => {}); + reject(new Error(`File write error: ${err.message}`)); + }); + }).on('error', (err) => { + file.destroy(); + fs.unlink(dest, () => {}); + reject(new Error(`Download error: ${err.message}`)); + }); + }); +} + +install(); diff --git a/packages/simplex-chat-nodejs/src/index.ts b/packages/simplex-chat-nodejs/src/index.ts new file mode 100644 index 0000000000..80180ae282 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/index.ts @@ -0,0 +1,22 @@ +/** + * A simple declarative API to run a chat-bot with a single function call. + * It automates creating and updating of the bot profile, address and bot commands shown in the app UI. + */ +export * as bot from "./bot" + +/** + * An API to send chat commands and receive chat events to/from chat core. + * You need to use it in bot event handlers, and for any other use cases. + */ +export * as api from "./api" + +/** + * A low level API to the core library - the same that is used in desktop clients. + * You are unlikely to ever need to use this module directly. + */ +export * as core from "./core" + +/** + * Useful functions for chat events and types. + */ +export * as util from "./util" diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts new file mode 100644 index 0000000000..10c2f6608a --- /dev/null +++ b/packages/simplex-chat-nodejs/src/simplex.d.ts @@ -0,0 +1,10 @@ +// These functions are defined in CPP add-on ../cpp/simplex.cc + +export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]> +export function chat_close_store(ctrl: bigint): Promise +export function chat_send_cmd(ctrl: bigint, cmd: string): Promise +export function chat_recv_msg_wait(ctrl: bigint, wait: number): Promise +export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer): Promise +export function chat_read_file(path: string, key: string, nonce: string): Promise +export function chat_encrypt_file(ctrl: bigint, fromPath: string, toPath: string): Promise +export function chat_decrypt_file(fromPath: string, key: string, nonce: string, toPath: string): Promise diff --git a/packages/simplex-chat-nodejs/src/simplex.js b/packages/simplex-chat-nodejs/src/simplex.js new file mode 100644 index 0000000000..fd26c67438 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/simplex.js @@ -0,0 +1 @@ +module.exports = require("../build/Release/simplex") diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts new file mode 100644 index 0000000000..f7365e731c --- /dev/null +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -0,0 +1,92 @@ +import {T} from "@simplex-chat/types" +import {BotAddressSettings} from "./api" + +export function chatInfoRef(cInfo: T.ChatInfo): T.ChatRef | undefined { + switch (cInfo.type) { + case T.ChatType.Direct: return {chatType: T.ChatType.Direct, chatId: cInfo.contact.contactId} + case T.ChatType.Group: { + const chatScope: T.GroupChatScope | undefined = + cInfo.groupChatScope?.type == "memberSupport" + ? {type: "memberSupport", groupMemberId_: cInfo.groupChatScope.groupMember_?.groupMemberId} + : undefined + return {chatType: T.ChatType.Group, chatId: cInfo.groupInfo.groupId, chatScope} + } + default: return undefined + } +} + +export function chatInfoName(cInfo: T.ChatInfo): string { + switch (cInfo.type) { + case "direct": return `@${cInfo.contact.profile.displayName}` + case "group": { + const scope = cInfo.groupChatScope + const scopeName = scope?.type === "memberSupport" + ? `(support${scope.groupMember_ ? ` ${scope.groupMember_.memberProfile.displayName}` : ""})` + : "" + return `#${cInfo.groupInfo.groupProfile.displayName}${scopeName}` + } + case "local": return "private notes" + case "contactRequest": return `request from @${cInfo.contactRequest.profile.displayName}` + case "contactConnection": { + const alias = cInfo.contactConnection.localAlias + return `pending connection${alias ? ` (@${alias})` : ""}` + } + } +} + +export function senderName(cInfo: T.ChatInfo, chatDir: T.CIDirection) { + const sender = chatDir.type === "groupRcv" + ? ` @${chatDir.groupMember.memberProfile.displayName}` + : "" + return chatInfoName(cInfo) + sender +} + +export function contactAddressStr(link: T.CreatedConnLink): string { + return link.connShortLink || link.connFullLink +} + +export function botAddressSettings({addressSettings}: T.UserContactLink): BotAddressSettings { + return { + autoAccept: addressSettings.autoAccept ? true : false, + welcomeMessage: addressSettings.autoReply, + businessAddress: addressSettings.businessAddress + } +} + +export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType}: T.LocalProfile): T.Profile { + const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} + for (const key in profile) { + if (typeof (profile as any)[key] === "undefined") delete (profile as any)[key] + } + return profile +} + +export function ciContentText({content}: T.ChatItem): string | undefined { + switch (content.type) { + case "sndMsgContent": return content.msgContent.text; + case "rcvMsgContent": return content.msgContent.text; + default: return undefined; + } +} + +export interface BotCommand { + keyword: string + params: string +} + +// returns command (without /) and trimmed parameters +export function ciBotCommand(chatItem: T.ChatItem): BotCommand | undefined { + const msg = ciContentText(chatItem)?.trim() + if (msg) { + const r = msg.match(/^\/([^\s]+)(.*)/) + if (r && r.length >= 3) { + return {keyword: r[1], params: r[2].trim()} + } + } + return undefined +} + +export function reactionText(reaction: T.ACIReaction): string { + const r = reaction.chatReaction + return r.reaction.type === "emoji" ? r.reaction.emoji : r.reaction.tag +} diff --git a/packages/simplex-chat-nodejs/tests/api.test.ts b/packages/simplex-chat-nodejs/tests/api.test.ts new file mode 100644 index 0000000000..99d511371c --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/api.test.ts @@ -0,0 +1,152 @@ +import * as path from "path" +import * as fs from "fs" +import {CEvt, T} from "@simplex-chat/types" +import {api} from ".." + +const CT = T.ChatType + +describe("API tests (use preset servers)", () => { + const tmpDir = "./tests/tmp" + const alicePath = path.join(tmpDir, "alice") + const bobPath = path.join(tmpDir, "bob") + + beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true})) + afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true})) + + it("should send/receive message", async () => { + // create users and start chat controllers + const alice = await api.ChatApi.init({type: "sqlite", filePrefix: alicePath}) + const bob = await api.ChatApi.init({type: "sqlite", filePrefix: bobPath}) + const servers: string[] = [] + let eventCount = 0 + alice.on("hostConnected" as CEvt.Tag, async ({transportHost}: any) => { servers.push(transportHost) }) + alice.onAny(async () => { eventCount++ }) + await expect(alice.apiGetActiveUser()).resolves.toBeUndefined() + const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""}) + await expect(alice.apiGetActiveUser()).resolves.toMatchObject(aliceUser) + await bob.apiCreateActiveUser({displayName: "bob", fullName: ""}) + await alice.startChat() + await bob.startChat() + // connect via link + const link = await alice.apiCreateLink(aliceUser.userId) + await expect(bob.apiConnectActiveUser(link)).resolves.toBe(api.ConnReqType.Invitation) + const [bobContact, aliceContact] = await Promise.all([ + (await alice.wait("contactConnected")).contact, + (await bob.wait("contactConnected")).contact + ]) + expect(bobContact).toMatchObject({profile: {displayName: "bob"}}) + expect(aliceContact).toMatchObject({profile: {displayName: "alice"}}) + // exchange messages + const isMessage = ({contactId}: T.Contact, msg: string) => (evt: CEvt.NewChatItems) => + evt.chatItems.some(ci => ci.chatInfo.type === CT.Direct && ci.chatInfo.contact.contactId === contactId && ci.chatItem.meta.itemText === msg) + await alice.apiSendTextMessage([CT.Direct, bobContact.contactId], "hello") + await bob.wait("newChatItems", isMessage(aliceContact, "hello")) + await bob.apiSendTextMessage([CT.Direct, aliceContact.contactId], "hello too") + await alice.wait("newChatItems", isMessage(bobContact, "hello too"), 10000) + await alice.apiSendTextMessage([CT.Direct, bobContact.contactId], "how are you?") + await bob.wait("newChatItems", isMessage(aliceContact, "how are you?")) + await bob.apiSendTextMessage([CT.Direct, aliceContact.contactId], "ok, and you?") + await alice.wait("newChatItems", isMessage(bobContact, "ok, and you?"), 10000) + // no more messages + await expect(alice.wait("newChatItems", 500)).resolves.toBeUndefined() + await expect(bob.wait("newChatItems", 500)).resolves.toBeUndefined() + // delete contacts, stop chat controllers and close databases + await alice.apiDeleteChat(CT.Direct, bobContact.contactId) + await bob.wait("contactDeletedByContact") + await bob.apiDeleteChat(CT.Direct, aliceContact.contactId) + await alice.stopChat() + await bob.stopChat() + await alice.close() + await bob.close() + await expect(alice.startChat).rejects.toThrow() + await expect(bob.startChat).rejects.toThrow() + expect(servers.length).toBe(2) + expect(servers[0] !== servers[1]).toBe(true) + expect(eventCount > 0).toBe(true) + }, 30000) + + it("should create member contact and send invitation", async () => { + // create 3 users and start chat controllers + const alice = await api.ChatApi.init({type: "sqlite", filePrefix: alicePath}) + const bob = await api.ChatApi.init({type: "sqlite", filePrefix: bobPath}) + const carolPath = path.join(tmpDir, "carol") + const carol = await api.ChatApi.init({type: "sqlite", filePrefix: carolPath}) + const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""}) + await bob.apiCreateActiveUser({displayName: "bob", fullName: ""}) + await carol.apiCreateActiveUser({displayName: "carol", fullName: ""}) + await alice.startChat() + await bob.startChat() + await carol.startChat() + // connect alice <-> bob + const aliceLink1 = await alice.apiCreateLink(aliceUser.userId) + await expect(bob.apiConnectActiveUser(aliceLink1)).resolves.toBe(api.ConnReqType.Invitation) + const [bobContact] = await Promise.all([ + (await alice.wait("contactConnected")).contact, + (await bob.wait("contactConnected")).contact + ]) + // connect alice <-> carol + const aliceLink2 = await alice.apiCreateLink(aliceUser.userId) + await expect(carol.apiConnectActiveUser(aliceLink2)).resolves.toBe(api.ConnReqType.Invitation) + const [carolContact] = await Promise.all([ + (await alice.wait("contactConnected")).contact, + (await carol.wait("contactConnected")).contact + ]) + // create group with direct messages enabled + const group = await alice.apiNewGroup(aliceUser.userId, { + displayName: "test-group", + fullName: "", + groupPreferences: { + directMessages: {enable: T.GroupFeatureEnabled.On}, + }, + }) + const groupId = group.groupId + // add bob to the group + const bobInvP = bob.wait("receivedGroupInvitation", 15000) + await alice.apiAddMember(groupId, bobContact.contactId, T.GroupMemberRole.Member) + const bobInvEvt = await bobInvP + expect(bobInvEvt).toBeDefined() + const aliceBobConnP = alice.wait("connectedToGroupMember", 15000) + const bobAliceConnP = bob.wait("connectedToGroupMember", 15000) + await bob.apiJoinGroup(bobInvEvt!.groupInfo.groupId) + await Promise.all([aliceBobConnP, bobAliceConnP]) + // add carol to the group + const carolInvP = carol.wait("receivedGroupInvitation", 30000) + await alice.apiAddMember(groupId, carolContact.contactId, T.GroupMemberRole.Member) + const carolInvEvt = await carolInvP + expect(carolInvEvt).toBeDefined() + // wait for carol to connect to both alice and bob (and vice versa) + const bobCarolConnP = bob.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000) + const carolAliceConnP = carol.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "alice", 30000) + const carolBobConnP = carol.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "bob", 30000) + const aliceCarolConnP = alice.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000) + await carol.apiJoinGroup(carolInvEvt!.groupInfo.groupId) + await Promise.all([bobCarolConnP, carolAliceConnP, carolBobConnP, aliceCarolConnP]) + // find carol's memberId from bob's perspective + const members = await bob.apiListMembers(groupId) + const carolMember = members.find(m => m.memberProfile.displayName === "carol") + expect(carolMember).toBeDefined() + // test apiCreateMemberContact + const dmContact = await bob.apiCreateMemberContact(groupId, carolMember!.groupMemberId) + expect(dmContact).toBeDefined() + expect(dmContact.contactId).toBeDefined() + // test apiSendMemberContactInvitation + const carolDmP = carol.wait("newMemberContactReceivedInv" as CEvt.Tag, 30000) + const invContact = await bob.apiSendMemberContactInvitation(dmContact.contactId, "hello from bob") + expect(invContact).toBeDefined() + // carol should receive the member contact invitation + const carolDmEvt = await carolDmP + expect(carolDmEvt).toBeDefined() + expect((carolDmEvt as any).contact).toBeDefined() + // cleanup + await alice.stopChat() + await bob.stopChat() + await carol.stopChat() + await alice.close() + await bob.close() + await carol.close() + }, 90000) +}) diff --git a/packages/simplex-chat-nodejs/tests/bot.test.ts b/packages/simplex-chat-nodejs/tests/bot.test.ts new file mode 100644 index 0000000000..5a7faa663f --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/bot.test.ts @@ -0,0 +1,60 @@ +import * as path from "path" +import * as fs from "fs" +import * as assert from "assert" +import {CEvt, T} from "@simplex-chat/types" +import {api, bot, util} from ".." + +const CT = T.ChatType + +describe("Bot tests (use preset servers)", () => { + const tmpDir = "./tests/tmp" + const botPath = path.join(tmpDir, "bot") + const alicePath = path.join(tmpDir, "alice") + + beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true})) + afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true})) + + it("should reply to messages", async () => { + // run bot + const [chat, botUser, botAddress] = await bot.run({ + profile: {displayName: "Squaring bot", fullName: ""}, + dbOpts: {type: "sqlite", filePrefix: botPath}, + options: { + addressSettings: {welcomeMessage: "If you send me a number, I will calculate its square."}, + }, + onMessage: async (ci, content) => { + const n = +content.text + const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` : `this is not a number` + await chat.apiSendTextReply(ci, reply) + } + }) + assert(typeof botAddress === "object") + // create user + const alice = await api.ChatApi.init({type: "sqlite", filePrefix: alicePath}) + const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""}) + await alice.startChat() + // connect to bot + const [plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(botAddress.connLinkContact)) + assert(plan.type === "contactAddress") + await expect(alice.apiConnect(aliceUser.userId, false, link)).resolves.toBe(api.ConnReqType.Contact) + const [botContact, aliceContact] = await Promise.all([ + (await alice.wait("contactConnected")).contact, + (await chat.wait("contactConnected")).contact + ]) + expect(botContact.profile.displayName).toBe("Squaring bot") + // send message to bot + const isMessage = ({contactId}: T.Contact, msg: string) => (evt: CEvt.NewChatItems) => + evt.chatItems.some(ci => ci.chatInfo.type === CT.Direct && ci.chatInfo.contact.contactId === contactId && ci.chatItem.meta.itemText === msg) + await alice.apiSendTextMessage([CT.Direct, botContact.contactId], "2") + console.log("after sending message") + await alice.wait("newChatItems", isMessage(botContact, "2 * 2 = 4"), 5000) + // cleanup + await alice.apiDeleteChat(CT.Direct, botContact.contactId) + await chat.wait("contactDeletedByContact", ({contact}) => contact.contactId === aliceContact.contactId) + await chat.apiDeleteUserAddress(botUser.userId) + await chat.stopChat() + await chat.close() + await alice.stopChat() + await alice.close() + }, 30000) +}) diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts new file mode 100644 index 0000000000..141f35746d --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/core.test.ts @@ -0,0 +1,85 @@ +import * as fs from "fs"; +import * as path from "path"; +import {core} from "../src/index"; + +describe("Core tests", () => { + const tmpDir = "./tests/tmp"; + const dbPath = path.join(tmpDir, "simplex_v1"); + + beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true})); + afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true})); + + it("should initialize chat controller", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + expect(typeof ctrl).toBe("bigint"); + await expect(core.chatCloseStore(ctrl)).resolves.toBe(undefined); + + await expect(core.chatMigrateInit(dbPath, "wrong_key", core.MigrationConfirmation.YesUp)).rejects.toMatchObject({ + message: "Database or migration error (see dbMigrationError property)", + dbMigrationError: expect.objectContaining({type: "errorNotADatabase"}) + }); + }); + + it("should send command and receive event", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + + await expect(core.chatSendCmd(ctrl, "/v")).resolves.toMatchObject({ + type: "versionInfo" + }); + await expect(core.chatSendCmd(ctrl, '/debug event {"type": "chatSuspended"}')).resolves.toMatchObject({ + type: "cmdOk" + }); + + const wait = 500_000; + await expect(core.chatRecvMsgWait(ctrl, wait)).resolves.toMatchObject({ + type: "chatSuspended" + }); + await expect(core.chatRecvMsgWait(ctrl, wait)).resolves.toBe(undefined); + + await expect(core.chatSendCmd(ctrl, "/unknown")).rejects.toMatchObject({ + message: "Chat command error (see chatError property)", + chatError: expect.objectContaining({type: "error"}) + }); + + await core.chatCloseStore(ctrl); + }); + + it("should write/read encrypted file from/to buffer", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + + const filePath = path.join(tmpDir, "write_file.txt"); + const buffer = new Uint8Array([0, 1, 2]).buffer; + const cryptoArgs = await core.chatWriteFile(ctrl, filePath, buffer); + expect(typeof cryptoArgs.fileKey).toBe("string"); + expect(typeof cryptoArgs.fileNonce).toBe("string"); + + const buffer2 = await core.chatReadFile(filePath, cryptoArgs); + expect(Buffer.from(buffer2).equals(Buffer.from(buffer))).toBe(true); + + await expect(core.chatWriteFile(ctrl, path.join(tmpDir, "unknown", "unknown.txt"), buffer)).rejects.toThrow(); + await expect(core.chatReadFile(path.join(tmpDir, "unknown.txt"), cryptoArgs)).rejects.toThrow(); + + await core.chatCloseStore(ctrl); + }); + + it("should encrypt/decrypt file", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + + const unencryptedPath = path.join(tmpDir, "file_unencrypted.txt"); + fs.writeFileSync(unencryptedPath, "unencrypted\n"); + const encryptedPath = path.join(tmpDir, "file_encrypted.txt"); + const cryptoArgs = await core.chatEncryptFile(ctrl, unencryptedPath, encryptedPath); + expect(typeof cryptoArgs.fileKey).toBe("string"); + expect(typeof cryptoArgs.fileNonce).toBe("string"); + + const decryptedPath: string = path.join(tmpDir, "file_decrypted.txt"); + await expect(core.chatDecryptFile(encryptedPath, cryptoArgs, decryptedPath)).resolves.toBe(undefined); + + expect(fs.readFileSync(decryptedPath, "utf8")).toBe("unencrypted\n"); + + await expect(core.chatEncryptFile(ctrl, path.join(tmpDir, "unknown.txt"), encryptedPath)).rejects.toThrow(); + await expect(core.chatDecryptFile(path.join(tmpDir, "unknown.txt"), cryptoArgs, decryptedPath)).rejects.toThrow(); + + await core.chatCloseStore(ctrl); + }); +}); diff --git a/packages/simplex-chat-nodejs/tests/tsconfig.json b/packages/simplex-chat-nodejs/tests/tsconfig.json new file mode 100644 index 0000000000..47e973f3af --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*", "../src/**/*"] +} diff --git a/packages/simplex-chat-nodejs/tests/util.test.ts b/packages/simplex-chat-nodejs/tests/util.test.ts new file mode 100644 index 0000000000..4fe3140edc --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/util.test.ts @@ -0,0 +1,37 @@ +import {T} from "@simplex-chat/types" +import {ciBotCommand} from "../src/util" + +function rcvText(text: string): T.ChatItem { + return {content: {type: "rcvMsgContent", msgContent: {type: "text", text}}} as T.ChatItem +} + +describe("ciBotCommand", () => { + it("parses a command at the start of the message", () => { + expect(ciBotCommand(rcvText("/grok hello"))).toEqual({keyword: "grok", params: "hello"}) + }) + + it("returns undefined for a slash in the middle of a word", () => { + expect(ciBotCommand(rcvText("What follow/read blog posts?"))).toBeUndefined() + }) + + it("returns undefined for a slash after a space", () => { + expect(ciBotCommand(rcvText("see /home for details"))).toBeUndefined() + }) + + it("strips leading whitespace before matching", () => { + expect(ciBotCommand(rcvText(" /grok ask this"))).toEqual({keyword: "grok", params: "ask this"}) + }) + + it("returns command with empty params when only the keyword is present", () => { + expect(ciBotCommand(rcvText("/team"))).toEqual({keyword: "team", params: ""}) + }) + + it("returns undefined for plain text without slash", () => { + expect(ciBotCommand(rcvText("hello there"))).toBeUndefined() + }) + + it("returns undefined for non-text chat item content", () => { + const ci = {content: {type: "rcvDeleted"}} as T.ChatItem + expect(ciBotCommand(ci)).toBeUndefined() + }) +}) diff --git a/packages/simplex-chat-nodejs/tsconfig.json b/packages/simplex-chat-nodejs/tsconfig.json new file mode 100644 index 0000000000..f5dc986431 --- /dev/null +++ b/packages/simplex-chat-nodejs/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2018"], + "module": "CommonJS", + "moduleResolution": "Node", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2018", + "types": ["node"] + } +} diff --git a/packages/simplex-chat-nodejs/typedoc.json b/packages/simplex-chat-nodejs/typedoc.json new file mode 100644 index 0000000000..3523115f49 --- /dev/null +++ b/packages/simplex-chat-nodejs/typedoc.json @@ -0,0 +1,14 @@ +{ + "name": "simplex-chat", + "plugin": ["typedoc-plugin-markdown"], + "entryPoints": [ + "./src/index.ts", + "../simplex-chat-client/types/typescript/src/index.ts" + ], + "entryPointStrategy": "expand", + "tsconfig": "./tsconfig.json", + "sourceLinkTemplate": "../{path}#L{line}", + "disableGit": true, + "flattenOutputFiles": true, + "out": "./docs" +} \ No newline at end of file diff --git a/packages/simplex-chat-python/.gitignore b/packages/simplex-chat-python/.gitignore new file mode 100644 index 0000000000..5d5acffbb9 --- /dev/null +++ b/packages/simplex-chat-python/.gitignore @@ -0,0 +1,22 @@ +# Python build / cache artifacts — never commit these +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.pyright_cache/ + +# Virtual environments +.venv/ +.venv-*/ +venv/ + +# Lazy-downloaded native libs (handled at runtime by _native._resolve_libs_dir) +libs/ + +# Local override for SIMPLEX_LIBS_DIR work, etc. +.env diff --git a/packages/simplex-chat-python/LICENSE b/packages/simplex-chat-python/LICENSE new file mode 100644 index 0000000000..0ad25db4bd --- /dev/null +++ b/packages/simplex-chat-python/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/simplex-chat-python/README.md b/packages/simplex-chat-python/README.md new file mode 100644 index 0000000000..b86886d591 --- /dev/null +++ b/packages/simplex-chat-python/README.md @@ -0,0 +1,70 @@ +# SimpleX Chat Python library + +Python 3.11+ client for [SimpleX Chat](https://simplex.chat) bots. Equivalent to the [Node.js library](https://www.npmjs.com/package/simplex-chat). + +## Install + +```bash +pip install simplex-chat +``` + +The native `libsimplex` is downloaded lazily on first use. To pre-fetch: + +```bash +python -m simplex_chat install # sqlite (default) +python -m simplex_chat install --backend postgres # linux-x86_64 only +``` + +## Quick start + +```python +import re +from simplex_chat import Bot, BotProfile, Message, SqliteDb, TextMessage + +bot = Bot( + profile=BotProfile(display_name="Squaring bot"), + db=SqliteDb(file_prefix="./squaring_bot"), + welcome="Send me a number, I'll square it.", +) + +@bot.on_message(content_type="text", text=re.compile(r"^-?\d+(\.\d+)?$")) +async def square(msg: TextMessage) -> None: + n = float(msg.text or "0") + await msg.reply(f"{n} * {n} = {n * n}") + +@bot.on_message(content_type="text") +async def fallback(msg: Message) -> None: + await msg.reply("Send me a number, like 7 or 3.14.") + +if __name__ == "__main__": + bot.run() +``` + +`bot.run()` blocks. The connection address is logged on startup — paste it into a SimpleX client to talk to the bot. `Ctrl+C` to stop. + +Three decorators: `@bot.on_message(...)`, `@bot.on_command(name)`, `@bot.on_event(tag)`. Message handlers are first-match-wins in registration order, so register specific filters first and catch-alls last. + +See [`examples/squaring_bot.py`](./examples/squaring_bot.py) for the full example. + +## Development + +```bash +uv venv && source .venv/bin/activate +uv pip install -e '.[dev]' +ruff check && pyright && pytest tests/ +``` + +Wire types under `src/simplex_chat/types/_*.py` are generated. Regenerate with `cabal test simplex-chat-test --test-options='--match Python'`. + +## Release + +Manual for now. Bump `_version.py:__version__`, build a wheel, upload to PyPI: + +```bash +uv build --wheel +uv publish --token "$PYPI_TOKEN" +``` + +## License + +[AGPL-3.0](./LICENSE) diff --git a/packages/simplex-chat-python/examples/squaring_bot.py b/packages/simplex-chat-python/examples/squaring_bot.py new file mode 100644 index 0000000000..296b51347e --- /dev/null +++ b/packages/simplex-chat-python/examples/squaring_bot.py @@ -0,0 +1,52 @@ +"""Squaring bot — replies to every number with its square. + +Run with the simplex-chat package installed: + + python examples/squaring_bot.py + +Sends `n * n = ...` for any text message that parses as a number; falls +back to a hint for non-number messages; responds to `/help` with usage. +""" + +from __future__ import annotations + +import re + +from simplex_chat import ( + Bot, + BotCommand, + BotProfile, + Message, + ParsedCommand, + SqliteDb, + TextMessage, +) + +bot = Bot( + profile=BotProfile(display_name="Squaring bot"), + db=SqliteDb(file_prefix="./squaring_bot"), + welcome="Send me a number, I'll square it.", + commands=[BotCommand(keyword="help", label="Show help")], +) + +NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$") + + +@bot.on_message(content_type="text", text=NUMBER_RE) +async def square(msg: TextMessage) -> None: + n = float(msg.text or "0") + await msg.reply(f"{n} * {n} = {n * n}") + + +@bot.on_message(content_type="text") +async def fallback(msg: Message) -> None: + await msg.reply("Send me a number, like 7 or 3.14.") + + +@bot.on_command("help") +async def help_cmd(msg: Message, _cmd: ParsedCommand) -> None: + await msg.reply("Send a number, I'll square it.") + + +if __name__ == "__main__": + bot.run() diff --git a/packages/simplex-chat-python/pyproject.toml b/packages/simplex-chat-python/pyproject.toml new file mode 100644 index 0000000000..76f22cdabe --- /dev/null +++ b/packages/simplex-chat-python/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "simplex-chat" +description = "SimpleX Chat Python library for chat bots" +readme = "README.md" +license = "AGPL-3.0-only" +authors = [{name = "SimpleX Chat"}] +requires-python = ">=3.11" +keywords = ["simplex", "messenger", "chat", "privacy", "security", "bots"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Communications :: Chat", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-python" +Issues = "https://github.com/simplex-chat/simplex-chat/issues" + +[project.optional-dependencies] +test = ["pytest>=8", "pytest-asyncio>=0.23"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"] + +[tool.hatch.version] +path = "src/simplex_chat/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/simplex_chat"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.format] +# `src/simplex_chat/types/*.py` are generated by the Haskell codegen +# (bots/src/API/Docs/Generate/Python.hs). Re-formatting them locally +# would diverge from the generator's output and break `cabal test +# simplex-chat-test --match Python`. Lint still applies — only format +# is suppressed. +exclude = ["src/simplex_chat/types/_*.py"] + +[tool.pyright] +# Same rationale: the generated cmd_string helpers use `self.get('x')` +# call pairs that pyright cannot narrow across (`is not None` followed +# by re-access). Hand-written code is still strictly checked. +include = ["src/simplex_chat"] +exclude = ["src/simplex_chat/types/_*.py", "**/__pycache__", "**/.venv*"] diff --git a/packages/simplex-chat-python/src/simplex_chat/__init__.py b/packages/simplex-chat-python/src/simplex_chat/__init__.py new file mode 100644 index 0000000000..c353b74935 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/__init__.py @@ -0,0 +1,72 @@ +"""SimpleX Chat — Python client library for chat bots.""" + +from ._version import __version__ +from .api import ( + ChatApi, + ChatCommandError, + ConnReqType, + ContactAlreadyExistsError, + Db, + PostgresDb, + SqliteDb, +) +from .bot import ( + Bot, + BotCommand, + BotProfile, + ChatMessage, + Client, + CommandHandler, + EventHandler, + FileMessage, + ImageMessage, + LinkMessage, + Message, + MessageHandler, + Middleware, + ParsedCommand, + Profile, + ReportMessage, + TextMessage, + UnknownMessage, + VideoMessage, + VoiceMessage, +) +from .core import ChatAPIError, ChatInitError, CryptoArgs, MigrationConfirmation +from . import util as util # re-export the util namespace + +__all__ = [ + "__version__", + "Bot", + "BotCommand", + "BotProfile", + "ChatAPIError", + "ChatApi", + "ChatCommandError", + "ChatInitError", + "ChatMessage", + "Client", + "CommandHandler", + "ConnReqType", + "ContactAlreadyExistsError", + "CryptoArgs", + "Db", + "EventHandler", + "FileMessage", + "ImageMessage", + "LinkMessage", + "Message", + "MessageHandler", + "Middleware", + "MigrationConfirmation", + "ParsedCommand", + "PostgresDb", + "Profile", + "ReportMessage", + "SqliteDb", + "TextMessage", + "UnknownMessage", + "VideoMessage", + "VoiceMessage", + "util", +] diff --git a/packages/simplex-chat-python/src/simplex_chat/__main__.py b/packages/simplex-chat-python/src/simplex_chat/__main__.py new file mode 100644 index 0000000000..2fa4f3cd37 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/__main__.py @@ -0,0 +1,35 @@ +"""CLI: ``python -m simplex_chat install [--backend=sqlite|postgres]``.""" + +from __future__ import annotations + +import argparse +import sys + +from . import _native + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="simplex_chat") + sub = p.add_subparsers(dest="command", required=True) + install = sub.add_parser("install", help="Pre-fetch libsimplex into the user cache") + install.add_argument( + "--backend", + choices=["sqlite", "postgres"], + default="sqlite", + help="which libsimplex variant to download (default: sqlite)", + ) + args = p.parse_args(argv) + # `args.command` is always set: `add_subparsers(required=True)` makes + # argparse exit before reaching this point if no subcommand is given. + assert args.command == "install" + try: + path = _native._resolve_libs_dir(args.backend) + print(f"libsimplex installed at: {path}") + return 0 + except Exception as e: + print(f"install failed: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py new file mode 100644 index 0000000000..313c606883 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -0,0 +1,257 @@ +"""Native libsimplex loader: platform detection, lazy download, ctypes setup. + +Internal — users interact with `Bot` / `ChatApi`, never with this module. +""" + +from __future__ import annotations + +import ctypes +import errno +import os +import platform +import sys +import tempfile +import threading +import urllib.request +import zipfile +from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p +from pathlib import Path +from typing import Literal + +from ._version import LIBS_VERSION + +Backend = Literal["sqlite", "postgres"] + +_GITHUB_REPO = "simplex-chat/simplex-chat-libs" + +_PLATFORM_MAP = { + "linux": ("linux", {"x86_64": "x86_64", "aarch64": "aarch64"}), + "darwin": ("macos", {"x86_64": "x86_64", "arm64": "aarch64"}), + "win32": ("windows", {"AMD64": "x86_64", "x86_64": "x86_64"}), +} + +_LIBNAME = {"linux": "libsimplex.so", "darwin": "libsimplex.dylib", "win32": "libsimplex.dll"} + +SUPPORTED = ( + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", + "windows-x86_64", +) + + +def _platform_tag() -> str: + info = _PLATFORM_MAP.get(sys.platform) + if not info: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + sysname, archs = info + arch = archs.get(platform.machine()) + if not arch: + raise RuntimeError(f"Unsupported architecture: {sys.platform}/{platform.machine()}") + tag = f"{sysname}-{arch}" + if tag not in SUPPORTED: + raise RuntimeError(f"Unsupported combination: {tag}; supported: {SUPPORTED}") + return tag + + +def _libname() -> str: + return _LIBNAME[sys.platform] + + +def _libs_url(backend: Backend) -> str: + suffix = "-postgres" if backend == "postgres" else "" + return ( + f"https://github.com/{_GITHUB_REPO}/releases/download/" + f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip" + ) + + +def _cache_root() -> Path: + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / "simplex-chat" + if sys.platform == "win32": + return Path(os.environ["LOCALAPPDATA"]) / "simplex-chat" + base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(base) / "simplex-chat" + + +def _resolve_libs_dir(backend: Backend) -> Path: + if override := os.environ.get("SIMPLEX_LIBS_DIR"): + return Path(override) + if backend == "postgres" and _platform_tag() != "linux-x86_64": + raise RuntimeError( + "postgres backend is only supported on linux-x86_64; " + f"current platform is {_platform_tag()}" + ) + target = _cache_root() / f"v{LIBS_VERSION}" / backend + if not (target / _libname()).exists(): + _download(target, backend) + return target + + +_DOWNLOAD_CHUNK = 1 << 16 # 64 KiB + + +def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None: + """Stream `url` → `dest`, printing a carriage-return progress bar. + + `timeout` is per-request; we don't touch `socket.setdefaulttimeout` + so other socket users in the same process aren't affected. + """ + with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - https://github.com/... + total = int(resp.headers.get("Content-Length") or 0) + received = 0 + with dest.open("wb") as out: + while chunk := resp.read(_DOWNLOAD_CHUNK): + out.write(chunk) + received += len(chunk) + if total > 0: + pct = min(100, received * 100 // total) + msg = f"\r download: {received >> 20} / {total >> 20} MiB ({pct}%)" + else: + msg = f"\r download: {received >> 20} MiB" + print(msg, end="", file=sys.stderr, flush=True) + print("", file=sys.stderr, flush=True) # newline after final progress line + + +def _download(target: Path, backend: Backend) -> None: + """Download libs zip → atomic rename into `target`. Concurrent processes safe. + + Atomicity strategy: each process extracts to its own sibling tempdir on the same + filesystem, then `os.rename` the `libs/` subdir to `target`. POSIX `os.rename` + onto a NON-EXISTENT path is atomic; if the target exists (another process won + the race), `os.rename` fails on most platforms — we then verify the winner has + what we need and proceed. NEVER rmtree the target: that creates a TOCTOU + window where another process is reading/loading the file we're deleting. + """ + target.parent.mkdir(parents=True, exist_ok=True) + url = _libs_url(backend) + print( + f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...", + file=sys.stderr, + flush=True, + ) + with tempfile.TemporaryDirectory(dir=target.parent) as tmp: + zip_path = Path(tmp) / "libs.zip" + _stream_to_file(url, zip_path, timeout=60.0) + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(tmp) + # zip layout: /libs/libsimplex.* + libHS*.* + extracted_libs = Path(tmp) / "libs" + if not extracted_libs.is_dir(): + raise RuntimeError(f"libs/ missing from {_libs_url(backend)}") + try: + os.rename(extracted_libs, target) + except OSError as e: + # EEXIST / ENOTEMPTY mean another process won the race — fall through + # and check that the winner left a usable libsimplex behind. Anything + # else (ENOSPC, EACCES, EROFS, Windows codes mapped to None) is a real + # failure and must propagate. Same VERSION cached → same content → + # safe to proceed once we've confirmed the file is there. + if e.errno not in (errno.EEXIST, errno.ENOTEMPTY): + raise + if not (target / _libname()).exists(): + raise RuntimeError( + f"another process partially populated {target} but libsimplex " + f"is missing; remove the directory manually and retry" + ) from e + + +_lock = threading.Lock() +_lib: ctypes.CDLL | None = None +_libc: ctypes.CDLL | None = None +_backend: Backend | None = None + + +def _load_libc() -> ctypes.CDLL: + if sys.platform == "win32": + return ctypes.CDLL("msvcrt") + return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table + + +def _setup_signatures(lib: ctypes.CDLL) -> None: + """Declare argtypes/restype for the 8 chat_* functions exported by libsimplex. + + All result strings come back as raw c_void_p so the caller can free them + after copying — matches HandleCResult in cpp/simplex.cc:157-165. + """ + lib.chat_migrate_init.argtypes = [c_char_p, c_char_p, c_char_p, POINTER(c_void_p)] + lib.chat_migrate_init.restype = c_void_p + lib.chat_close_store.argtypes = [c_void_p] + lib.chat_close_store.restype = c_void_p + lib.chat_send_cmd.argtypes = [c_void_p, c_char_p] + lib.chat_send_cmd.restype = c_void_p + lib.chat_recv_msg_wait.argtypes = [c_void_p, c_int] + lib.chat_recv_msg_wait.restype = c_void_p + # chat_write_file's payload is treated read-only by libsimplex; passing + # `bytes` via c_char_p avoids the from_buffer_copy doubling. ctypes pins + # the bytes buffer for the duration of the call. + lib.chat_write_file.argtypes = [c_void_p, c_char_p, c_char_p, c_int] + lib.chat_write_file.restype = c_void_p + lib.chat_read_file.argtypes = [c_char_p, c_char_p, c_char_p] + lib.chat_read_file.restype = POINTER(c_uint8) + lib.chat_encrypt_file.argtypes = [c_void_p, c_char_p, c_char_p] + lib.chat_encrypt_file.restype = c_void_p + lib.chat_decrypt_file.argtypes = [c_char_p, c_char_p, c_char_p, c_char_p] + lib.chat_decrypt_file.restype = c_void_p + + +def _hs_init(lib: ctypes.CDLL) -> None: + """Initialize the Haskell runtime exactly once. Mirrors cpp/simplex.cc:13-32.""" + if sys.platform == "win32": + argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"--install-signal-handlers=no"] + else: + argv_strs = [ + b"simplex", + b"+RTS", + b"-A64m", + b"-H64m", + b"-xn", + b"--install-signal-handlers=no", + ] + argc = c_int(len(argv_strs)) + arr = (c_char_p * (len(argv_strs) + 1))(*argv_strs, None) + arr_ptr = ctypes.byref(ctypes.cast(arr, POINTER(c_char_p))) + lib.hs_init_with_rtsopts.argtypes = [POINTER(c_int), POINTER(POINTER(c_char_p))] + lib.hs_init_with_rtsopts.restype = None + lib.hs_init_with_rtsopts(ctypes.byref(argc), arr_ptr) + + +def lib_for(backend: Backend) -> ctypes.CDLL: + """Resolve, load, and initialize libsimplex for the given backend. + + Idempotent for the same backend; raises if called with a different backend. + Concurrent calls serialize on the module-level lock. + """ + global _lib, _libc, _backend + with _lock: + if _lib is not None: + if _backend != backend: + raise RuntimeError( + f"libsimplex already loaded with backend={_backend!r}; " + f"cannot switch to {backend!r} in the same process" + ) + return _lib + libs_dir = _resolve_libs_dir(backend) + lib = ctypes.CDLL(str(libs_dir / _libname())) + _setup_signatures(lib) + _hs_init(lib) + _libc = _load_libc() + _lib = lib + _backend = backend + return lib + + +def libc() -> ctypes.CDLL: + """libc — needed by `core` to free Haskell-allocated result strings.""" + if _libc is None: + raise RuntimeError("lib_for() must be called before libc()") + return _libc + + +def lib() -> ctypes.CDLL: + """Loaded libsimplex handle. Raises if `lib_for()` has not been called.""" + if _lib is None: + raise RuntimeError("lib_for() must be called before lib()") + return _lib diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py new file mode 100644 index 0000000000..bd182d0240 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -0,0 +1,9 @@ +"""Single source of truth for both the Python package version and the +simplex-chat-libs release tag we depend on. + +Bump both together for normal releases. For wrapper-only fixes use a PEP 440 +post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. +""" + +__version__ = "6.5.2" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.2" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py new file mode 100644 index 0000000000..ef37e28384 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -0,0 +1,720 @@ +"""Low-level escape-hatch API. Most users go through `Bot` instead.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Literal + +from . import _native, core, util +from .core import MigrationConfirmation +from .types import CC, CEvt, CR, T + +# Mirrors Node `ConnReqType` enum (api.ts:15-18) — the two possible outcomes +# of `api_connect` / `api_connect_active_user` depending on the link kind. +ConnReqType = Literal["invitation", "contact"] + + +@dataclass(slots=True) +class SqliteDb: + file_prefix: str + encryption_key: str | None = None + + +@dataclass(slots=True) +class PostgresDb: + connection_string: str + schema_prefix: str | None = None + + +Db = SqliteDb | PostgresDb + + +def _db_to_migrate_args(db: Db) -> tuple[str, str, _native.Backend]: + """Returns (path-or-prefix, key-or-conn, backend).""" + if isinstance(db, SqliteDb): + return (db.file_prefix, db.encryption_key or "", "sqlite") + if isinstance(db, PostgresDb): + return (db.schema_prefix or "", db.connection_string, "postgres") + raise TypeError(f"Unknown db: {db!r}") + + +class ChatCommandError(Exception): + """A chat command returned an unexpected response type. + + `response` is the raw wire response; `response_type` exposes its `type` + discriminator for quick checks. Subclasses cover known recoverable cases + so callers can `except ContactAlreadyExistsError` instead of inspecting + `response.get("type")` themselves. + """ + + def __init__(self, message: str, response: CR.ChatResponse): + super().__init__(message) + self.response = response + + @property + def response_type(self) -> str: + return self.response.get("type", "") # type: ignore[return-value] + + +class ContactAlreadyExistsError(ChatCommandError): + """`api_connect`/`api_connect_active_user` was called for an existing contact.""" + + +class ChatApi: + def __init__(self, ctrl: int): + self._ctrl: int | None = ctrl + self._started = False + + @classmethod + async def init( + cls, + db: Db, + confirm: MigrationConfirmation = MigrationConfirmation.YES_UP, + ) -> "ChatApi": + path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db) + # Trigger lazy lib load with the right backend BEFORE chat_migrate_init. + _native.lib_for(backend) + ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm) + return cls(ctrl) + + @property + def ctrl(self) -> int: + """Opaque controller pointer. Raises if `close()` has been called.""" + if self._ctrl is None: + raise RuntimeError("ChatApi controller not initialized (close() called?)") + return self._ctrl + + @property + def initialized(self) -> bool: + """True until `close()` is called. Mirrors Node `ChatApi.initialized`.""" + return self._ctrl is not None + + @property + def started(self) -> bool: + """True between `start_chat()` and the next `stop_chat()` / `close()`.""" + return self._started + + async def start_chat(self) -> None: + r = await self.send_chat_cmd( + CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True}) + ) + if r.get("type") not in ("chatStarted", "chatRunning"): + raise ChatCommandError("error starting chat", r) + self._started = True + + async def stop_chat(self) -> None: + r = await self.send_chat_cmd("/_stop") + if r.get("type") != "chatStopped": + raise ChatCommandError("error stopping chat", r) + self._started = False + + async def close(self) -> None: + await core.chat_close_store(self.ctrl) + self._ctrl = None + self._started = False + + async def send_chat_cmd(self, cmd: str) -> CR.ChatResponse: + return await core.chat_send_cmd(self.ctrl, cmd) + + async def recv_chat_event(self, wait_us: int = 500_000) -> CEvt.ChatEvent | None: + return await core.chat_recv_msg_wait(self.ctrl, wait_us) + + # ------------------------------------------------------------------ # + # Address commands + # ------------------------------------------------------------------ # + + async def api_create_user_address(self, user_id: int) -> T.CreatedConnLink: + r = await self.send_chat_cmd(CC.APICreateMyAddress_cmd_string({"userId": user_id})) + if r["type"] == "userContactLinkCreated": + return r["connLinkContact"] + raise ChatCommandError("error creating user address", r) + + async def api_delete_user_address(self, user_id: int) -> None: + r = await self.send_chat_cmd(CC.APIDeleteMyAddress_cmd_string({"userId": user_id})) + if r["type"] != "userContactLinkDeleted": + raise ChatCommandError("error deleting user address", r) + + async def api_get_user_address(self, user_id: int) -> T.UserContactLink | None: + try: + r = await self.send_chat_cmd(CC.APIShowMyAddress_cmd_string({"userId": user_id})) + if r["type"] == "userContactLink": + return r["contactLink"] + raise ChatCommandError("error loading user address", r) + except core.ChatAPIError as e: + ce = e.chat_error + if ( + ce is not None + and ce.get("type") == "errorStore" + and ce.get("storeError", {}).get("type") == "userContactLinkNotFound" + ): + return None + raise + + async def api_set_profile_address( + self, user_id: int, enable: bool + ) -> T.UserProfileUpdateSummary: + r = await self.send_chat_cmd( + CC.APISetProfileAddress_cmd_string({"userId": user_id, "enable": enable}) + ) + if r["type"] == "userProfileUpdated": + return r["updateSummary"] + raise ChatCommandError("error setting profile address", r) + + async def api_set_address_settings(self, user_id: int, settings: T.AddressSettings) -> None: + r = await self.send_chat_cmd( + CC.APISetAddressSettings_cmd_string({"userId": user_id, "settings": settings}) + ) + if r["type"] != "userContactLinkUpdated": + raise ChatCommandError("error changing user contact address settings", r) + + # ------------------------------------------------------------------ # + # Message commands + # ------------------------------------------------------------------ # + + async def api_send_messages( + self, + chat: list | T.ChatRef | T.ChatInfo, + messages: list[T.ComposedMessage], + live_message: bool = False, + ) -> list[T.AChatItem]: + if isinstance(chat, list): + send_ref: T.ChatRef = {"chatType": chat[0], "chatId": chat[1]} + elif "chatType" in chat and "chatId" in chat: + send_ref = chat + else: + ref = util.chat_info_ref(chat) + if ref is None: + raise ValueError("api_send_messages: can't send messages to this chat") + send_ref = ref + r = await self.send_chat_cmd( + CC.APISendMessages_cmd_string( + { + "sendRef": send_ref, + "composedMessages": messages, + "liveMessage": live_message, + } + ) + ) + if r["type"] == "newChatItems": + return r["chatItems"] + raise ChatCommandError("unexpected response", r) + + async def api_send_text_message( + self, + chat: list | T.ChatRef | T.ChatInfo, + text: str, + in_reply_to: int | None = None, + ) -> list[T.AChatItem]: + msg: T.ComposedMessage = {"msgContent": {"type": "text", "text": text}, "mentions": {}} + if in_reply_to is not None: + msg["quotedItemId"] = in_reply_to + return await self.api_send_messages(chat, [msg]) + + async def api_send_text_reply(self, chat_item: T.AChatItem, text: str) -> list[T.AChatItem]: + return await self.api_send_text_message( + chat_item["chatInfo"], text, chat_item["chatItem"]["meta"]["itemId"] + ) + + async def api_update_chat_item( + self, + chat_type: T.ChatType, + chat_id: int, + chat_item_id: int, + msg_content: T.MsgContent, + live_message: bool = False, + ) -> T.ChatItem: + r = await self.send_chat_cmd( + CC.APIUpdateChatItem_cmd_string( + { + "chatRef": {"chatType": chat_type, "chatId": chat_id}, + "chatItemId": chat_item_id, + "liveMessage": live_message, + "updatedMessage": {"msgContent": msg_content, "mentions": {}}, + } + ) + ) + if r["type"] == "chatItemUpdated": + return r["chatItem"]["chatItem"] + raise ChatCommandError("error updating chat item", r) + + async def api_delete_chat_items( + self, + chat_type: T.ChatType, + chat_id: int, + chat_item_ids: list[int], + delete_mode: T.CIDeleteMode, + ) -> list[T.ChatItemDeletion]: + r = await self.send_chat_cmd( + CC.APIDeleteChatItem_cmd_string( + { + "chatRef": {"chatType": chat_type, "chatId": chat_id}, + "chatItemIds": chat_item_ids, + "deleteMode": delete_mode, + } + ) + ) + if r["type"] == "chatItemsDeleted": + return r["chatItemDeletions"] + raise ChatCommandError("error deleting chat item", r) + + async def api_delete_member_chat_item( + self, group_id: int, chat_item_ids: list[int] + ) -> list[T.ChatItemDeletion]: + r = await self.send_chat_cmd( + CC.APIDeleteMemberChatItem_cmd_string( + {"groupId": group_id, "chatItemIds": chat_item_ids} + ) + ) + if r["type"] == "chatItemsDeleted": + return r["chatItemDeletions"] + raise ChatCommandError("error deleting member chat item", r) + + async def api_chat_item_reaction( + self, + chat_type: T.ChatType, + chat_id: int, + chat_item_id: int, + add: bool, + reaction: T.MsgReaction, + ) -> T.ACIReaction: + r = await self.send_chat_cmd( + CC.APIChatItemReaction_cmd_string( + { + "chatRef": {"chatType": chat_type, "chatId": chat_id}, + "chatItemId": chat_item_id, + "add": add, + "reaction": reaction, + } + ) + ) + if r["type"] == "chatItemReaction": + return r["reaction"] + raise ChatCommandError("error setting item reaction", r) + + # ------------------------------------------------------------------ # + # File commands + # ------------------------------------------------------------------ # + + async def api_receive_file(self, file_id: int) -> T.AChatItem: + r = await self.send_chat_cmd( + CC.ReceiveFile_cmd_string({"fileId": file_id, "userApprovedRelays": True}) + ) + if r["type"] == "rcvFileAccepted": + return r["chatItem"] + raise ChatCommandError("error receiving file", r) + + async def api_cancel_file(self, file_id: int) -> None: + r = await self.send_chat_cmd(CC.CancelFile_cmd_string({"fileId": file_id})) + if r["type"] not in ("sndFileCancelled", "rcvFileCancelled"): + raise ChatCommandError("error canceling file", r) + + # ------------------------------------------------------------------ # + # Group commands + # ------------------------------------------------------------------ # + + async def api_add_member( + self, group_id: int, contact_id: int, member_role: T.GroupMemberRole + ) -> T.GroupMember: + r = await self.send_chat_cmd( + CC.APIAddMember_cmd_string( + {"groupId": group_id, "contactId": contact_id, "memberRole": member_role} + ) + ) + if r["type"] == "sentGroupInvitation": + return r["member"] + raise ChatCommandError("error adding member", r) + + async def api_join_group(self, group_id: int) -> T.GroupInfo: + r = await self.send_chat_cmd(CC.APIJoinGroup_cmd_string({"groupId": group_id})) + if r["type"] == "userAcceptedGroupSent": + return r["groupInfo"] + raise ChatCommandError("error joining group", r) + + async def api_accept_member( + self, group_id: int, group_member_id: int, member_role: T.GroupMemberRole + ) -> T.GroupMember: + r = await self.send_chat_cmd( + CC.APIAcceptMember_cmd_string( + {"groupId": group_id, "groupMemberId": group_member_id, "memberRole": member_role} + ) + ) + if r["type"] == "memberAccepted": + return r["member"] + raise ChatCommandError("error accepting member", r) + + async def api_set_members_role( + self, group_id: int, group_member_ids: list[int], member_role: T.GroupMemberRole + ) -> None: + r = await self.send_chat_cmd( + CC.APIMembersRole_cmd_string( + {"groupId": group_id, "groupMemberIds": group_member_ids, "memberRole": member_role} + ) + ) + if r["type"] != "membersRoleUser": + raise ChatCommandError("error setting members role", r) + + async def api_block_members_for_all( + self, group_id: int, group_member_ids: list[int], blocked: bool + ) -> None: + r = await self.send_chat_cmd( + CC.APIBlockMembersForAll_cmd_string( + {"groupId": group_id, "groupMemberIds": group_member_ids, "blocked": blocked} + ) + ) + if r["type"] != "membersBlockedForAllUser": + raise ChatCommandError("error blocking members", r) + + async def api_remove_members( + self, group_id: int, member_ids: list[int], with_messages: bool = False + ) -> list[T.GroupMember]: + r = await self.send_chat_cmd( + CC.APIRemoveMembers_cmd_string( + {"groupId": group_id, "groupMemberIds": member_ids, "withMessages": with_messages} + ) + ) + if r["type"] == "userDeletedMembers": + return r["members"] + raise ChatCommandError("error removing member", r) + + async def api_leave_group(self, group_id: int) -> T.GroupInfo: + r = await self.send_chat_cmd(CC.APILeaveGroup_cmd_string({"groupId": group_id})) + if r["type"] == "leftMemberUser": + return r["groupInfo"] + raise ChatCommandError("error leaving group", r) + + async def api_list_members(self, group_id: int) -> list[T.GroupMember]: + r = await self.send_chat_cmd(CC.APIListMembers_cmd_string({"groupId": group_id})) + if r["type"] == "groupMembers": + return r["group"]["members"] + raise ChatCommandError("error getting group members", r) + + async def api_new_group(self, user_id: int, group_profile: T.GroupProfile) -> T.GroupInfo: + r = await self.send_chat_cmd( + CC.APINewGroup_cmd_string( + {"userId": user_id, "groupProfile": group_profile, "incognito": False} + ) + ) + if r["type"] == "groupCreated": + return r["groupInfo"] + raise ChatCommandError("error creating group", r) + + async def api_update_group_profile( + self, group_id: int, group_profile: T.GroupProfile + ) -> T.GroupInfo: + r = await self.send_chat_cmd( + CC.APIUpdateGroupProfile_cmd_string( + {"groupId": group_id, "groupProfile": group_profile} + ) + ) + if r["type"] == "groupUpdated": + return r["toGroup"] + raise ChatCommandError("error updating group", r) + + # ------------------------------------------------------------------ # + # Group link commands + # ------------------------------------------------------------------ # + + async def api_create_group_link(self, group_id: int, member_role: T.GroupMemberRole) -> str: + r = await self.send_chat_cmd( + CC.APICreateGroupLink_cmd_string({"groupId": group_id, "memberRole": member_role}) + ) + if r["type"] == "groupLinkCreated": + link = r["groupLink"]["connLinkContact"] + return link.get("connShortLink") or link["connFullLink"] + raise ChatCommandError("error creating group link", r) + + async def api_set_group_link_member_role( + self, group_id: int, member_role: T.GroupMemberRole + ) -> None: + r = await self.send_chat_cmd( + CC.APIGroupLinkMemberRole_cmd_string({"groupId": group_id, "memberRole": member_role}) + ) + if r["type"] != "groupLink": + raise ChatCommandError("error setting group link member role", r) + + async def api_delete_group_link(self, group_id: int) -> None: + r = await self.send_chat_cmd(CC.APIDeleteGroupLink_cmd_string({"groupId": group_id})) + if r["type"] != "groupLinkDeleted": + raise ChatCommandError("error deleting group link", r) + + async def api_get_group_link(self, group_id: int) -> T.GroupLink: + r = await self.send_chat_cmd(CC.APIGetGroupLink_cmd_string({"groupId": group_id})) + if r["type"] == "groupLink": + return r["groupLink"] + raise ChatCommandError("error getting group link", r) + + async def api_get_group_link_str(self, group_id: int) -> str: + link = (await self.api_get_group_link(group_id))["connLinkContact"] + return link.get("connShortLink") or link["connFullLink"] + + # ------------------------------------------------------------------ # + # Connection commands + # ------------------------------------------------------------------ # + + async def api_create_link(self, user_id: int) -> str: + r = await self.send_chat_cmd( + CC.APIAddContact_cmd_string({"userId": user_id, "incognito": False}) + ) + if r["type"] == "invitation": + link = r["connLinkInvitation"] + return link.get("connShortLink") or link["connFullLink"] + raise ChatCommandError("error creating link", r) + + async def api_connect_plan( + self, user_id: int, connection_link: str + ) -> tuple[T.ConnectionPlan, T.CreatedConnLink]: + r = await self.send_chat_cmd( + CC.APIConnectPlan_cmd_string( + {"userId": user_id, "connectionLink": connection_link, "resolveKnown": False} + ) + ) + if r["type"] == "connectionPlan": + return (r["connectionPlan"], r["connLink"]) + raise ChatCommandError("error getting connect plan", r) + + async def api_connect( + self, + user_id: int, + incognito: bool, + prepared_link: T.CreatedConnLink | None = None, + ) -> ConnReqType: + args: CC.APIConnect = {"userId": user_id, "incognito": incognito} + if prepared_link is not None: + args["preparedLink_"] = prepared_link + r = await self.send_chat_cmd(CC.APIConnect_cmd_string(args)) + return self._handle_connect_result(r) + + async def api_connect_active_user(self, conn_link: str) -> ConnReqType: + r = await self.send_chat_cmd( + CC.Connect_cmd_string({"incognito": False, "connLink_": conn_link}) + ) + return self._handle_connect_result(r) + + def _handle_connect_result(self, r: CR.ChatResponse) -> ConnReqType: + if r["type"] == "sentConfirmation": + return "invitation" + if r["type"] == "sentInvitation": + return "contact" + if r["type"] == "contactAlreadyExists": + raise ContactAlreadyExistsError("contact already exists", r) + raise ChatCommandError("connection error", r) + + async def api_accept_contact_request(self, contact_req_id: int) -> T.Contact: + r = await self.send_chat_cmd( + CC.APIAcceptContact_cmd_string({"contactReqId": contact_req_id}) + ) + if r["type"] == "acceptingContactRequest": + return r["contact"] + raise ChatCommandError("error accepting contact request", r) + + async def api_reject_contact_request(self, contact_req_id: int) -> None: + r = await self.send_chat_cmd( + CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id}) + ) + if r["type"] != "contactRequestRejected": + raise ChatCommandError("error rejecting contact request", r) + + # ------------------------------------------------------------------ # + # Chat commands + # ------------------------------------------------------------------ # + + async def api_list_contacts(self, user_id: int) -> list[T.Contact]: + r = await self.send_chat_cmd(CC.APIListContacts_cmd_string({"userId": user_id})) + if r["type"] == "contactsList": + return r["contacts"] + raise ChatCommandError("error listing contacts", r) + + async def api_list_groups( + self, + user_id: int, + contact_id: int | None = None, + search: str | None = None, + ) -> list[T.GroupInfo]: + args: CC.APIListGroups = {"userId": user_id} + if contact_id is not None: + args["contactId_"] = contact_id + if search is not None: + args["search"] = search + r = await self.send_chat_cmd(CC.APIListGroups_cmd_string(args)) + if r["type"] == "groupsList": + return r["groups"] + raise ChatCommandError("error listing groups", r) + + async def api_get_chats( + self, + user_id: int, + pagination: T.PaginationByTime, + query: T.ChatListQuery | None = None, + pending_connections: bool = False, + ) -> list[T.AChat]: + if query is None: + query = {"type": "filters", "favorite": False, "unread": False} + r = await self.send_chat_cmd( + CC.APIGetChats_cmd_string( + { + "userId": user_id, + "pendingConnections": pending_connections, + "pagination": pagination, + "query": query, + } + ) + ) + if r["type"] == "apiChats": + return r["chats"] + raise ChatCommandError("error getting chats", r) + + async def api_delete_chat( + self, + chat_type: T.ChatType, + chat_id: int, + delete_mode: T.ChatDeleteMode | None = None, + ) -> None: + if delete_mode is None: + delete_mode = {"type": "full", "notify": True} + r = await self.send_chat_cmd( + CC.APIDeleteChat_cmd_string( + { + "chatRef": {"chatType": chat_type, "chatId": chat_id}, + "chatDeleteMode": delete_mode, + } + ) + ) + if chat_type == "direct" and r["type"] == "contactDeleted": + return + if chat_type == "group" and r["type"] == "groupDeletedUser": + return + raise ChatCommandError("error deleting chat", r) + + async def api_set_group_custom_data( + self, group_id: int, custom_data: dict[str, object] | None = None + ) -> None: + args: CC.APISetGroupCustomData = {"groupId": group_id} + if custom_data is not None: + args["customData"] = custom_data + r = await self.send_chat_cmd(CC.APISetGroupCustomData_cmd_string(args)) + if r["type"] != "cmdOk": + raise ChatCommandError("error setting group custom data", r) + + async def api_set_contact_custom_data( + self, contact_id: int, custom_data: dict[str, object] | None = None + ) -> None: + args: CC.APISetContactCustomData = {"contactId": contact_id} + if custom_data is not None: + args["customData"] = custom_data + r = await self.send_chat_cmd(CC.APISetContactCustomData_cmd_string(args)) + if r["type"] != "cmdOk": + raise ChatCommandError("error setting contact custom data", r) + + async def api_set_auto_accept_member_contacts(self, user_id: int, on_off: bool) -> None: + r = await self.send_chat_cmd( + CC.APISetUserAutoAcceptMemberContacts_cmd_string({"userId": user_id, "onOff": on_off}) + ) + if r["type"] != "cmdOk": + raise ChatCommandError("error setting auto-accept member contacts", r) + + async def api_get_chat(self, chat_type: T.ChatType, chat_id: int, count: int) -> dict[str, Any]: + ref = T.ChatType_cmd_string(chat_type) + str(chat_id) + r = await self.send_chat_cmd(f"/_get chat {ref} count={count}") + if r["type"] == "apiChat": + return r["chat"] + raise ChatCommandError("error getting chat", r) + + # ------------------------------------------------------------------ # + # User profile commands + # ------------------------------------------------------------------ # + + async def api_get_active_user(self) -> T.User | None: + try: + r = await self.send_chat_cmd(CC.ShowActiveUser_cmd_string({})) + if r["type"] == "activeUser": + return r["user"] + raise ChatCommandError("unexpected response", r) + except core.ChatAPIError as e: + ce = e.chat_error + if ( + ce is not None + and ce.get("type") == "error" + and ce.get("errorType", {}).get("type") == "noActiveUser" + ): + return None + raise + + async def api_create_active_user(self, profile: T.Profile | None = None) -> T.User: + new_user: T.NewUser = {"pastTimestamp": False, "userChatRelay": False} + if profile is not None: + new_user["profile"] = profile + r = await self.send_chat_cmd(CC.CreateActiveUser_cmd_string({"newUser": new_user})) + if r["type"] == "activeUser": + return r["user"] + raise ChatCommandError("unexpected response", r) + + async def api_list_users(self) -> list[T.UserInfo]: + r = await self.send_chat_cmd(CC.ListUsers_cmd_string({})) + if r["type"] == "usersList": + return r["users"] + raise ChatCommandError("error listing users", r) + + async def api_set_active_user(self, user_id: int, view_pwd: str | None = None) -> T.User: + args: CC.APISetActiveUser = {"userId": user_id} + if view_pwd is not None: + args["viewPwd"] = view_pwd + r = await self.send_chat_cmd(CC.APISetActiveUser_cmd_string(args)) + if r["type"] == "activeUser": + return r["user"] + raise ChatCommandError("error setting active user", r) + + async def api_delete_user( + self, user_id: int, del_smp_queues: bool, view_pwd: str | None = None + ) -> None: + args: CC.APIDeleteUser = {"userId": user_id, "delSMPQueues": del_smp_queues} + if view_pwd is not None: + args["viewPwd"] = view_pwd + r = await self.send_chat_cmd(CC.APIDeleteUser_cmd_string(args)) + if r["type"] != "cmdOk": + raise ChatCommandError("error deleting user", r) + + async def api_update_profile( + self, user_id: int, profile: T.Profile + ) -> T.UserProfileUpdateSummary | None: + r = await self.send_chat_cmd( + CC.APIUpdateProfile_cmd_string({"userId": user_id, "profile": profile}) + ) + if r["type"] == "userProfileNoChange": + return None + if r["type"] == "userProfileUpdated": + return r["updateSummary"] + raise ChatCommandError("error updating profile", r) + + async def api_set_contact_prefs(self, contact_id: int, preferences: T.Preferences) -> None: + r = await self.send_chat_cmd( + CC.APISetContactPrefs_cmd_string({"contactId": contact_id, "preferences": preferences}) + ) + if r["type"] != "contactPrefsUpdated": + raise ChatCommandError("error setting contact prefs", r) + + # ------------------------------------------------------------------ # + # Member contact commands + # ------------------------------------------------------------------ # + + async def api_create_member_contact(self, group_id: int, group_member_id: int) -> T.Contact: + r = await self.send_chat_cmd(f"/_create member contact #{group_id} {group_member_id}") + if r["type"] == "newMemberContact": + return r["contact"] + raise ChatCommandError("error creating member contact", r) + + async def api_send_member_contact_invitation( + self, + contact_id: int, + message: T.MsgContent | str | None = None, + ) -> T.Contact: + cmd = f"/_invite member contact @{contact_id}" + if message is not None: + if isinstance(message, str): + cmd += f" text {message}" + else: + cmd += f" json {json.dumps(message)}" + r = await self.send_chat_cmd(cmd) + if r["type"] == "newMemberContactSentInv": + return r["contact"] + raise ChatCommandError("error sending member contact invitation", r) diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py new file mode 100644 index 0000000000..fb511e2818 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -0,0 +1,178 @@ +"""`Bot` — Client extended with server-side features (address, auto-accept, commands).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import util +from .api import Db +from .client import ( + BotProfile, + ChatMessage, + Client, + CommandHandler, + EventHandler, + FileMessage, + ImageMessage, + LinkMessage, + Message, + MessageHandler, + Middleware, + ParsedCommand, + Profile, + ReportMessage, + TextMessage, + UnknownMessage, + VideoMessage, + VoiceMessage, + log, +) +from .core import MigrationConfirmation +from .types import T + + +@dataclass(slots=True) +class BotCommand: + keyword: str + label: str + + +class Bot(Client): + """SimpleX bot — Client extended with server-side features. + + On top of `Client` (identity + messaging + connect_to/send_and_wait/events), + a Bot: + - creates and announces its own contact address + - auto-accepts incoming contact requests (configurable) + - advertises a list of slash-commands in its profile preferences + - sets `peerType=bot` and disables calls/voice in profile prefs + - sends a `welcome` message to new contacts via the auto-reply address setting + + If you want just identity + messaging without any of that, use `Client` + directly. + """ + + def __init__( + self, + *, + profile: Profile, + db: Db, + welcome: str | T.MsgContent | None = None, + commands: list[BotCommand] | None = None, + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + create_address: bool = True, + update_address: bool = True, + update_profile: bool = True, + auto_accept: bool = True, + business_address: bool = False, + allow_files: bool = False, + log_contacts: bool = True, + log_network: bool = False, + ) -> None: + super().__init__( + profile=profile, + db=db, + confirm_migrations=confirm_migrations, + update_profile=update_profile, + log_contacts=log_contacts, + log_network=log_network, + ) + self._welcome = welcome + self._commands = commands or [] + self._create_address = create_address + self._update_address = update_address + self._auto_accept = auto_accept + self._business_address = business_address + self._allow_files = allow_files + + # ------------------------------------------------------------------ # + # Profile + address sync (overrides hooks in Client) + # ------------------------------------------------------------------ # + + async def _post_start(self, user: T.User) -> None: + """Bots sync address first, then embed the link in the profile.""" + link = await self._sync_address(user) + await self._maybe_sync_profile(user, contact_link=link) + + async def _sync_address(self, user: T.User) -> str | None: + """Address sync. Returns the public link if any, for embedding in the profile.""" + api = self.api + user_id = user["userId"] + + address = await api.api_get_user_address(user_id) + if address is None: + if self._create_address: + log.info("Bot has no address, creating...") + await api.api_create_user_address(user_id) + address = await api.api_get_user_address(user_id) + if address is None: + raise RuntimeError("Failed reading newly created user address") + else: + log.warning("Bot has no address") + + link: str | None = None + if address is not None: + link = util.contact_address_str(address["connLinkContact"]) + log.info("Bot address: %s", link) + + # Address settings (auto-accept + welcome message). Mirrors bot.ts:185-194. + # autoAccept present → accept; absent → no auto-accept (mirrors Node bot.ts). + if address is not None and self._update_address: + desired: T.AddressSettings = {"businessAddress": self._business_address} + if self._auto_accept: + desired["autoAccept"] = {"acceptIncognito": False} + if self._welcome is not None: + desired["autoReply"] = ( + {"type": "text", "text": self._welcome} + if isinstance(self._welcome, str) + else self._welcome + ) + if address["addressSettings"] != desired: + log.info("Bot address settings changed, updating...") + await api.api_set_address_settings(user_id, desired) + + return link + + def _profile_to_wire(self) -> T.Profile: + """Bot profile: base profile + peerType=bot, command list, calls/voice prefs disabled. + + Mirrors Node `mkBotProfile` (bot.ts:88-102). + """ + p = super()._profile_to_wire() + prefs: T.Preferences = { + "calls": {"allow": "no"}, + "voice": {"allow": "no"}, + "files": {"allow": "yes" if self._allow_files else "no"}, + } + if self._commands: + prefs["commands"] = [ + {"type": "command", "keyword": c.keyword, "label": c.label} + for c in self._commands + ] + p["preferences"] = prefs + p["peerType"] = "bot" + return p + + +__all__ = [ + "Bot", + "BotCommand", + "BotProfile", + "ChatMessage", + "Client", + "CommandHandler", + "EventHandler", + "FileMessage", + "ImageMessage", + "LinkMessage", + "Message", + "MessageHandler", + "Middleware", + "ParsedCommand", + "Profile", + "ReportMessage", + "TextMessage", + "UnknownMessage", + "VideoMessage", + "VoiceMessage", +] diff --git a/packages/simplex-chat-python/src/simplex_chat/client.py b/packages/simplex-chat-python/src/simplex_chat/client.py new file mode 100644 index 0000000000..b0d144b8b9 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/client.py @@ -0,0 +1,955 @@ +"""Base `Client` API: lifecycle, dispatch, decorators, connect_to / send_and_wait / events. + +Bot extends Client to add server-side features (address, auto-accept, welcome, +commands). Client by itself is suitable for monitors, probes, automated +participants — anything that talks TO services rather than accepting incoming +connections. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import signal as _signal +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Generic, Literal, TypeVar, overload + +from . import util +from .api import ChatApi, ChatCommandError, ContactAlreadyExistsError, Db +from .core import ChatAPIError, MigrationConfirmation +from .filters import compile_message_filter +from .types import CEvt, T + +log = logging.getLogger("simplex_chat") + +C = TypeVar("C", bound="T.MsgContent") + + +@dataclass(slots=True) +class Profile: + """SimpleX user profile fields: display name, optional full name, descr, avatar. + + Universal — used by both `Client` and `Bot`. The bot-specific extensions + (peerType=bot, command list, calls/voice preferences) are added at + wire-conversion time by `Bot`, not stored here. + """ + + display_name: str + full_name: str = "" + short_descr: str | None = None + image: str | None = None + + +# Backwards-compatibility alias — the dataclass was named `BotProfile` before +# the Client/Bot hierarchy was introduced. Keep the old name working so +# `from simplex_chat import BotProfile` doesn't break existing code. +BotProfile = Profile + + +@dataclass(slots=True, frozen=True) +class ParsedCommand: + keyword: str + args: str + + +@dataclass(slots=True, frozen=True) +class Message(Generic[C]): + chat_item: T.AChatItem + content: C + client: "Client" + + @property + def chat_info(self) -> T.ChatInfo: + return self.chat_item["chatInfo"] + + @property + def text(self) -> str | None: + c = self.content + if isinstance(c, dict): + return c.get("text") # type: ignore[return-value] + return None + + async def reply(self, text: str) -> "Message[T.MsgContent]": + items = await self.client.api.api_send_text_reply(self.chat_item, text) + ci = items[0] + content = ci["chatItem"]["content"] + # content is CIContent — snd variant has msgContent; cast for type safety. + msg_content: T.MsgContent = content["msgContent"] # type: ignore[index] + return Message(chat_item=ci, content=msg_content, client=self.client) + + async def reply_content(self, content: T.MsgContent) -> "Message[T.MsgContent]": + items = await self.client.api.api_send_messages( + self.chat_info, [{"msgContent": content, "mentions": {}}] + ) + ci = items[0] + ci_content = ci["chatItem"]["content"] + msg_content: T.MsgContent = ci_content["msgContent"] # type: ignore[index] + return Message(chat_item=ci, content=msg_content, client=self.client) + + +# Concrete narrowed aliases — one per MsgContent_ variant in _types.py. +TextMessage = Message[T.MsgContent_text] +LinkMessage = Message[T.MsgContent_link] +ImageMessage = Message[T.MsgContent_image] +VideoMessage = Message[T.MsgContent_video] +VoiceMessage = Message[T.MsgContent_voice] +FileMessage = Message[T.MsgContent_file] +ReportMessage = Message[T.MsgContent_report] +ChatMessage = Message[T.MsgContent_chat] +UnknownMessage = Message[T.MsgContent_unknown] + +MessageHandler = Callable[[Message[Any]], Awaitable[None]] +CommandHandler = Callable[[Message[Any], ParsedCommand], Awaitable[None]] +EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None]] + + +class Middleware: + """Override `__call__` to wrap message handlers with cross-cutting logic. + + `handler` is the next stage in the chain — call it with `(message, data)` + to continue, or skip the call to short-circuit. `data` is a per-dispatch + dict that middleware can use to pass values down the chain. + """ + + async def __call__( + self, + handler: Callable[[Message[Any], dict[str, object]], Awaitable[None]], + message: Message[Any], + data: dict[str, object], + ) -> None: + await handler(message, data) + + +class Client: + """SimpleX participant — has an identity, sends and receives messages. + + No address, no auto-accept of incoming requests, no bot profile prefs. Use + this for monitors, probes, automated participants — anything that talks + TO services rather than accepting incoming connections. Use `Bot` for the + server-side flavour. + + Typical pattern: + + async with Client(profile=Profile(display_name="m"), db=...) as c: + serve = asyncio.create_task(c.serve_forever()) + contact = await c.connect_to(link) + reply = await c.send_and_wait(contact["contactId"], "/help") + c.stop() + await serve + + The decorator-style handlers (`@on_message`, `@on_command`, `@on_event`) + work too if you want callback-style dispatch instead of async-await. + """ + + def __init__( + self, + *, + profile: Profile, + db: Db, + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + update_profile: bool = True, + log_contacts: bool = False, + log_network: bool = False, + ) -> None: + self._profile = profile + self._db = db + self._confirm_migrations = confirm_migrations + self._update_profile = update_profile + self._log_contacts = log_contacts + self._log_network = log_network + self._api: ChatApi | None = None + self._serving = False + self._stop_event = asyncio.Event() + self._message_handlers: list[tuple[Callable[[Message[Any]], bool], MessageHandler]] = [] + self._command_handlers: list[ + tuple[tuple[str, ...], Callable[[Message[Any]], bool], CommandHandler] + ] = [] + self._event_handlers: dict[str, list[EventHandler]] = {} + self._middleware: list[Middleware] = [] + # Track default-handler registration so __aenter__ on a re-used client + # doesn't accumulate duplicate log/error handlers. + self._defaults_registered = False + # Internal waiters used by `send_and_wait` (keyed by contact_id, FIFO + # within a contact) and `connect_to` (one-shot, resolved on the next + # contactConnected event). Populated by user-async-callers, drained + # in `_dispatch_event` before user handlers run. + self._reply_waiters: dict[int, list[asyncio.Future[Message[Any]]]] = {} + self._connect_waiters: list[asyncio.Future[T.Contact]] = [] + + @property + def api(self) -> ChatApi: + if self._api is None: + raise RuntimeError("Client not initialized — call run() or use `async with client:`") + return self._api + + # ------------------------------------------------------------------ # + # Decorators + # ------------------------------------------------------------------ # + + @overload + def on_message( + self, *, content_type: Literal["text"], **rest: Any + ) -> Callable[ + [Callable[[TextMessage], Awaitable[None]]], + Callable[[TextMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["link"], **rest: Any + ) -> Callable[ + [Callable[[LinkMessage], Awaitable[None]]], + Callable[[LinkMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["image"], **rest: Any + ) -> Callable[ + [Callable[[ImageMessage], Awaitable[None]]], + Callable[[ImageMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["video"], **rest: Any + ) -> Callable[ + [Callable[[VideoMessage], Awaitable[None]]], + Callable[[VideoMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["voice"], **rest: Any + ) -> Callable[ + [Callable[[VoiceMessage], Awaitable[None]]], + Callable[[VoiceMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["file"], **rest: Any + ) -> Callable[ + [Callable[[FileMessage], Awaitable[None]]], + Callable[[FileMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["report"], **rest: Any + ) -> Callable[ + [Callable[[ReportMessage], Awaitable[None]]], + Callable[[ReportMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["chat"], **rest: Any + ) -> Callable[ + [Callable[[ChatMessage], Awaitable[None]]], + Callable[[ChatMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["unknown"], **rest: Any + ) -> Callable[ + [Callable[[UnknownMessage], Awaitable[None]]], + Callable[[UnknownMessage], Awaitable[None]], + ]: ... + + @overload + def on_message(self, **rest: Any) -> Callable[[MessageHandler], MessageHandler]: ... + + def on_message(self, **filter_kw: Any) -> Callable[[MessageHandler], MessageHandler]: + predicate = compile_message_filter(filter_kw) + + def deco(fn: MessageHandler) -> MessageHandler: + self._message_handlers.append((predicate, fn)) + return fn + + return deco + + def on_command( + self, name: str | tuple[str, ...], **filter_kw: Any + ) -> Callable[[CommandHandler], CommandHandler]: + names = (name,) if isinstance(name, str) else tuple(name) + predicate = compile_message_filter(filter_kw) + + def deco(fn: CommandHandler) -> CommandHandler: + self._command_handlers.append((names, predicate, fn)) + return fn + + return deco + + # `on_event` is exposed as a property typed as the generated + # `OnEventDecorator` Protocol so per-tag narrowing applies — e.g. + # `@client.on_event("contactConnected")` types the handler's event + # parameter as `CEvt.ContactConnected`, not the unnarrowed + # `CEvt.ChatEvent` union. The Protocol's overload chain lives in + # generated code (one entry per event tag) so it stays in sync with + # the wire schema automatically. The runtime implementation is the + # plain handler-registration below. + @property + def on_event(self) -> CEvt.OnEventDecorator: + return self._register_event_handler # type: ignore[return-value] + + def _register_event_handler( + self, event: str, / + ) -> Callable[[EventHandler], EventHandler]: + def deco(fn: EventHandler) -> EventHandler: + self._event_handlers.setdefault(event, []).append(fn) + return fn + + return deco + + def use(self, middleware: Middleware) -> None: + self._middleware.append(middleware) + + # ------------------------------------------------------------------ # + # Lifecycle + # ------------------------------------------------------------------ # + + async def __aenter__(self) -> "Client": + # Order matters: libsimplex `/_start` requires an active user, so + # ensure (or create) the user first, THEN start the chat, THEN + # do post-start setup (profile sync; Bot adds address sync). + # Clear `_stop_event` here (not in `serve_forever`/`events`) so that + # a `stop()` call landing between `__aenter__` and the receive loop + # — e.g. a signal handler firing while signal handlers are being + # wired up — is preserved and causes the loop to exit immediately + # on entry. + self._stop_event.clear() + self._api = await ChatApi.init(self._db, self._confirm_migrations) + try: + user = await self._ensure_active_user() + await self._api.start_chat() + await self._post_start(user) + self._register_log_handlers() + return self + except BaseException: + # __aexit__ is only called when __aenter__ returns successfully — + # roll back the open chat controller here so a failure during + # init doesn't leak the FFI resource. + await self._shutdown_partial_init() + raise + + async def _shutdown_partial_init(self) -> None: + """Best-effort teardown for an `__aenter__` that didn't reach return.""" + api = self._api + if api is None: + return + if api.started: + try: + await api.stop_chat() + except Exception: + log.exception("stop_chat failed during init rollback") + try: + await api.close() + except Exception: + log.exception("close failed during init rollback") + self._api = None + + async def __aexit__(self, *exc_info: object) -> None: + self.stop() + api = self._api + if api is None: + return + # Null out the reference up-front so the Client appears closed even + # if stop_chat / close raise — otherwise `client.api` would still + # hand back a half-shutdown controller after `async with` exits. + self._api = None + try: + await api.stop_chat() + finally: + await api.close() + + async def _post_start(self, user: T.User) -> None: + """Hook for subclasses to add work between `start_chat` and serving. + + Default (Client): sync profile only. Bot overrides to also sync its + address and embed the connection link in the profile. + """ + await self._maybe_sync_profile(user, contact_link=None) + + def run(self) -> None: + """Blocking entry: runs serve_forever() with SIGINT/SIGTERM handlers installed. + + Configures `logging.basicConfig(level=INFO)` if the root logger has no + handlers yet, so startup messages and the announced address are + visible without callers having to set up logging. Embedders that + manage logging themselves are unaffected (basicConfig is a no-op when + handlers already exist). + """ + if not logging.getLogger().handlers: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + + async def _main() -> None: + async with self: + loop = asyncio.get_running_loop() + # First Ctrl+C → graceful stop (~500ms, bounded by the + # receive-loop poll interval). Second Ctrl+C → force-exit + # immediately (in case stop_chat / close hang on a wedged + # FFI call). Standard CLI UX (jupyter, ipython, …). + sigint_count = 0 + + def on_interrupt() -> None: + nonlocal sigint_count + sigint_count += 1 + if sigint_count == 1: + log.info("stopping... (press Ctrl+C again to force exit)") + self.stop() + else: + os._exit(130) # 128 + SIGINT + + if hasattr(_signal, "SIGINT"): + try: + loop.add_signal_handler(_signal.SIGINT, on_interrupt) + loop.add_signal_handler(_signal.SIGTERM, self.stop) + except NotImplementedError: # Windows + _signal.signal(_signal.SIGINT, lambda *_: on_interrupt()) + await self.serve_forever() + + asyncio.run(_main()) + + async def serve_forever(self) -> None: + if self._serving: + raise RuntimeError("already serving") + self._serving = True + try: + await self._receive_loop() + finally: + self._serving = False + + def stop(self) -> None: + self._stop_event.set() + + async def events(self) -> AsyncIterator[CEvt.ChatEvent]: + """Yield chat events one at a time — alternative to `serve_forever`. + + Runs the full dispatch pipeline on each event (internal waiters, + user `@on_event`/`@on_message`/`@on_command` handlers), then yields + the raw event for inspection. Use this when you want direct control + over the receive loop, e.g. to surface errors that `serve_forever` + would otherwise swallow, or to compose with other async iterators. + + Mutually exclusive with `serve_forever`. Stops when `stop()` is + called or when the consumer exits the `async for` loop (which + triggers the generator's `aclose`). Async-generator GC alone is + not reliable for cleanup — exit the loop explicitly. + """ + if self._serving: + raise RuntimeError( + "already serving — events() and serve_forever() are mutually exclusive" + ) + self._serving = True + try: + while not self._stop_event.is_set(): + try: + event = await self.api.recv_chat_event(wait_us=500_000) + except asyncio.CancelledError: + raise + if event is None: + continue + try: + await self._dispatch_event(event) + except asyncio.CancelledError: + raise + except Exception: + log.exception("dispatch_event failed for tag=%s", event.get("type")) + yield event + finally: + self._serving = False + + async def connect_to(self, link: str, *, timeout: float = 120.0) -> T.Contact: + """Connect to a SimpleX contact link, returning the resulting Contact. + + Idempotent: if the link is already known (via `api_connect_plan`) + the existing Contact is returned without re-handshaking. Otherwise + initiates the handshake and waits for the `contactConnected` event. + + Requires the receive loop to be running (`serve_forever` or + `events()`), since the handshake completes asynchronously. + + Concurrency caveat: pending `connect_to` waiters are a single FIFO + with no link↔waiter correlation. If you call `connect_to` for two + different links concurrently, or if a third party connects to your + address (Bot subclass with `auto_accept=True`) while a `connect_to` + is in flight, the returned Contact may not be the one you asked + for. Sequence concurrent connects, or call them one at a time. + + Raises: + asyncio.TimeoutError: handshake didn't complete within `timeout` + ValueError: timeout is not positive + RuntimeError: no active user, or receive loop not running + """ + if timeout <= 0: + # Reject upfront — otherwise wait_for raises TimeoutError after + # the handshake side-effect (api_connect_active_user) has + # already gone over the wire, leaving the caller with no + # Contact reference and a half-initiated connection. + raise ValueError(f"timeout must be positive, got {timeout!r}") + if not self._serving: + raise RuntimeError( + "connect_to requires the receive loop to be running — " + "call serve_forever() (in a task) or iterate events() first" + ) + api = self.api + user = await api.api_get_active_user() + if user is None: + raise RuntimeError("no active user") + + existing = await self._lookup_known_contact(user["userId"], link) + if existing is not None: + return existing + + loop = asyncio.get_running_loop() + waiter: asyncio.Future[T.Contact] = loop.create_future() + self._connect_waiters.append(waiter) + try: + try: + await api.api_connect_active_user(link) + except ContactAlreadyExistsError: + # Handshake mid-flight, or a previous incomplete attempt + # left the connection in a known-but-not-connected state. + # Either way: wait for the contactConnected event. + pass + return await asyncio.wait_for(waiter, timeout=timeout) + finally: + if waiter in self._connect_waiters: + self._connect_waiters.remove(waiter) + + async def _lookup_known_contact(self, user_id: int, link: str) -> T.Contact | None: + """Resolve a link to an existing Contact via api_connect_plan, or None. + + Only ChatCommandError is swallowed (malformed link, etc.) — the + connect_to caller will fall back to the full handshake path. + Transport/FFI errors propagate so the caller sees the real cause. + """ + try: + plan, _ = await self.api.api_connect_plan(user_id, link) + except ChatCommandError: + return None + if plan["type"] == "contactAddress": + cap = plan["contactAddressPlan"] + if cap["type"] == "known": + return cap["contact"] + if plan["type"] == "invitationLink": + ilp = plan["invitationLinkPlan"] + if ilp["type"] == "known": + return ilp["contact"] + return None + + async def send_and_wait( + self, + contact_id: int, + text: str, + *, + timeout: float = 30.0, + ) -> "Message[T.MsgContent]": + """Send text to a direct contact and wait for the next reply from them. + + Waiters are FIFO per contact_id: two concurrent calls to the same + contact get two replies in send order. Concurrent calls to *different* + contacts run in parallel. Once a reply matches a waiter, user + message handlers do NOT fire for that message — the awaiter owns it. + + Correlation caveat: matching is by sender contact_id only — there + is no message-id correlation. ANY direct message from `contact_id` + arriving while a waiter is pending will resolve that waiter, even + if it was an unsolicited message (e.g. an auto-reply from a bot's + address settings, a delayed reply from a previous send, a push + notification). For strict request/response semantics, ensure the + peer is otherwise quiet, or use the `@on_message` callback model. + + Requires the receive loop to be running. Raises asyncio.TimeoutError + on timeout, ValueError if timeout is not positive. + """ + if timeout <= 0: + # Reject upfront — otherwise wait_for raises TimeoutError after + # api_send_text_message already went over the wire, surprising + # the caller with a sent message and no Future to await. + raise ValueError(f"timeout must be positive, got {timeout!r}") + if not self._serving: + raise RuntimeError( + "send_and_wait requires the receive loop to be running — " + "call serve_forever() (in a task) or iterate events() first" + ) + loop = asyncio.get_running_loop() + waiter: asyncio.Future[Message[Any]] = loop.create_future() + waiters = self._reply_waiters.setdefault(contact_id, []) + waiters.append(waiter) + try: + await self.api.api_send_text_message(["direct", contact_id], text) + return await asyncio.wait_for(waiter, timeout=timeout) + finally: + # Always clean up our slot, even on send error or timeout. Leaving + # an unresolved Future in the dict would make the next incoming + # message resolve a future no one is waiting on. + if waiter in waiters: + waiters.remove(waiter) + if not waiters: + self._reply_waiters.pop(contact_id, None) + + async def _receive_loop(self) -> None: + # Catch broad Exception so a single malformed event or transient + # native error doesn't crash the whole client. CancelledError must + # always re-raise so `stop()` and asyncio cancellation work. + # `wait_us=500_000` (500ms) bounds the worst-case Ctrl+C latency: + # the C call blocks the worker thread until timeout, and the loop + # only checks `_stop_event` between polls. + while not self._stop_event.is_set(): + try: + event = await self.api.recv_chat_event(wait_us=500_000) + except asyncio.CancelledError: + raise + except ChatAPIError as e: + # Async chat errors emitted via the Haskell `eToView` path — + # routine soft errors (stale connections after a peer deletes + # a chat, file cleanup failures, etc.) intermixed with + # CRITICAL agent failures the operator must see. Mirror the + # desktop policy in SimpleXAPI.kt:3332-3340: escalate + # CRITICAL agent errors, keep everything else at debug. + chat_err: Any = e.chat_error or {} + agent_err: Any = ( + chat_err.get("agentError", {}) if chat_err.get("type") == "errorAgent" else {} + ) + if agent_err.get("type") == "CRITICAL": + log.error( + "chat agent CRITICAL: %s (offerRestart=%s)", + agent_err.get("criticalErr"), + agent_err.get("offerRestart"), + ) + else: + log.debug("chat event error: %s", chat_err.get("type")) + continue + except Exception: + log.exception("recv_chat_event failed") + # Bound the spin rate when the FFI is wedged on a persistent + # error (vs the timeout path, which already paces itself). + await asyncio.sleep(0.5) + continue + if event is None: + continue + try: + await self._dispatch_event(event) + except asyncio.CancelledError: + raise + except Exception: + log.exception("dispatch_event failed for tag=%s", event.get("type")) + + # ------------------------------------------------------------------ # + # Dispatch + # ------------------------------------------------------------------ # + + async def _dispatch_event(self, event: CEvt.ChatEvent) -> None: + tag = event["type"] + # Resolve internal waiters BEFORE user handlers. A pending + # `connect_to` consumes the contactConnected; a pending + # `send_and_wait` consumes the matching incoming message — user + # handlers don't fire for that message. This matches the mental + # model: the awaiter explicitly asked for this event. + if tag == "contactConnected" and self._connect_waiters: + contact: T.Contact = event["contact"] # type: ignore[typeddict-item] + waiter = self._connect_waiters.pop(0) + if not waiter.done(): + waiter.set_result(contact) + for h in self._event_handlers.get(tag, []): + try: + await h(event) + except Exception: + log.exception("on_event handler failed") + if tag == "newChatItems": + evt: CEvt.NewChatItems = event # type: ignore[assignment] + for ci in evt["chatItems"]: + content = ci["chatItem"]["content"] + if content["type"] != "rcvMsgContent": + continue + msg_content = content["msgContent"] # type: ignore[index] + msg: Message[T.MsgContent] = Message(chat_item=ci, content=msg_content, client=self) + # If a send_and_wait is pending for this sender, fulfil it + # and skip the user dispatch chain — the awaiter "owns" this + # reply. FIFO within a contact_id. + if self._maybe_resolve_reply_waiter(msg): + continue + await self._dispatch_message(msg) + + def _maybe_resolve_reply_waiter(self, msg: Message[T.MsgContent]) -> bool: + chat_info = msg.chat_info + if chat_info.get("type") != "direct": + return False + contact_id = chat_info.get("contact", {}).get("contactId") # type: ignore[union-attr] + if contact_id is None: + return False + waiters = self._reply_waiters.get(contact_id) + if not waiters: + return False + # Skip waiters whose callers have already given up (cancelled by + # wait_for timing out at the same loop tick). Without this skip, + # a reply arriving in the narrow timeout-race window would be + # silently dropped because the FIFO would pop a done waiter and + # neither resolve it nor dispatch to user handlers. + while waiters: + waiter = waiters.pop(0) + if not waiter.done(): + if not waiters: + self._reply_waiters.pop(contact_id, None) + waiter.set_result(msg) + return True + self._reply_waiters.pop(contact_id, None) + return False + + async def _dispatch_message(self, msg: Message[Any]) -> None: + # First-match-wins. The squaring bot's `@on_message(text=NUMBER_RE)` + # and catch-all `@on_message(content_type="text")` both match a number + # like "1"; we want only the first to fire. Registration order is the + # priority order — register the most-specific filters first. + # + # Slash-commands are tried first against command handlers; if no + # command handler matches, fall through to message handlers (so + # `@on_message` can still catch unknown slash-commands). + cmd = self._parse_command(msg) + if cmd is not None: + for names, predicate, handler in self._command_handlers: + if cmd.keyword in names and predicate(msg): + await self._invoke_command_with_middleware(handler, msg, cmd) + return + for predicate, handler in self._message_handlers: + if predicate(msg): + await self._invoke_with_middleware(handler, msg) + return + + async def _invoke_with_middleware(self, handler: MessageHandler, message: Message[Any]) -> None: + # Fast path: most clients register no middleware. Skip the closure-chain + # construction and the empty-data dict on every dispatch. + if not self._middleware: + try: + await handler(message) + except Exception: + log.exception("message handler failed") + return + + async def call(m: Message[Any], _data: dict[str, object]) -> None: + await handler(m) + + chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call + for mw in reversed(self._middleware): + inner = chain + + async def _wrapped( + m: Message[Any], + d: dict[str, object], + mw: Middleware = mw, + inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner, + ) -> None: + await mw(inner, m, d) + + chain = _wrapped + + try: + await chain(message, {}) + except Exception: + log.exception("message handler failed") + + async def _invoke_command_with_middleware( + self, handler: CommandHandler, message: Message[Any], cmd: ParsedCommand + ) -> None: + if not self._middleware: + try: + await handler(message, cmd) + except Exception: + log.exception("command handler failed") + return + + async def call(m: Message[Any], _data: dict[str, object]) -> None: + await handler(m, cmd) + + chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call + for mw in reversed(self._middleware): + inner = chain + + async def _wrapped( + m: Message[Any], + d: dict[str, object], + mw: Middleware = mw, + inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner, + ) -> None: + await mw(inner, m, d) + + chain = _wrapped + + try: + await chain(message, {}) + except Exception: + log.exception("command handler failed") + + @staticmethod + def _parse_command(msg: Message[Any]) -> ParsedCommand | None: + parsed = util.ci_bot_command(msg.chat_item["chatItem"]) + if parsed is None: + return None + keyword, args = parsed + return ParsedCommand(keyword=keyword, args=args) + + # ------------------------------------------------------------------ # + # Profile sync + # ------------------------------------------------------------------ # + + async def _ensure_active_user(self) -> T.User: + """Get or create the active user. Must run before `start_chat`. + + Mirrors Node `createBotUser` (bot.ts:158-166). The chat controller + won't accept `/_start` without a user, so this phase has to land + before lifecycle proceeds. + """ + api = self.api + user = await api.api_get_active_user() + if user is None: + log.info("No active user in database, creating...") + user = await api.api_create_active_user(self._profile_to_wire()) + log.info("user: %s", user["profile"]["displayName"]) + return user + + async def _maybe_sync_profile(self, user: T.User, *, contact_link: str | None) -> None: + """Update the user profile on the wire if its fields changed. + + `contact_link` is only set by Bot (to embed its address). Mirrors + Node `updateBotUserProfile` (bot.ts:199-214). Field-by-field + comparison because user["profile"] is LocalProfile (has extra + fields profileId, localAlias, preferences, peerType) so a full + dict equality would always differ. + """ + if not self._update_profile: + return + new_profile = self._profile_to_wire() + if contact_link is not None: + new_profile["contactLink"] = contact_link + cur = user["profile"] + changed = ( + cur["displayName"] != new_profile["displayName"] + or cur.get("fullName", "") != new_profile.get("fullName", "") + or cur.get("shortDescr") != new_profile.get("shortDescr") + or cur.get("image") != new_profile.get("image") + or cur.get("preferences") != new_profile.get("preferences") + or cur.get("peerType") != new_profile.get("peerType") + or cur.get("contactLink") != new_profile.get("contactLink") + ) + if changed: + log.info("profile changed, updating...") + await self.api.api_update_profile(user["userId"], new_profile) + + def _profile_to_wire(self) -> T.Profile: + """Convert the user-facing Profile dataclass to wire format. + + Base version produces a plain user profile. Bot overrides this to + add the bot-specific extensions (peerType=bot, command list, + calls/voice/files prefs). + """ + p: T.Profile = { + "displayName": self._profile.display_name, + "fullName": self._profile.full_name, + } + if self._profile.short_descr is not None: + p["shortDescr"] = self._profile.short_descr + if self._profile.image is not None: + p["image"] = self._profile.image + return p + + # ------------------------------------------------------------------ # + # Log subscriptions (mirror Node subscribeLogEvents bot.ts:142-156) + # ------------------------------------------------------------------ # + + def _register_log_handlers(self) -> None: + # Idempotent: re-entering the async context must not stack duplicate + # log handlers. Always-on error handlers run regardless of + # log_contacts/log_network so messageError/chatError/chatErrors + # don't disappear into the void. + if self._defaults_registered: + return + self._defaults_registered = True + self._event_handlers.setdefault("messageError", []).append(self._log_message_error) + self._event_handlers.setdefault("chatError", []).append(self._log_chat_error) + self._event_handlers.setdefault("chatErrors", []).append(self._log_chat_errors) + if self._log_contacts: + self._event_handlers.setdefault("contactConnected", []).append( + self._log_contact_connected + ) + self._event_handlers.setdefault("contactDeletedByContact", []).append( + self._log_contact_deleted + ) + if self._log_network: + self._event_handlers.setdefault("hostConnected", []).append(self._log_host_connected) + self._event_handlers.setdefault("hostDisconnected", []).append( + self._log_host_disconnected + ) + self._event_handlers.setdefault("subscriptionStatus", []).append( + self._log_subscription_status + ) + + @staticmethod + async def _log_contact_connected(evt: CEvt.ChatEvent) -> None: + log.info("%s connected", evt["contact"]["profile"]["displayName"]) # type: ignore[index] + + @staticmethod + async def _log_contact_deleted(evt: CEvt.ChatEvent) -> None: + log.info( + "%s deleted connection", + evt["contact"]["profile"]["displayName"], # type: ignore[index] + ) + + @staticmethod + async def _log_host_connected(evt: CEvt.ChatEvent) -> None: + log.info("connected server %s", evt["transportHost"]) # type: ignore[index] + + @staticmethod + async def _log_host_disconnected(evt: CEvt.ChatEvent) -> None: + log.info("disconnected server %s", evt["transportHost"]) # type: ignore[index] + + @staticmethod + async def _log_subscription_status(evt: CEvt.ChatEvent) -> None: + log.info( + "%d subscription(s) %s", + len(evt["connections"]), # type: ignore[index] + evt["subscriptionStatus"]["type"], # type: ignore[index] + ) + + @staticmethod + async def _log_message_error(evt: CEvt.ChatEvent) -> None: + log.warning("messageError: %s", evt.get("severity", "?")) # type: ignore[union-attr] + + @staticmethod + async def _log_chat_error(evt: CEvt.ChatEvent) -> None: + err = evt.get("chatError") # type: ignore[union-attr] + log.error("chatError: %s", err.get("type") if isinstance(err, dict) else err) + + @staticmethod + async def _log_chat_errors(evt: CEvt.ChatEvent) -> None: + errs = evt.get("chatErrors") or [] # type: ignore[union-attr] + log.error("chatErrors: %d errors", len(errs)) + + +__all__ = [ + "BotProfile", # backwards-compat alias for Profile + "ChatMessage", + "Client", + "CommandHandler", + "EventHandler", + "FileMessage", + "ImageMessage", + "LinkMessage", + "Message", + "MessageHandler", + "Middleware", + "ParsedCommand", + "Profile", + "ReportMessage", + "TextMessage", + "UnknownMessage", + "VideoMessage", + "VoiceMessage", +] diff --git a/packages/simplex-chat-python/src/simplex_chat/core.py b/packages/simplex-chat-python/src/simplex_chat/core.py new file mode 100644 index 0000000000..075db34b52 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/core.py @@ -0,0 +1,200 @@ +"""Internal typed async wrapper around libsimplex's 8 C ABI functions. + +Users interact with `Bot` / `ChatApi`. This module is exposed as +`simplex_chat.core` for tests and the api.ChatApi class only. +""" + +from __future__ import annotations + +import asyncio +import ctypes +import json +from enum import StrEnum +from typing import Any, TypedDict + +from . import _native +from .types import T, CR, CEvt + + +class ChatAPIError(Exception): + """Raised when chat_send_cmd / chat_recv_msg_wait returns a chat error.""" + + def __init__(self, message: str, chat_error: T.ChatError | None = None): + super().__init__(message) + self.chat_error = chat_error + + +class ChatInitError(Exception): + """Raised when chat_migrate_init returns a DBMigrationResult error.""" + + def __init__(self, message: str, db_migration_error: dict[str, Any]): + super().__init__(message) + self.db_migration_error = db_migration_error + + +class MigrationConfirmation(StrEnum): + YES_UP = "yesUp" + YES_UP_DOWN = "yesUpDown" + CONSOLE = "console" + ERROR = "error" + + +class CryptoArgs(TypedDict): # wire-format JSON; camelCase fields + fileKey: str + fileNonce: str + + +def _read_and_free(ptr: int | None) -> str: + """Copy a Haskell-allocated null-terminated UTF-8 string and free its buffer. + + Mirrors HandleCResult in packages/simplex-chat-nodejs/cpp/simplex.cc:157-165. + """ + if not ptr: + raise RuntimeError("null pointer returned from libsimplex") + try: + return ctypes.string_at(ptr).decode("utf-8") + finally: + _native.libc().free(ctypes.c_void_p(ptr)) + + +async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse: + def _call() -> str: + ptr = _native.lib().chat_send_cmd(ctrl, cmd.encode("utf-8")) + return _read_and_free(ptr) + + raw = await asyncio.to_thread(_call) + parsed = json.loads(raw) + if "result" in parsed and isinstance(parsed["result"], dict): + return parsed["result"] # type: ignore[return-value] + err = parsed.get("error") + if isinstance(err, dict): + raise ChatAPIError(f"chat command error: {err.get('type')}", err) # type: ignore[arg-type] + raise ChatAPIError(f"invalid chat command result: {raw[:200]}") + + +async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEvent | None: + def _call() -> str: + # On timeout, the C side returns a non-NULL pointer to a single NUL byte + # (see Mobile.hs `fromMaybe ""`), so `_read_and_free` returns "" — no + # NULL-pointer guard is needed here. + ptr = _native.lib().chat_recv_msg_wait(ctrl, wait_us) + return _read_and_free(ptr) + + raw = await asyncio.to_thread(_call) + if not raw: + return None + parsed = json.loads(raw) + if "result" in parsed and isinstance(parsed["result"], dict): + return parsed["result"] # type: ignore[return-value] + err = parsed.get("error") + if isinstance(err, dict): + raise ChatAPIError(f"chat event error: {err.get('type')}", err) # type: ignore[arg-type] + raise ChatAPIError(f"invalid chat event: {raw[:200]}") + + +async def chat_migrate_init(db_path: str, db_key: str, confirm: MigrationConfirmation) -> int: + """Initialize chat controller. Returns opaque ctrl pointer as Python int.""" + + def _call() -> tuple[int, str]: + ctrl = ctypes.c_void_p() + ptr = _native.lib().chat_migrate_init( + db_path.encode("utf-8"), + db_key.encode("utf-8"), + confirm.encode("utf-8"), + ctypes.byref(ctrl), + ) + return (ctrl.value or 0, _read_and_free(ptr)) + + ctrl_val, raw = await asyncio.to_thread(_call) + parsed = json.loads(raw) + if parsed.get("type") == "ok": + if not ctrl_val: + # ABI invariant: type=="ok" → out-param written. Defensive guard so a + # broken libsimplex doesn't hand us a NULL controller that would only + # crash on first use much later. + raise RuntimeError("chat_migrate_init returned ok but did not set ctrl pointer") + return ctrl_val + raise ChatInitError( + "Database or migration error (see db_migration_error)", + parsed, + ) + + +async def chat_close_store(ctrl: int) -> None: + def _call() -> str: + ptr = _native.lib().chat_close_store(ctrl) + return _read_and_free(ptr) + + res = await asyncio.to_thread(_call) + if res: + raise RuntimeError(res) + + +async def chat_write_file(ctrl: int, path: str, data: bytes) -> CryptoArgs: + def _call() -> str: + ptr = _native.lib().chat_write_file(ctrl, path.encode("utf-8"), data, len(data)) + return _read_and_free(ptr) + + raw = await asyncio.to_thread(_call) + return _crypto_args_result(raw) + + +async def chat_read_file(path: str, args: CryptoArgs) -> bytes: + def _call() -> bytes: + ptr = _native.lib().chat_read_file( + path.encode("utf-8"), + args["fileKey"].encode("utf-8"), + args["fileNonce"].encode("utf-8"), + ) + if not ptr: + raise RuntimeError("chat_read_file returned null") + addr = ctypes.cast(ptr, ctypes.c_void_p).value + assert addr is not None # `if not ptr` above already filtered NULL + try: + status = ctypes.cast(addr, ctypes.POINTER(ctypes.c_uint8))[0] + if status == 1: + msg = ctypes.string_at(addr + 1).decode("utf-8") + raise RuntimeError(msg) + if status != 0: + raise RuntimeError(f"unexpected status {status} from chat_read_file") + # `addr + 1` is unaligned for a uint32 read. On the supported platforms + # (linux-x86_64, linux-aarch64, macos-aarch64, windows-x86_64) this is + # silently handled; matches the Node.js binding (cpp/simplex.cc:344). + length = ctypes.cast(addr + 1, ctypes.POINTER(ctypes.c_uint32))[0] + return ctypes.string_at(addr + 5, length) + finally: + _native.libc().free(ctypes.c_void_p(addr)) + + return await asyncio.to_thread(_call) + + +async def chat_encrypt_file(ctrl: int, src: str, dst: str) -> CryptoArgs: + def _call() -> str: + ptr = _native.lib().chat_encrypt_file(ctrl, src.encode("utf-8"), dst.encode("utf-8")) + return _read_and_free(ptr) + + return _crypto_args_result(await asyncio.to_thread(_call)) + + +async def chat_decrypt_file(src: str, args: CryptoArgs, dst: str) -> None: + def _call() -> str: + ptr = _native.lib().chat_decrypt_file( + src.encode("utf-8"), + args["fileKey"].encode("utf-8"), + args["fileNonce"].encode("utf-8"), + dst.encode("utf-8"), + ) + return _read_and_free(ptr) + + res = await asyncio.to_thread(_call) + if res: + raise RuntimeError(res) + + +def _crypto_args_result(raw: str) -> CryptoArgs: + parsed = json.loads(raw) + if parsed.get("type") == "result": + return parsed["cryptoArgs"] + if parsed.get("type") == "error": + raise RuntimeError(parsed.get("writeError", "unknown write error")) + raise RuntimeError(f"unexpected result: {raw[:200]}") diff --git a/packages/simplex-chat-python/src/simplex_chat/filters.py b/packages/simplex-chat-python/src/simplex_chat/filters.py new file mode 100644 index 0000000000..8af15c1c66 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/filters.py @@ -0,0 +1,54 @@ +"""Compile kwarg-based message filters into a single predicate.""" + +from __future__ import annotations + +import re +from typing import Any, Callable + + +def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]: + """Compile filter kwargs into a single predicate function. + + Multiple kwargs combine with AND; tuples within a kwarg combine with OR. + `when` is the last predicate evaluated. + """ + predicates: list[Callable[[Any], bool]] = [] + + if (ct := kw.get("content_type")) is not None: + ct_set = (ct,) if isinstance(ct, str) else tuple(ct) + predicates.append(lambda m: m.content.get("type") in ct_set) + + if (t := kw.get("text")) is not None: + if isinstance(t, re.Pattern): + predicates.append(lambda m: bool(t.search(m.content.get("text", "") or ""))) + else: + predicates.append(lambda m: m.content.get("text") == t) + + if (cht := kw.get("chat_type")) is not None: + cht_set = (cht,) if isinstance(cht, str) else tuple(cht) + predicates.append(lambda m: m.chat_item["chatInfo"]["type"] in cht_set) + + if (gid := kw.get("group_id")) is not None: + gid_set: tuple[int, ...] = (gid,) if isinstance(gid, int) else tuple(gid) + + def gid_match(m: Any) -> bool: + ci = m.chat_item["chatInfo"] + return ci["type"] == "group" and ci["groupInfo"]["groupId"] in gid_set + + predicates.append(gid_match) + + if (cid := kw.get("contact_id")) is not None: + cid_set: tuple[int, ...] = (cid,) if isinstance(cid, int) else tuple(cid) + + def cid_match(m: Any) -> bool: + ci = m.chat_item["chatInfo"] + return ci["type"] == "direct" and ci["contact"]["contactId"] in cid_set + + predicates.append(cid_match) + + if (when := kw.get("when")) is not None: + predicates.append(when) + + if not predicates: + return lambda _m: True + return lambda m: all(p(m) for p in predicates) diff --git a/packages/simplex-chat-python/src/simplex_chat/py.typed b/packages/simplex-chat-python/src/simplex_chat/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/simplex-chat-python/src/simplex_chat/types/__init__.py b/packages/simplex-chat-python/src/simplex_chat/types/__init__.py new file mode 100644 index 0000000000..4d21965dd4 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/__init__.py @@ -0,0 +1,16 @@ +"""SimpleX Chat wire types — auto-generated from Haskell. + +Re-exports the four generated modules as namespaces: + +- ``T`` — :mod:`._types` (records, enums, discriminated unions) +- ``CC`` — :mod:`._commands` (command TypedDicts + ``_cmd_string`` helpers) +- ``CR`` — :mod:`._responses` (``ChatResponse`` and member TypedDicts) +- ``CEvt`` — :mod:`._events` (``ChatEvent`` and member TypedDicts) +""" + +from . import _commands as CC +from . import _events as CEvt +from . import _responses as CR +from . import _types as T + +__all__ = ["T", "CC", "CR", "CEvt"] diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py new file mode 100644 index 0000000000..3847f44811 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -0,0 +1,717 @@ +# API Commands +# This file is generated automatically. +from __future__ import annotations +import json +from typing import NotRequired, TypedDict +from . import _types as T +from . import _responses as CR + +# Address commands +# Bots can use these commands to automatically check and create address when initialized + +# Create bot address. +# Network usage: interactive. +class APICreateMyAddress(TypedDict): + userId: int # int64 + + +def APICreateMyAddress_cmd_string(self: APICreateMyAddress) -> str: + return '/_address ' + str(self['userId']) + +APICreateMyAddress_Response = CR.UserContactLinkCreated | CR.ChatCmdError + + +# Delete bot address. +# Network usage: background. +class APIDeleteMyAddress(TypedDict): + userId: int # int64 + + +def APIDeleteMyAddress_cmd_string(self: APIDeleteMyAddress) -> str: + return '/_delete_address ' + str(self['userId']) + +APIDeleteMyAddress_Response = CR.UserContactLinkDeleted | CR.ChatCmdError + + +# Get bot address and settings. +# Network usage: no. +class APIShowMyAddress(TypedDict): + userId: int # int64 + + +def APIShowMyAddress_cmd_string(self: APIShowMyAddress) -> str: + return '/_show_address ' + str(self['userId']) + +APIShowMyAddress_Response = CR.UserContactLink | CR.ChatCmdError + + +# Add address to bot profile. +# Network usage: interactive. +class APISetProfileAddress(TypedDict): + userId: int # int64 + enable: bool + + +def APISetProfileAddress_cmd_string(self: APISetProfileAddress) -> str: + return '/_profile_address ' + str(self['userId']) + ' ' + ('on' if self['enable'] else 'off') + +APISetProfileAddress_Response = CR.UserProfileUpdated | CR.ChatCmdError + + +# Set bot address settings. +# Network usage: interactive. +class APISetAddressSettings(TypedDict): + userId: int # int64 + settings: "T.AddressSettings" + + +def APISetAddressSettings_cmd_string(self: APISetAddressSettings) -> str: + return '/_address_settings ' + str(self['userId']) + ' ' + json.dumps(self['settings']) + +APISetAddressSettings_Response = CR.UserContactLinkUpdated | CR.ChatCmdError + + +# Message commands +# Commands to send, update, delete, moderate messages and set message reactions + +# Send messages. +# Network usage: background. +class APISendMessages(TypedDict): + sendRef: "T.ChatRef" + liveMessage: bool + ttl: NotRequired[int] # int + composedMessages: list["T.ComposedMessage"] # non-empty + + +def APISendMessages_cmd_string(self: APISendMessages) -> str: + return '/_send ' + T.ChatRef_cmd_string(self['sendRef']) + (' live=on' if self['liveMessage'] else '') + ((' ttl=' + str(self.get('ttl'))) if self.get('ttl') is not None else '') + ' json ' + json.dumps(self['composedMessages']) + +APISendMessages_Response = CR.NewChatItems | CR.ChatCmdError + + +# Update message. +# Network usage: background. +class APIUpdateChatItem(TypedDict): + chatRef: "T.ChatRef" + chatItemId: int # int64 + liveMessage: bool + updatedMessage: "T.UpdatedMessage" + + +def APIUpdateChatItem_cmd_string(self: APIUpdateChatItem) -> str: + return '/_update item ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + str(self['chatItemId']) + (' live=on' if self['liveMessage'] else '') + ' json ' + json.dumps(self['updatedMessage']) + +APIUpdateChatItem_Response = CR.ChatItemUpdated | CR.ChatItemNotChanged | CR.ChatCmdError + + +# Delete message. +# Network usage: background. +class APIDeleteChatItem(TypedDict): + chatRef: "T.ChatRef" + chatItemIds: list[int] # int64, non-empty + deleteMode: "T.CIDeleteMode" + + +def APIDeleteChatItem_cmd_string(self: APIDeleteChatItem) -> str: + return '/_delete item ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + ','.join(map(str, self['chatItemIds'])) + ' ' + str(self['deleteMode']) + +APIDeleteChatItem_Response = CR.ChatItemsDeleted | CR.ChatCmdError + + +# Moderate message. Requires Moderator role (and higher than message author's). +# Network usage: background. +class APIDeleteMemberChatItem(TypedDict): + groupId: int # int64 + chatItemIds: list[int] # int64, non-empty + + +def APIDeleteMemberChatItem_cmd_string(self: APIDeleteMemberChatItem) -> str: + return '/_delete member item #' + str(self['groupId']) + ' ' + ','.join(map(str, self['chatItemIds'])) + +APIDeleteMemberChatItem_Response = CR.ChatItemsDeleted | CR.ChatCmdError + + +# Add/remove message reaction. +# Network usage: background. +class APIChatItemReaction(TypedDict): + chatRef: "T.ChatRef" + chatItemId: int # int64 + add: bool + reaction: "T.MsgReaction" + + +def APIChatItemReaction_cmd_string(self: APIChatItemReaction) -> str: + return '/_reaction ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + str(self['chatItemId']) + ' ' + ('on' if self['add'] else 'off') + ' ' + json.dumps(self['reaction']) + +APIChatItemReaction_Response = CR.ChatItemReaction | CR.ChatCmdError + + +# File commands +# Commands to receive and to cancel files. Files are sent as part of the message, there are no separate commands to send files. + +# Receive file. +# Network usage: no. +class ReceiveFile(TypedDict): + fileId: int # int64 + userApprovedRelays: bool + storeEncrypted: NotRequired[bool] + fileInline: NotRequired[bool] + filePath: NotRequired[str] + + +def ReceiveFile_cmd_string(self: ReceiveFile) -> str: + return '/freceive ' + str(self['fileId']) + (' approved_relays=on' if self['userApprovedRelays'] else '') + ((' encrypt=' + ('on' if self.get('storeEncrypted') else 'off')) if self.get('storeEncrypted') is not None else '') + ((' inline=' + ('on' if self.get('fileInline') else 'off')) if self.get('fileInline') is not None else '') + ((' ' + self.get('filePath')) if self.get('filePath') is not None else '') + +ReceiveFile_Response = CR.RcvFileAccepted | CR.RcvFileAcceptedSndCancelled | CR.ChatCmdError + + +# Cancel file. +# Network usage: background. +class CancelFile(TypedDict): + fileId: int # int64 + + +def CancelFile_cmd_string(self: CancelFile) -> str: + return '/fcancel ' + str(self['fileId']) + +CancelFile_Response = CR.SndFileCancelled | CR.RcvFileCancelled | CR.ChatCmdError + + +# Group commands +# Commands to manage and moderate groups. These commands can be used with business chats as well - they are groups. E.g., a common scenario would be to add human agents to business chat with the customer who connected via business address. + +# Add contact to group. Requires bot to have Admin role. +# Network usage: interactive. +class APIAddMember(TypedDict): + groupId: int # int64 + contactId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIAddMember_cmd_string(self: APIAddMember) -> str: + return '/_add #' + str(self['groupId']) + ' ' + str(self['contactId']) + ' ' + str(self['memberRole']) + +APIAddMember_Response = CR.SentGroupInvitation | CR.ChatCmdError + + +# Join group. +# Network usage: interactive. +class APIJoinGroup(TypedDict): + groupId: int # int64 + + +def APIJoinGroup_cmd_string(self: APIJoinGroup) -> str: + return '/_join #' + str(self['groupId']) + +APIJoinGroup_Response = CR.UserAcceptedGroupSent | CR.ChatCmdError + + +# Accept group member. Requires Admin role. +# Network usage: background. +class APIAcceptMember(TypedDict): + groupId: int # int64 + groupMemberId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIAcceptMember_cmd_string(self: APIAcceptMember) -> str: + return '/_accept member #' + str(self['groupId']) + ' ' + str(self['groupMemberId']) + ' ' + str(self['memberRole']) + +APIAcceptMember_Response = CR.MemberAccepted | CR.ChatCmdError + + +# Set members role. Requires Admin role. +# Network usage: background. +class APIMembersRole(TypedDict): + groupId: int # int64 + groupMemberIds: list[int] # int64, non-empty + memberRole: "T.GroupMemberRole" + + +def APIMembersRole_cmd_string(self: APIMembersRole) -> str: + return '/_member role #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + ' ' + str(self['memberRole']) + +APIMembersRole_Response = CR.MembersRoleUser | CR.ChatCmdError + + +# Block members. Requires Moderator role. +# Network usage: background. +class APIBlockMembersForAll(TypedDict): + groupId: int # int64 + groupMemberIds: list[int] # int64, non-empty + blocked: bool + + +def APIBlockMembersForAll_cmd_string(self: APIBlockMembersForAll) -> str: + return '/_block #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + ' blocked=' + ('on' if self['blocked'] else 'off') + +APIBlockMembersForAll_Response = CR.MembersBlockedForAllUser | CR.ChatCmdError + + +# Remove members. Requires Admin role. +# Network usage: background. +class APIRemoveMembers(TypedDict): + groupId: int # int64 + groupMemberIds: list[int] # int64, non-empty + withMessages: bool + + +def APIRemoveMembers_cmd_string(self: APIRemoveMembers) -> str: + return '/_remove #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + (' messages=on' if self['withMessages'] else '') + +APIRemoveMembers_Response = CR.UserDeletedMembers | CR.ChatCmdError + + +# Leave group. +# Network usage: background. +class APILeaveGroup(TypedDict): + groupId: int # int64 + + +def APILeaveGroup_cmd_string(self: APILeaveGroup) -> str: + return '/_leave #' + str(self['groupId']) + +APILeaveGroup_Response = CR.LeftMemberUser | CR.ChatCmdError + + +# Get group members. +# Network usage: no. +class APIListMembers(TypedDict): + groupId: int # int64 + + +def APIListMembers_cmd_string(self: APIListMembers) -> str: + return '/_members #' + str(self['groupId']) + +APIListMembers_Response = CR.GroupMembers | CR.ChatCmdError + + +# Create group. +# Network usage: no. +class APINewGroup(TypedDict): + userId: int # int64 + incognito: bool + groupProfile: "T.GroupProfile" + + +def APINewGroup_cmd_string(self: APINewGroup) -> str: + return '/_group ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + ' ' + json.dumps(self['groupProfile']) + +APINewGroup_Response = CR.GroupCreated | CR.ChatCmdError + + +# Create public group. +# Network usage: interactive. +class APINewPublicGroup(TypedDict): + userId: int # int64 + incognito: bool + relayIds: list[int] # int64, non-empty + groupProfile: "T.GroupProfile" + + +def APINewPublicGroup_cmd_string(self: APINewPublicGroup) -> str: + return '/_public group ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + ' ' + ','.join(map(str, self['relayIds'])) + ' ' + json.dumps(self['groupProfile']) + +APINewPublicGroup_Response = CR.PublicGroupCreated | CR.PublicGroupCreationFailed | CR.ChatCmdError + + +# Get group relays. +# Network usage: no. +class APIGetGroupRelays(TypedDict): + groupId: int # int64 + + +def APIGetGroupRelays_cmd_string(self: APIGetGroupRelays) -> str: + return '/_get relays #' + str(self['groupId']) + +APIGetGroupRelays_Response = CR.GroupRelays | CR.ChatCmdError + + +# Add relays to group. +# Network usage: interactive. +class APIAddGroupRelays(TypedDict): + groupId: int # int64 + relayIds: list[int] # int64, non-empty + + +def APIAddGroupRelays_cmd_string(self: APIAddGroupRelays) -> str: + return '/_add relays #' + str(self['groupId']) + ' ' + ','.join(map(str, self['relayIds'])) + +APIAddGroupRelays_Response = CR.GroupRelaysAdded | CR.GroupRelaysAddFailed | CR.ChatCmdError + + +# Clear relay rejection for a channel (relay operator). +# Network usage: background. +class APIAllowRelayGroup(TypedDict): + groupId: int # int64 + + +def APIAllowRelayGroup_cmd_string(self: APIAllowRelayGroup) -> str: + return '/_relay allow #' + str(self['groupId']) + +APIAllowRelayGroup_Response = CR.RelayGroupAllowed | CR.ChatCmdError + + +# Update group profile. +# Network usage: background. +class APIUpdateGroupProfile(TypedDict): + groupId: int # int64 + groupProfile: "T.GroupProfile" + + +def APIUpdateGroupProfile_cmd_string(self: APIUpdateGroupProfile) -> str: + return '/_group_profile #' + str(self['groupId']) + ' ' + json.dumps(self['groupProfile']) + +APIUpdateGroupProfile_Response = CR.GroupUpdated | CR.ChatCmdError + + +# Group link commands +# These commands can be used by bots that manage multiple public groups + +# Create group link. +# Network usage: interactive. +class APICreateGroupLink(TypedDict): + groupId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APICreateGroupLink_cmd_string(self: APICreateGroupLink) -> str: + return '/_create link #' + str(self['groupId']) + ' ' + str(self['memberRole']) + +APICreateGroupLink_Response = CR.GroupLinkCreated | CR.ChatCmdError + + +# Set member role for group link. +# Network usage: no. +class APIGroupLinkMemberRole(TypedDict): + groupId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIGroupLinkMemberRole_cmd_string(self: APIGroupLinkMemberRole) -> str: + return '/_set link role #' + str(self['groupId']) + ' ' + str(self['memberRole']) + +APIGroupLinkMemberRole_Response = CR.GroupLink | CR.ChatCmdError + + +# Delete group link. +# Network usage: background. +class APIDeleteGroupLink(TypedDict): + groupId: int # int64 + + +def APIDeleteGroupLink_cmd_string(self: APIDeleteGroupLink) -> str: + return '/_delete link #' + str(self['groupId']) + +APIDeleteGroupLink_Response = CR.GroupLinkDeleted | CR.ChatCmdError + + +# Get group link. +# Network usage: no. +class APIGetGroupLink(TypedDict): + groupId: int # int64 + + +def APIGetGroupLink_cmd_string(self: APIGetGroupLink) -> str: + return '/_get link #' + str(self['groupId']) + +APIGetGroupLink_Response = CR.GroupLink | CR.ChatCmdError + + +# Connection commands +# These commands may be used to create connections. Most bots do not need to use them - bot users will connect via bot address with auto-accept enabled. + +# Create 1-time invitation link. +# Network usage: interactive. +class APIAddContact(TypedDict): + userId: int # int64 + incognito: bool + + +def APIAddContact_cmd_string(self: APIAddContact) -> str: + return '/_connect ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + +APIAddContact_Response = CR.Invitation | CR.ChatCmdError + + +# Determine SimpleX link type and if the bot is already connected via this link. +# Network usage: interactive. +class APIConnectPlan(TypedDict): + userId: int # int64 + connectionLink: NotRequired[str] + resolveKnown: bool + linkOwnerSig: NotRequired["T.LinkOwnerSig"] + + +def APIConnectPlan_cmd_string(self: APIConnectPlan) -> str: + return '/_connect plan ' + str(self['userId']) + ' ' + self.get('connectionLink') + +APIConnectPlan_Response = CR.ConnectionPlan | CR.ChatCmdError + + +# Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link. +# Network usage: interactive. +class APIConnect(TypedDict): + userId: int # int64 + incognito: bool + preparedLink_: NotRequired["T.CreatedConnLink"] + + +def APIConnect_cmd_string(self: APIConnect) -> str: + return '/_connect ' + str(self['userId']) + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '') + +APIConnect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError + + +# Connect via SimpleX link as string in the active user profile. +# Network usage: interactive. +class Connect(TypedDict): + incognito: bool + connLink_: NotRequired[str] + + +def Connect_cmd_string(self: Connect) -> str: + return '/connect' + ((' ' + self.get('connLink_')) if self.get('connLink_') is not None else '') + +Connect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError + + +# Accept contact request. +# Network usage: interactive. +class APIAcceptContact(TypedDict): + contactReqId: int # int64 + + +def APIAcceptContact_cmd_string(self: APIAcceptContact) -> str: + return '/_accept ' + str(self['contactReqId']) + +APIAcceptContact_Response = CR.AcceptingContactRequest | CR.ChatCmdError + + +# Reject contact request. The user who sent the request is **not notified**. +# Network usage: no. +class APIRejectContact(TypedDict): + contactReqId: int # int64 + + +def APIRejectContact_cmd_string(self: APIRejectContact) -> str: + return '/_reject ' + str(self['contactReqId']) + +APIRejectContact_Response = CR.ContactRequestRejected | CR.ChatCmdError + + +# Chat commands +# Commands to list and delete conversations. + +# Get contacts. +# Network usage: no. +class APIListContacts(TypedDict): + userId: int # int64 + + +def APIListContacts_cmd_string(self: APIListContacts) -> str: + return '/_contacts ' + str(self['userId']) + +APIListContacts_Response = CR.ContactsList | CR.ChatCmdError + + +# Get groups. +# Network usage: no. +class APIListGroups(TypedDict): + userId: int # int64 + contactId_: NotRequired[int] # int64 + search: NotRequired[str] + + +def APIListGroups_cmd_string(self: APIListGroups) -> str: + return '/_groups ' + str(self['userId']) + ((' @' + str(self.get('contactId_'))) if self.get('contactId_') is not None else '') + ((' ' + self.get('search')) if self.get('search') is not None else '') + +APIListGroups_Response = CR.GroupsList | CR.ChatCmdError + + +# Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases). +# Network usage: no. +class APIGetChats(TypedDict): + userId: int # int64 + pendingConnections: bool + pagination: "T.PaginationByTime" + query: "T.ChatListQuery" + + +def APIGetChats_cmd_string(self: APIGetChats) -> str: + return '/_get chats ' + str(self['userId']) + (' pcc=on' if self['pendingConnections'] else '') + ' ' + T.PaginationByTime_cmd_string(self['pagination']) + ' ' + json.dumps(self['query']) + +APIGetChats_Response = CR.ApiChats | CR.ChatCmdError + + +# Delete chat. +# Network usage: background. +class APIDeleteChat(TypedDict): + chatRef: "T.ChatRef" + chatDeleteMode: "T.ChatDeleteMode" + + +def APIDeleteChat_cmd_string(self: APIDeleteChat) -> str: + return '/_delete ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + T.ChatDeleteMode_cmd_string(self['chatDeleteMode']) + +APIDeleteChat_Response = CR.ContactDeleted | CR.ContactConnectionDeleted | CR.GroupDeletedUser | CR.ChatCmdError + + +# Set group custom data. +# Network usage: no. +class APISetGroupCustomData(TypedDict): + groupId: int # int64 + customData: NotRequired[dict[str, object]] + + +def APISetGroupCustomData_cmd_string(self: APISetGroupCustomData) -> str: + return '/_set custom #' + str(self['groupId']) + ((' ' + json.dumps(self.get('customData'))) if self.get('customData') is not None else '') + +APISetGroupCustomData_Response = CR.CmdOk | CR.ChatCmdError + + +# Set contact custom data. +# Network usage: no. +class APISetContactCustomData(TypedDict): + contactId: int # int64 + customData: NotRequired[dict[str, object]] + + +def APISetContactCustomData_cmd_string(self: APISetContactCustomData) -> str: + return '/_set custom @' + str(self['contactId']) + ((' ' + json.dumps(self.get('customData'))) if self.get('customData') is not None else '') + +APISetContactCustomData_Response = CR.CmdOk | CR.ChatCmdError + + +# Set auto-accept member contacts. +# Network usage: no. +class APISetUserAutoAcceptMemberContacts(TypedDict): + userId: int # int64 + onOff: bool + + +def APISetUserAutoAcceptMemberContacts_cmd_string(self: APISetUserAutoAcceptMemberContacts) -> str: + return '/_set accept member contacts ' + str(self['userId']) + ' ' + ('on' if self['onOff'] else 'off') + +APISetUserAutoAcceptMemberContacts_Response = CR.CmdOk | CR.ChatCmdError + + +# User profile commands +# Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). + +# Get active user profile. +# Network usage: no. +class ShowActiveUser(TypedDict): + pass + + +def ShowActiveUser_cmd_string(self: ShowActiveUser) -> str: + return '/user' + +ShowActiveUser_Response = CR.ActiveUser | CR.ChatCmdError + + +# Create new user profile. +# Network usage: no. +class CreateActiveUser(TypedDict): + newUser: "T.NewUser" + + +def CreateActiveUser_cmd_string(self: CreateActiveUser) -> str: + return '/_create user ' + json.dumps(self['newUser']) + +CreateActiveUser_Response = CR.ActiveUser | CR.ChatCmdError + + +# Get all user profiles. +# Network usage: no. +class ListUsers(TypedDict): + pass + + +def ListUsers_cmd_string(self: ListUsers) -> str: + return '/users' + +ListUsers_Response = CR.UsersList | CR.ChatCmdError + + +# Set active user profile. +# Network usage: no. +class APISetActiveUser(TypedDict): + userId: int # int64 + viewPwd: NotRequired[str] + + +def APISetActiveUser_cmd_string(self: APISetActiveUser) -> str: + return '/_user ' + str(self['userId']) + ((' ' + json.dumps(self.get('viewPwd'))) if self.get('viewPwd') is not None else '') + +APISetActiveUser_Response = CR.ActiveUser | CR.ChatCmdError + + +# Delete user profile. +# Network usage: background. +class APIDeleteUser(TypedDict): + userId: int # int64 + delSMPQueues: bool + viewPwd: NotRequired[str] + + +def APIDeleteUser_cmd_string(self: APIDeleteUser) -> str: + return '/_delete user ' + str(self['userId']) + ' del_smp=' + ('on' if self['delSMPQueues'] else 'off') + ((' ' + json.dumps(self.get('viewPwd'))) if self.get('viewPwd') is not None else '') + +APIDeleteUser_Response = CR.CmdOk | CR.ChatCmdError + + +# Update user profile. +# Network usage: background. +class APIUpdateProfile(TypedDict): + userId: int # int64 + profile: "T.Profile" + + +def APIUpdateProfile_cmd_string(self: APIUpdateProfile) -> str: + return '/_profile ' + str(self['userId']) + ' ' + json.dumps(self['profile']) + +APIUpdateProfile_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + +# Configure chat preference overrides for the contact. +# Network usage: background. +class APISetContactPrefs(TypedDict): + contactId: int # int64 + preferences: "T.Preferences" + + +def APISetContactPrefs_cmd_string(self: APISetContactPrefs) -> str: + return '/_set prefs @' + str(self['contactId']) + ' ' + json.dumps(self['preferences']) + +APISetContactPrefs_Response = CR.ContactPrefsUpdated | CR.ChatCmdError + + +# Chat management +# These commands should not be used with CLI-based bots + +# Start chat controller. +# Network usage: no. +class StartChat(TypedDict): + mainApp: bool + enableSndFiles: bool + + +def StartChat_cmd_string(self: StartChat) -> str: + return '/_start' + +StartChat_Response = CR.ChatStarted | CR.ChatRunning + + +# Stop chat controller. +# Network usage: no. +class APIStopChat(TypedDict): + pass + + +def APIStopChat_cmd_string(self: APIStopChat) -> str: + return '/_stop' + +APIStopChat_Response = CR.ChatStopped + diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_events.py b/packages/simplex-chat-python/src/simplex_chat/types/_events.py new file mode 100644 index 0000000000..7b7c724c92 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/_events.py @@ -0,0 +1,695 @@ +# API Events +# This file is generated automatically. +from __future__ import annotations +from collections.abc import Awaitable, Callable +from typing import Literal, NotRequired, Protocol, TypedDict, overload +from . import _types as T + +class ContactConnected(TypedDict): + type: Literal["contactConnected"] + user: "T.User" + contact: "T.Contact" + userCustomProfile: NotRequired["T.Profile"] + +class ContactUpdated(TypedDict): + type: Literal["contactUpdated"] + user: "T.User" + fromContact: "T.Contact" + toContact: "T.Contact" + +class ContactDeletedByContact(TypedDict): + type: Literal["contactDeletedByContact"] + user: "T.User" + contact: "T.Contact" + +class ReceivedContactRequest(TypedDict): + type: Literal["receivedContactRequest"] + user: "T.User" + contactRequest: "T.UserContactRequest" + chat_: NotRequired["T.AChat"] + +class NewMemberContactReceivedInv(TypedDict): + type: Literal["newMemberContactReceivedInv"] + user: "T.User" + contact: "T.Contact" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + +class ContactSndReady(TypedDict): + type: Literal["contactSndReady"] + user: "T.User" + contact: "T.Contact" + +class NewChatItems(TypedDict): + type: Literal["newChatItems"] + user: "T.User" + chatItems: list["T.AChatItem"] + +class ChatItemReaction(TypedDict): + type: Literal["chatItemReaction"] + user: "T.User" + added: bool + reaction: "T.ACIReaction" + +class ChatItemsDeleted(TypedDict): + type: Literal["chatItemsDeleted"] + user: "T.User" + chatItemDeletions: list["T.ChatItemDeletion"] + byUser: bool + timed: bool + +class ChatItemUpdated(TypedDict): + type: Literal["chatItemUpdated"] + user: "T.User" + chatItem: "T.AChatItem" + +class GroupChatItemsDeleted(TypedDict): + type: Literal["groupChatItemsDeleted"] + user: "T.User" + groupInfo: "T.GroupInfo" + chatItemIDs: list[int] # int64 + byUser: bool + member_: NotRequired["T.GroupMember"] + +class ChatItemsStatusesUpdated(TypedDict): + type: Literal["chatItemsStatusesUpdated"] + user: "T.User" + chatItems: list["T.AChatItem"] + +class ReceivedGroupInvitation(TypedDict): + type: Literal["receivedGroupInvitation"] + user: "T.User" + groupInfo: "T.GroupInfo" + contact: "T.Contact" + fromMemberRole: "T.GroupMemberRole" + memberRole: "T.GroupMemberRole" + +class UserJoinedGroup(TypedDict): + type: Literal["userJoinedGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostMember: "T.GroupMember" + +class GroupUpdated(TypedDict): + type: Literal["groupUpdated"] + user: "T.User" + fromGroup: "T.GroupInfo" + toGroup: "T.GroupInfo" + member_: NotRequired["T.GroupMember"] + msgSigned: NotRequired["T.MsgSigStatus"] + +class JoinedGroupMember(TypedDict): + type: Literal["joinedGroupMember"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + +class MemberRole(TypedDict): + type: Literal["memberRole"] + user: "T.User" + groupInfo: "T.GroupInfo" + byMember: "T.GroupMember" + member: "T.GroupMember" + fromRole: "T.GroupMemberRole" + toRole: "T.GroupMemberRole" + msgSigned: NotRequired["T.MsgSigStatus"] + +class DeletedMember(TypedDict): + type: Literal["deletedMember"] + user: "T.User" + groupInfo: "T.GroupInfo" + byMember: "T.GroupMember" + deletedMember: "T.GroupMember" + withMessages: bool + msgSigned: NotRequired["T.MsgSigStatus"] + +class LeftMember(TypedDict): + type: Literal["leftMember"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + msgSigned: NotRequired["T.MsgSigStatus"] + +class DeletedMemberUser(TypedDict): + type: Literal["deletedMemberUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + withMessages: bool + msgSigned: NotRequired["T.MsgSigStatus"] + +class GroupDeleted(TypedDict): + type: Literal["groupDeleted"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + msgSigned: NotRequired["T.MsgSigStatus"] + +class ConnectedToGroupMember(TypedDict): + type: Literal["connectedToGroupMember"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + memberContact: NotRequired["T.Contact"] + +class MemberAcceptedByOther(TypedDict): + type: Literal["memberAcceptedByOther"] + user: "T.User" + groupInfo: "T.GroupInfo" + acceptingMember: "T.GroupMember" + member: "T.GroupMember" + +class MemberBlockedForAll(TypedDict): + type: Literal["memberBlockedForAll"] + user: "T.User" + groupInfo: "T.GroupInfo" + byMember: "T.GroupMember" + member: "T.GroupMember" + blocked: bool + msgSigned: NotRequired["T.MsgSigStatus"] + +class GroupMemberUpdated(TypedDict): + type: Literal["groupMemberUpdated"] + user: "T.User" + groupInfo: "T.GroupInfo" + fromMember: "T.GroupMember" + toMember: "T.GroupMember" + +class GroupLinkDataUpdated(TypedDict): + type: Literal["groupLinkDataUpdated"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + groupRelays: list["T.GroupRelay"] + relaysChanged: bool + +class GroupRelayUpdated(TypedDict): + type: Literal["groupRelayUpdated"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + groupRelay: "T.GroupRelay" + +class RcvFileDescrReady(TypedDict): + type: Literal["rcvFileDescrReady"] + user: "T.User" + chatItem: "T.AChatItem" + rcvFileTransfer: "T.RcvFileTransfer" + rcvFileDescr: "T.RcvFileDescr" + +class RcvFileComplete(TypedDict): + type: Literal["rcvFileComplete"] + user: "T.User" + chatItem: "T.AChatItem" + +class SndFileCompleteXFTP(TypedDict): + type: Literal["sndFileCompleteXFTP"] + user: "T.User" + chatItem: "T.AChatItem" + fileTransferMeta: "T.FileTransferMeta" + +class RcvFileStart(TypedDict): + type: Literal["rcvFileStart"] + user: "T.User" + chatItem: "T.AChatItem" + +class RcvFileSndCancelled(TypedDict): + type: Literal["rcvFileSndCancelled"] + user: "T.User" + chatItem: "T.AChatItem" + rcvFileTransfer: "T.RcvFileTransfer" + +class RcvFileAccepted(TypedDict): + type: Literal["rcvFileAccepted"] + user: "T.User" + chatItem: "T.AChatItem" + +class RcvFileError(TypedDict): + type: Literal["rcvFileError"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + agentError: "T.AgentErrorType" + rcvFileTransfer: "T.RcvFileTransfer" + +class RcvFileWarning(TypedDict): + type: Literal["rcvFileWarning"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + agentError: "T.AgentErrorType" + rcvFileTransfer: "T.RcvFileTransfer" + +class SndFileError(TypedDict): + type: Literal["sndFileError"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + fileTransferMeta: "T.FileTransferMeta" + errorMessage: str + +class SndFileWarning(TypedDict): + type: Literal["sndFileWarning"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + fileTransferMeta: "T.FileTransferMeta" + errorMessage: str + +class AcceptingContactRequest(TypedDict): + type: Literal["acceptingContactRequest"] + user: "T.User" + contact: "T.Contact" + +class AcceptingBusinessRequest(TypedDict): + type: Literal["acceptingBusinessRequest"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class ContactConnecting(TypedDict): + type: Literal["contactConnecting"] + user: "T.User" + contact: "T.Contact" + +class BusinessLinkConnecting(TypedDict): + type: Literal["businessLinkConnecting"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostMember: "T.GroupMember" + fromContact: "T.Contact" + +class JoinedGroupMemberConnecting(TypedDict): + type: Literal["joinedGroupMemberConnecting"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostMember: "T.GroupMember" + member: "T.GroupMember" + +class SentGroupInvitation(TypedDict): + type: Literal["sentGroupInvitation"] + user: "T.User" + groupInfo: "T.GroupInfo" + contact: "T.Contact" + member: "T.GroupMember" + +class GroupLinkConnecting(TypedDict): + type: Literal["groupLinkConnecting"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostMember: "T.GroupMember" + +class HostConnected(TypedDict): + type: Literal["hostConnected"] + protocol: str + transportHost: str + +class HostDisconnected(TypedDict): + type: Literal["hostDisconnected"] + protocol: str + transportHost: str + +class SubscriptionStatus(TypedDict): + type: Literal["subscriptionStatus"] + server: str + subscriptionStatus: "T.SubscriptionStatus" + connections: list[str] + +class MessageError(TypedDict): + type: Literal["messageError"] + user: "T.User" + severity: str + errorMessage: str + +class ChatError(TypedDict): + type: Literal["chatError"] + chatError: "T.ChatError" + +class ChatErrors(TypedDict): + type: Literal["chatErrors"] + chatErrors: list["T.ChatError"] + +ChatEvent = ( + ContactConnected + | ContactUpdated + | ContactDeletedByContact + | ReceivedContactRequest + | NewMemberContactReceivedInv + | ContactSndReady + | NewChatItems + | ChatItemReaction + | ChatItemsDeleted + | ChatItemUpdated + | GroupChatItemsDeleted + | ChatItemsStatusesUpdated + | ReceivedGroupInvitation + | UserJoinedGroup + | GroupUpdated + | JoinedGroupMember + | MemberRole + | DeletedMember + | LeftMember + | DeletedMemberUser + | GroupDeleted + | ConnectedToGroupMember + | MemberAcceptedByOther + | MemberBlockedForAll + | GroupMemberUpdated + | GroupLinkDataUpdated + | GroupRelayUpdated + | RcvFileDescrReady + | RcvFileComplete + | SndFileCompleteXFTP + | RcvFileStart + | RcvFileSndCancelled + | RcvFileAccepted + | RcvFileError + | RcvFileWarning + | SndFileError + | SndFileWarning + | AcceptingContactRequest + | AcceptingBusinessRequest + | ContactConnecting + | BusinessLinkConnecting + | JoinedGroupMemberConnecting + | SentGroupInvitation + | GroupLinkConnecting + | HostConnected + | HostDisconnected + | SubscriptionStatus + | MessageError + | ChatError + | ChatErrors +) + +ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "sentGroupInvitation", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "messageError", "chatError", "chatErrors"] + + +class OnEventDecorator(Protocol): + """Per-tag narrowing protocol for ``Client.on_event``. + + ``@client.on_event("contactConnected")`` types the handler's + ``evt`` parameter as :class:`ContactConnected` rather than the + unnarrowed :data:`ChatEvent` union. + """ + + @overload + def __call__(self, event: Literal["contactConnected"], /) -> Callable[ + [Callable[["ContactConnected"], Awaitable[None]]], + Callable[["ContactConnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactUpdated"], /) -> Callable[ + [Callable[["ContactUpdated"], Awaitable[None]]], + Callable[["ContactUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactDeletedByContact"], /) -> Callable[ + [Callable[["ContactDeletedByContact"], Awaitable[None]]], + Callable[["ContactDeletedByContact"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["receivedContactRequest"], /) -> Callable[ + [Callable[["ReceivedContactRequest"], Awaitable[None]]], + Callable[["ReceivedContactRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["newMemberContactReceivedInv"], /) -> Callable[ + [Callable[["NewMemberContactReceivedInv"], Awaitable[None]]], + Callable[["NewMemberContactReceivedInv"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactSndReady"], /) -> Callable[ + [Callable[["ContactSndReady"], Awaitable[None]]], + Callable[["ContactSndReady"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["newChatItems"], /) -> Callable[ + [Callable[["NewChatItems"], Awaitable[None]]], + Callable[["NewChatItems"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemReaction"], /) -> Callable[ + [Callable[["ChatItemReaction"], Awaitable[None]]], + Callable[["ChatItemReaction"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemsDeleted"], /) -> Callable[ + [Callable[["ChatItemsDeleted"], Awaitable[None]]], + Callable[["ChatItemsDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemUpdated"], /) -> Callable[ + [Callable[["ChatItemUpdated"], Awaitable[None]]], + Callable[["ChatItemUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupChatItemsDeleted"], /) -> Callable[ + [Callable[["GroupChatItemsDeleted"], Awaitable[None]]], + Callable[["GroupChatItemsDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemsStatusesUpdated"], /) -> Callable[ + [Callable[["ChatItemsStatusesUpdated"], Awaitable[None]]], + Callable[["ChatItemsStatusesUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["receivedGroupInvitation"], /) -> Callable[ + [Callable[["ReceivedGroupInvitation"], Awaitable[None]]], + Callable[["ReceivedGroupInvitation"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["userJoinedGroup"], /) -> Callable[ + [Callable[["UserJoinedGroup"], Awaitable[None]]], + Callable[["UserJoinedGroup"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupUpdated"], /) -> Callable[ + [Callable[["GroupUpdated"], Awaitable[None]]], + Callable[["GroupUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["joinedGroupMember"], /) -> Callable[ + [Callable[["JoinedGroupMember"], Awaitable[None]]], + Callable[["JoinedGroupMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberRole"], /) -> Callable[ + [Callable[["MemberRole"], Awaitable[None]]], + Callable[["MemberRole"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["deletedMember"], /) -> Callable[ + [Callable[["DeletedMember"], Awaitable[None]]], + Callable[["DeletedMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["leftMember"], /) -> Callable[ + [Callable[["LeftMember"], Awaitable[None]]], + Callable[["LeftMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["deletedMemberUser"], /) -> Callable[ + [Callable[["DeletedMemberUser"], Awaitable[None]]], + Callable[["DeletedMemberUser"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupDeleted"], /) -> Callable[ + [Callable[["GroupDeleted"], Awaitable[None]]], + Callable[["GroupDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["connectedToGroupMember"], /) -> Callable[ + [Callable[["ConnectedToGroupMember"], Awaitable[None]]], + Callable[["ConnectedToGroupMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberAcceptedByOther"], /) -> Callable[ + [Callable[["MemberAcceptedByOther"], Awaitable[None]]], + Callable[["MemberAcceptedByOther"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberBlockedForAll"], /) -> Callable[ + [Callable[["MemberBlockedForAll"], Awaitable[None]]], + Callable[["MemberBlockedForAll"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupMemberUpdated"], /) -> Callable[ + [Callable[["GroupMemberUpdated"], Awaitable[None]]], + Callable[["GroupMemberUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupLinkDataUpdated"], /) -> Callable[ + [Callable[["GroupLinkDataUpdated"], Awaitable[None]]], + Callable[["GroupLinkDataUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupRelayUpdated"], /) -> Callable[ + [Callable[["GroupRelayUpdated"], Awaitable[None]]], + Callable[["GroupRelayUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileDescrReady"], /) -> Callable[ + [Callable[["RcvFileDescrReady"], Awaitable[None]]], + Callable[["RcvFileDescrReady"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileComplete"], /) -> Callable[ + [Callable[["RcvFileComplete"], Awaitable[None]]], + Callable[["RcvFileComplete"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileCompleteXFTP"], /) -> Callable[ + [Callable[["SndFileCompleteXFTP"], Awaitable[None]]], + Callable[["SndFileCompleteXFTP"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileStart"], /) -> Callable[ + [Callable[["RcvFileStart"], Awaitable[None]]], + Callable[["RcvFileStart"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileSndCancelled"], /) -> Callable[ + [Callable[["RcvFileSndCancelled"], Awaitable[None]]], + Callable[["RcvFileSndCancelled"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileAccepted"], /) -> Callable[ + [Callable[["RcvFileAccepted"], Awaitable[None]]], + Callable[["RcvFileAccepted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileError"], /) -> Callable[ + [Callable[["RcvFileError"], Awaitable[None]]], + Callable[["RcvFileError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileWarning"], /) -> Callable[ + [Callable[["RcvFileWarning"], Awaitable[None]]], + Callable[["RcvFileWarning"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileError"], /) -> Callable[ + [Callable[["SndFileError"], Awaitable[None]]], + Callable[["SndFileError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileWarning"], /) -> Callable[ + [Callable[["SndFileWarning"], Awaitable[None]]], + Callable[["SndFileWarning"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["acceptingContactRequest"], /) -> Callable[ + [Callable[["AcceptingContactRequest"], Awaitable[None]]], + Callable[["AcceptingContactRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["acceptingBusinessRequest"], /) -> Callable[ + [Callable[["AcceptingBusinessRequest"], Awaitable[None]]], + Callable[["AcceptingBusinessRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactConnecting"], /) -> Callable[ + [Callable[["ContactConnecting"], Awaitable[None]]], + Callable[["ContactConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["businessLinkConnecting"], /) -> Callable[ + [Callable[["BusinessLinkConnecting"], Awaitable[None]]], + Callable[["BusinessLinkConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["joinedGroupMemberConnecting"], /) -> Callable[ + [Callable[["JoinedGroupMemberConnecting"], Awaitable[None]]], + Callable[["JoinedGroupMemberConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sentGroupInvitation"], /) -> Callable[ + [Callable[["SentGroupInvitation"], Awaitable[None]]], + Callable[["SentGroupInvitation"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupLinkConnecting"], /) -> Callable[ + [Callable[["GroupLinkConnecting"], Awaitable[None]]], + Callable[["GroupLinkConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["hostConnected"], /) -> Callable[ + [Callable[["HostConnected"], Awaitable[None]]], + Callable[["HostConnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["hostDisconnected"], /) -> Callable[ + [Callable[["HostDisconnected"], Awaitable[None]]], + Callable[["HostDisconnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["subscriptionStatus"], /) -> Callable[ + [Callable[["SubscriptionStatus"], Awaitable[None]]], + Callable[["SubscriptionStatus"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["messageError"], /) -> Callable[ + [Callable[["MessageError"], Awaitable[None]]], + Callable[["MessageError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatError"], /) -> Callable[ + [Callable[["ChatError"], Awaitable[None]]], + Callable[["ChatError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatErrors"], /) -> Callable[ + [Callable[["ChatErrors"], Awaitable[None]]], + Callable[["ChatErrors"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: str, /) -> Callable[ + [Callable[["ChatEvent"], Awaitable[None]]], + Callable[["ChatEvent"], Awaitable[None]], + ]: ... diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py new file mode 100644 index 0000000000..e85de02c78 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -0,0 +1,366 @@ +# API Responses +# This file is generated automatically. +from __future__ import annotations +from typing import Literal, NotRequired, TypedDict +from . import _types as T + +class AcceptingContactRequest(TypedDict): + type: Literal["acceptingContactRequest"] + user: "T.User" + contact: "T.Contact" + +class ActiveUser(TypedDict): + type: Literal["activeUser"] + user: "T.User" + +class ChatItemNotChanged(TypedDict): + type: Literal["chatItemNotChanged"] + user: "T.User" + chatItem: "T.AChatItem" + +class ChatItemReaction(TypedDict): + type: Literal["chatItemReaction"] + user: "T.User" + added: bool + reaction: "T.ACIReaction" + +class ChatItemUpdated(TypedDict): + type: Literal["chatItemUpdated"] + user: "T.User" + chatItem: "T.AChatItem" + +class ChatItemsDeleted(TypedDict): + type: Literal["chatItemsDeleted"] + user: "T.User" + chatItemDeletions: list["T.ChatItemDeletion"] + byUser: bool + timed: bool + +class ChatRunning(TypedDict): + type: Literal["chatRunning"] + +class ChatStarted(TypedDict): + type: Literal["chatStarted"] + +class ChatStopped(TypedDict): + type: Literal["chatStopped"] + +class CmdOk(TypedDict): + type: Literal["cmdOk"] + user_: NotRequired["T.User"] + +class ChatCmdError(TypedDict): + type: Literal["chatCmdError"] + chatError: "T.ChatError" + +class ConnectionPlan(TypedDict): + type: Literal["connectionPlan"] + user: "T.User" + connLink: "T.CreatedConnLink" + connectionPlan: "T.ConnectionPlan" + +class ContactAlreadyExists(TypedDict): + type: Literal["contactAlreadyExists"] + user: "T.User" + contact: "T.Contact" + +class ContactConnectionDeleted(TypedDict): + type: Literal["contactConnectionDeleted"] + user: "T.User" + connection: "T.PendingContactConnection" + +class ContactDeleted(TypedDict): + type: Literal["contactDeleted"] + user: "T.User" + contact: "T.Contact" + +class ContactPrefsUpdated(TypedDict): + type: Literal["contactPrefsUpdated"] + user: "T.User" + fromContact: "T.Contact" + toContact: "T.Contact" + +class ContactRequestRejected(TypedDict): + type: Literal["contactRequestRejected"] + user: "T.User" + contactRequest: "T.UserContactRequest" + contact_: NotRequired["T.Contact"] + +class ContactsList(TypedDict): + type: Literal["contactsList"] + user: "T.User" + contacts: list["T.Contact"] + +class GroupDeletedUser(TypedDict): + type: Literal["groupDeletedUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + msgSigned: bool + +class GroupLink(TypedDict): + type: Literal["groupLink"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + +class GroupLinkCreated(TypedDict): + type: Literal["groupLinkCreated"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + +class GroupLinkDeleted(TypedDict): + type: Literal["groupLinkDeleted"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class GroupCreated(TypedDict): + type: Literal["groupCreated"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class PublicGroupCreated(TypedDict): + type: Literal["publicGroupCreated"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + groupRelays: list["T.GroupRelay"] + +class PublicGroupCreationFailed(TypedDict): + type: Literal["publicGroupCreationFailed"] + user: "T.User" + addRelayResults: list["T.AddRelayResult"] + +class GroupRelays(TypedDict): + type: Literal["groupRelays"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupRelays: list["T.GroupRelay"] + +class GroupRelaysAdded(TypedDict): + type: Literal["groupRelaysAdded"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + groupRelays: list["T.GroupRelay"] + +class GroupRelaysAddFailed(TypedDict): + type: Literal["groupRelaysAddFailed"] + user: "T.User" + addRelayResults: list["T.AddRelayResult"] + +class RelayGroupAllowed(TypedDict): + type: Literal["relayGroupAllowed"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class GroupMembers(TypedDict): + type: Literal["groupMembers"] + user: "T.User" + group: "T.Group" + +class GroupUpdated(TypedDict): + type: Literal["groupUpdated"] + user: "T.User" + fromGroup: "T.GroupInfo" + toGroup: "T.GroupInfo" + member_: NotRequired["T.GroupMember"] + msgSigned: bool + +class GroupsList(TypedDict): + type: Literal["groupsList"] + user: "T.User" + groups: list["T.GroupInfo"] + +class Invitation(TypedDict): + type: Literal["invitation"] + user: "T.User" + connLinkInvitation: "T.CreatedConnLink" + connection: "T.PendingContactConnection" + +class LeftMemberUser(TypedDict): + type: Literal["leftMemberUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class MemberAccepted(TypedDict): + type: Literal["memberAccepted"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + +class MembersBlockedForAllUser(TypedDict): + type: Literal["membersBlockedForAllUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + members: list["T.GroupMember"] + blocked: bool + msgSigned: bool + +class MembersRoleUser(TypedDict): + type: Literal["membersRoleUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + members: list["T.GroupMember"] + toRole: "T.GroupMemberRole" + msgSigned: bool + +class NewChatItems(TypedDict): + type: Literal["newChatItems"] + user: "T.User" + chatItems: list["T.AChatItem"] + +class RcvFileAccepted(TypedDict): + type: Literal["rcvFileAccepted"] + user: "T.User" + chatItem: "T.AChatItem" + +class RcvFileAcceptedSndCancelled(TypedDict): + type: Literal["rcvFileAcceptedSndCancelled"] + user: "T.User" + rcvFileTransfer: "T.RcvFileTransfer" + +class RcvFileCancelled(TypedDict): + type: Literal["rcvFileCancelled"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + rcvFileTransfer: "T.RcvFileTransfer" + +class SentConfirmation(TypedDict): + type: Literal["sentConfirmation"] + user: "T.User" + connection: "T.PendingContactConnection" + customUserProfile: NotRequired["T.Profile"] + +class SentGroupInvitation(TypedDict): + type: Literal["sentGroupInvitation"] + user: "T.User" + groupInfo: "T.GroupInfo" + contact: "T.Contact" + member: "T.GroupMember" + +class SentInvitation(TypedDict): + type: Literal["sentInvitation"] + user: "T.User" + connection: "T.PendingContactConnection" + customUserProfile: NotRequired["T.Profile"] + +class SndFileCancelled(TypedDict): + type: Literal["sndFileCancelled"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + fileTransferMeta: "T.FileTransferMeta" + sndFileTransfers: list["T.SndFileTransfer"] + +class UserAcceptedGroupSent(TypedDict): + type: Literal["userAcceptedGroupSent"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostContact: NotRequired["T.Contact"] + +class UserContactLink(TypedDict): + type: Literal["userContactLink"] + user: "T.User" + contactLink: "T.UserContactLink" + +class UserContactLinkCreated(TypedDict): + type: Literal["userContactLinkCreated"] + user: "T.User" + connLinkContact: "T.CreatedConnLink" + +class UserContactLinkDeleted(TypedDict): + type: Literal["userContactLinkDeleted"] + user: "T.User" + +class UserContactLinkUpdated(TypedDict): + type: Literal["userContactLinkUpdated"] + user: "T.User" + contactLink: "T.UserContactLink" + +class UserDeletedMembers(TypedDict): + type: Literal["userDeletedMembers"] + user: "T.User" + groupInfo: "T.GroupInfo" + members: list["T.GroupMember"] + withMessages: bool + msgSigned: bool + +class UserProfileUpdated(TypedDict): + type: Literal["userProfileUpdated"] + user: "T.User" + fromProfile: "T.Profile" + toProfile: "T.Profile" + updateSummary: "T.UserProfileUpdateSummary" + +class UserProfileNoChange(TypedDict): + type: Literal["userProfileNoChange"] + user: "T.User" + +class UsersList(TypedDict): + type: Literal["usersList"] + users: list["T.UserInfo"] + +class ApiChats(TypedDict): + type: Literal["apiChats"] + user: "T.User" + chats: list["T.AChat"] + +ChatResponse = ( + AcceptingContactRequest + | ActiveUser + | ChatItemNotChanged + | ChatItemReaction + | ChatItemUpdated + | ChatItemsDeleted + | ChatRunning + | ChatStarted + | ChatStopped + | CmdOk + | ChatCmdError + | ConnectionPlan + | ContactAlreadyExists + | ContactConnectionDeleted + | ContactDeleted + | ContactPrefsUpdated + | ContactRequestRejected + | ContactsList + | GroupDeletedUser + | GroupLink + | GroupLinkCreated + | GroupLinkDeleted + | GroupCreated + | PublicGroupCreated + | PublicGroupCreationFailed + | GroupRelays + | GroupRelaysAdded + | GroupRelaysAddFailed + | RelayGroupAllowed + | GroupMembers + | GroupUpdated + | GroupsList + | Invitation + | LeftMemberUser + | MemberAccepted + | MembersBlockedForAllUser + | MembersRoleUser + | NewChatItems + | RcvFileAccepted + | RcvFileAcceptedSndCancelled + | RcvFileCancelled + | SentConfirmation + | SentGroupInvitation + | SentInvitation + | SndFileCancelled + | UserAcceptedGroupSent + | UserContactLink + | UserContactLinkCreated + | UserContactLinkDeleted + | UserContactLinkUpdated + | UserDeletedMembers + | UserProfileUpdated + | UserProfileNoChange + | UsersList + | ApiChats +) + +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py new file mode 100644 index 0000000000..b2fc00a44c --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -0,0 +1,3506 @@ +# API Types +# This file is generated automatically. +from __future__ import annotations +from typing import Literal, NotRequired, TypedDict + +class ACIReaction(TypedDict): + chatInfo: "ChatInfo" + chatReaction: "CIReaction" + +class AChat(TypedDict): + chatInfo: "ChatInfo" + chatItems: list["ChatItem"] + chatStats: "ChatStats" + +class AChatItem(TypedDict): + chatInfo: "ChatInfo" + chatItem: "ChatItem" + +class AddRelayResult(TypedDict): + relay: "UserChatRelay" + relayError: NotRequired["ChatError"] + +class AddressSettings(TypedDict): + businessAddress: bool + autoAccept: NotRequired["AutoAccept"] + autoReply: NotRequired["MsgContent"] + +class AgentCryptoError_DECRYPT_AES(TypedDict): + type: Literal["DECRYPT_AES"] + +class AgentCryptoError_DECRYPT_CB(TypedDict): + type: Literal["DECRYPT_CB"] + +class AgentCryptoError_RATCHET_HEADER(TypedDict): + type: Literal["RATCHET_HEADER"] + +class AgentCryptoError_RATCHET_SYNC(TypedDict): + type: Literal["RATCHET_SYNC"] + +AgentCryptoError = ( + AgentCryptoError_DECRYPT_AES + | AgentCryptoError_DECRYPT_CB + | AgentCryptoError_RATCHET_HEADER + | AgentCryptoError_RATCHET_SYNC +) + +AgentCryptoError_Tag = Literal["DECRYPT_AES", "DECRYPT_CB", "RATCHET_HEADER", "RATCHET_SYNC"] + +class AgentErrorType_CMD(TypedDict): + type: Literal["CMD"] + cmdErr: "CommandErrorType" + errContext: str + +class AgentErrorType_CONN(TypedDict): + type: Literal["CONN"] + connErr: "ConnectionErrorType" + errContext: str + +class AgentErrorType_NO_USER(TypedDict): + type: Literal["NO_USER"] + +class AgentErrorType_SMP(TypedDict): + type: Literal["SMP"] + serverAddress: str + smpErr: "ErrorType" + +class AgentErrorType_NTF(TypedDict): + type: Literal["NTF"] + serverAddress: str + ntfErr: "ErrorType" + +class AgentErrorType_XFTP(TypedDict): + type: Literal["XFTP"] + serverAddress: str + xftpErr: "XFTPErrorType" + +class AgentErrorType_FILE(TypedDict): + type: Literal["FILE"] + fileErr: "FileErrorType" + +class AgentErrorType_PROXY(TypedDict): + type: Literal["PROXY"] + proxyServer: str + relayServer: str + proxyErr: "ProxyClientError" + +class AgentErrorType_RCP(TypedDict): + type: Literal["RCP"] + rcpErr: "RCErrorType" + +class AgentErrorType_BROKER(TypedDict): + type: Literal["BROKER"] + brokerAddress: str + brokerErr: "BrokerErrorType" + +class AgentErrorType_AGENT(TypedDict): + type: Literal["AGENT"] + agentErr: "SMPAgentError" + +class AgentErrorType_NOTICE(TypedDict): + type: Literal["NOTICE"] + server: str + preset: bool + expiresAt: NotRequired[str] # ISO-8601 timestamp + +class AgentErrorType_INTERNAL(TypedDict): + type: Literal["INTERNAL"] + internalErr: str + +class AgentErrorType_CRITICAL(TypedDict): + type: Literal["CRITICAL"] + offerRestart: bool + criticalErr: str + +class AgentErrorType_INACTIVE(TypedDict): + type: Literal["INACTIVE"] + +AgentErrorType = ( + AgentErrorType_CMD + | AgentErrorType_CONN + | AgentErrorType_NO_USER + | AgentErrorType_SMP + | AgentErrorType_NTF + | AgentErrorType_XFTP + | AgentErrorType_FILE + | AgentErrorType_PROXY + | AgentErrorType_RCP + | AgentErrorType_BROKER + | AgentErrorType_AGENT + | AgentErrorType_NOTICE + | AgentErrorType_INTERNAL + | AgentErrorType_CRITICAL + | AgentErrorType_INACTIVE +) + +AgentErrorType_Tag = Literal["CMD", "CONN", "NO_USER", "SMP", "NTF", "XFTP", "FILE", "PROXY", "RCP", "BROKER", "AGENT", "NOTICE", "INTERNAL", "CRITICAL", "INACTIVE"] + +class AutoAccept(TypedDict): + acceptIncognito: bool + +class BlockingInfo(TypedDict): + reason: "BlockingReason" + notice: NotRequired["ClientNotice"] + +BlockingReason = Literal["spam", "content"] + +class BrokerErrorType_RESPONSE(TypedDict): + type: Literal["RESPONSE"] + respErr: str + +class BrokerErrorType_UNEXPECTED(TypedDict): + type: Literal["UNEXPECTED"] + respErr: str + +class BrokerErrorType_NETWORK(TypedDict): + type: Literal["NETWORK"] + networkError: "NetworkError" + +class BrokerErrorType_HOST(TypedDict): + type: Literal["HOST"] + +class BrokerErrorType_NO_SERVICE(TypedDict): + type: Literal["NO_SERVICE"] + +class BrokerErrorType_TRANSPORT(TypedDict): + type: Literal["TRANSPORT"] + transportErr: "TransportError" + +class BrokerErrorType_TIMEOUT(TypedDict): + type: Literal["TIMEOUT"] + +BrokerErrorType = ( + BrokerErrorType_RESPONSE + | BrokerErrorType_UNEXPECTED + | BrokerErrorType_NETWORK + | BrokerErrorType_HOST + | BrokerErrorType_NO_SERVICE + | BrokerErrorType_TRANSPORT + | BrokerErrorType_TIMEOUT +) + +BrokerErrorType_Tag = Literal["RESPONSE", "UNEXPECTED", "NETWORK", "HOST", "NO_SERVICE", "TRANSPORT", "TIMEOUT"] + +class BusinessChatInfo(TypedDict): + chatType: "BusinessChatType" + businessId: str + customerId: str + +BusinessChatType = Literal["business", "customer"] + +CICallStatus = Literal["pending", "missed", "rejected", "accepted", "negotiated", "progress", "ended", "error"] + +class CIContent_sndMsgContent(TypedDict): + type: Literal["sndMsgContent"] + msgContent: "MsgContent" + +class CIContent_rcvMsgContent(TypedDict): + type: Literal["rcvMsgContent"] + msgContent: "MsgContent" + +class CIContent_sndDeleted(TypedDict): + type: Literal["sndDeleted"] + deleteMode: "CIDeleteMode" + +class CIContent_rcvDeleted(TypedDict): + type: Literal["rcvDeleted"] + deleteMode: "CIDeleteMode" + +class CIContent_sndCall(TypedDict): + type: Literal["sndCall"] + status: "CICallStatus" + duration: int # int + +class CIContent_rcvCall(TypedDict): + type: Literal["rcvCall"] + status: "CICallStatus" + duration: int # int + +class CIContent_rcvIntegrityError(TypedDict): + type: Literal["rcvIntegrityError"] + msgError: "MsgErrorType" + +class CIContent_rcvDecryptionError(TypedDict): + type: Literal["rcvDecryptionError"] + msgDecryptError: "MsgDecryptError" + msgCount: int # word32 + +class CIContent_rcvMsgError(TypedDict): + type: Literal["rcvMsgError"] + rcvMsgError: "RcvMsgError" + +class CIContent_rcvGroupInvitation(TypedDict): + type: Literal["rcvGroupInvitation"] + groupInvitation: "CIGroupInvitation" + memberRole: "GroupMemberRole" + +class CIContent_sndGroupInvitation(TypedDict): + type: Literal["sndGroupInvitation"] + groupInvitation: "CIGroupInvitation" + memberRole: "GroupMemberRole" + +class CIContent_rcvDirectEvent(TypedDict): + type: Literal["rcvDirectEvent"] + rcvDirectEvent: "RcvDirectEvent" + +class CIContent_rcvGroupEvent(TypedDict): + type: Literal["rcvGroupEvent"] + rcvGroupEvent: "RcvGroupEvent" + +class CIContent_sndGroupEvent(TypedDict): + type: Literal["sndGroupEvent"] + sndGroupEvent: "SndGroupEvent" + +class CIContent_rcvConnEvent(TypedDict): + type: Literal["rcvConnEvent"] + rcvConnEvent: "RcvConnEvent" + +class CIContent_sndConnEvent(TypedDict): + type: Literal["sndConnEvent"] + sndConnEvent: "SndConnEvent" + +class CIContent_rcvChatFeature(TypedDict): + type: Literal["rcvChatFeature"] + feature: "ChatFeature" + enabled: "PrefEnabled" + param: NotRequired[int] # int + +class CIContent_sndChatFeature(TypedDict): + type: Literal["sndChatFeature"] + feature: "ChatFeature" + enabled: "PrefEnabled" + param: NotRequired[int] # int + +class CIContent_rcvChatPreference(TypedDict): + type: Literal["rcvChatPreference"] + feature: "ChatFeature" + allowed: "FeatureAllowed" + param: NotRequired[int] # int + +class CIContent_sndChatPreference(TypedDict): + type: Literal["sndChatPreference"] + feature: "ChatFeature" + allowed: "FeatureAllowed" + param: NotRequired[int] # int + +class CIContent_rcvGroupFeature(TypedDict): + type: Literal["rcvGroupFeature"] + groupFeature: "GroupFeature" + preference: "GroupPreference" + param: NotRequired[int] # int + memberRole_: NotRequired["GroupMemberRole"] + +class CIContent_sndGroupFeature(TypedDict): + type: Literal["sndGroupFeature"] + groupFeature: "GroupFeature" + preference: "GroupPreference" + param: NotRequired[int] # int + memberRole_: NotRequired["GroupMemberRole"] + +class CIContent_rcvChatFeatureRejected(TypedDict): + type: Literal["rcvChatFeatureRejected"] + feature: "ChatFeature" + +class CIContent_rcvGroupFeatureRejected(TypedDict): + type: Literal["rcvGroupFeatureRejected"] + groupFeature: "GroupFeature" + +class CIContent_sndModerated(TypedDict): + type: Literal["sndModerated"] + +class CIContent_rcvModerated(TypedDict): + type: Literal["rcvModerated"] + +class CIContent_rcvBlocked(TypedDict): + type: Literal["rcvBlocked"] + +class CIContent_sndDirectE2EEInfo(TypedDict): + type: Literal["sndDirectE2EEInfo"] + e2eeInfo: "E2EInfo" + +class CIContent_rcvDirectE2EEInfo(TypedDict): + type: Literal["rcvDirectE2EEInfo"] + e2eeInfo: "E2EInfo" + +class CIContent_sndGroupE2EEInfo(TypedDict): + type: Literal["sndGroupE2EEInfo"] + e2eeInfo: "E2EInfo" + +class CIContent_rcvGroupE2EEInfo(TypedDict): + type: Literal["rcvGroupE2EEInfo"] + e2eeInfo: "E2EInfo" + +class CIContent_chatBanner(TypedDict): + type: Literal["chatBanner"] + +CIContent = ( + CIContent_sndMsgContent + | CIContent_rcvMsgContent + | CIContent_sndDeleted + | CIContent_rcvDeleted + | CIContent_sndCall + | CIContent_rcvCall + | CIContent_rcvIntegrityError + | CIContent_rcvDecryptionError + | CIContent_rcvMsgError + | CIContent_rcvGroupInvitation + | CIContent_sndGroupInvitation + | CIContent_rcvDirectEvent + | CIContent_rcvGroupEvent + | CIContent_sndGroupEvent + | CIContent_rcvConnEvent + | CIContent_sndConnEvent + | CIContent_rcvChatFeature + | CIContent_sndChatFeature + | CIContent_rcvChatPreference + | CIContent_sndChatPreference + | CIContent_rcvGroupFeature + | CIContent_sndGroupFeature + | CIContent_rcvChatFeatureRejected + | CIContent_rcvGroupFeatureRejected + | CIContent_sndModerated + | CIContent_rcvModerated + | CIContent_rcvBlocked + | CIContent_sndDirectE2EEInfo + | CIContent_rcvDirectE2EEInfo + | CIContent_sndGroupE2EEInfo + | CIContent_rcvGroupE2EEInfo + | CIContent_chatBanner +) + +CIContent_Tag = Literal["sndMsgContent", "rcvMsgContent", "sndDeleted", "rcvDeleted", "sndCall", "rcvCall", "rcvIntegrityError", "rcvDecryptionError", "rcvMsgError", "rcvGroupInvitation", "sndGroupInvitation", "rcvDirectEvent", "rcvGroupEvent", "sndGroupEvent", "rcvConnEvent", "sndConnEvent", "rcvChatFeature", "sndChatFeature", "rcvChatPreference", "sndChatPreference", "rcvGroupFeature", "sndGroupFeature", "rcvChatFeatureRejected", "rcvGroupFeatureRejected", "sndModerated", "rcvModerated", "rcvBlocked", "sndDirectE2EEInfo", "rcvDirectE2EEInfo", "sndGroupE2EEInfo", "rcvGroupE2EEInfo", "chatBanner"] + +CIDeleteMode = Literal["broadcast", "internal", "internalMark", "history"] + +class CIDeleted_deleted(TypedDict): + type: Literal["deleted"] + deletedTs: NotRequired[str] # ISO-8601 timestamp + chatType: "ChatType" + +class CIDeleted_blocked(TypedDict): + type: Literal["blocked"] + deletedTs: NotRequired[str] # ISO-8601 timestamp + +class CIDeleted_blockedByAdmin(TypedDict): + type: Literal["blockedByAdmin"] + deletedTs: NotRequired[str] # ISO-8601 timestamp + +class CIDeleted_moderated(TypedDict): + type: Literal["moderated"] + deletedTs: NotRequired[str] # ISO-8601 timestamp + byGroupMember: "GroupMember" + +CIDeleted = CIDeleted_deleted | CIDeleted_blocked | CIDeleted_blockedByAdmin | CIDeleted_moderated + +CIDeleted_Tag = Literal["deleted", "blocked", "blockedByAdmin", "moderated"] + +class CIDirection_directSnd(TypedDict): + type: Literal["directSnd"] + +class CIDirection_directRcv(TypedDict): + type: Literal["directRcv"] + +class CIDirection_groupSnd(TypedDict): + type: Literal["groupSnd"] + +class CIDirection_groupRcv(TypedDict): + type: Literal["groupRcv"] + groupMember: "GroupMember" + +class CIDirection_channelRcv(TypedDict): + type: Literal["channelRcv"] + +class CIDirection_localSnd(TypedDict): + type: Literal["localSnd"] + +class CIDirection_localRcv(TypedDict): + type: Literal["localRcv"] + +CIDirection = ( + CIDirection_directSnd + | CIDirection_directRcv + | CIDirection_groupSnd + | CIDirection_groupRcv + | CIDirection_channelRcv + | CIDirection_localSnd + | CIDirection_localRcv +) + +CIDirection_Tag = Literal["directSnd", "directRcv", "groupSnd", "groupRcv", "channelRcv", "localSnd", "localRcv"] + +class CIFile(TypedDict): + fileId: int # int64 + fileName: str + fileSize: int # int64 + fileSource: NotRequired["CryptoFile"] + fileStatus: "CIFileStatus" + fileProtocol: "FileProtocol" + +class CIFileStatus_sndStored(TypedDict): + type: Literal["sndStored"] + +class CIFileStatus_sndTransfer(TypedDict): + type: Literal["sndTransfer"] + sndProgress: int # int64 + sndTotal: int # int64 + +class CIFileStatus_sndCancelled(TypedDict): + type: Literal["sndCancelled"] + +class CIFileStatus_sndComplete(TypedDict): + type: Literal["sndComplete"] + +class CIFileStatus_sndError(TypedDict): + type: Literal["sndError"] + sndFileError: "FileError" + +class CIFileStatus_sndWarning(TypedDict): + type: Literal["sndWarning"] + sndFileError: "FileError" + +class CIFileStatus_rcvInvitation(TypedDict): + type: Literal["rcvInvitation"] + +class CIFileStatus_rcvAccepted(TypedDict): + type: Literal["rcvAccepted"] + +class CIFileStatus_rcvTransfer(TypedDict): + type: Literal["rcvTransfer"] + rcvProgress: int # int64 + rcvTotal: int # int64 + +class CIFileStatus_rcvAborted(TypedDict): + type: Literal["rcvAborted"] + +class CIFileStatus_rcvComplete(TypedDict): + type: Literal["rcvComplete"] + +class CIFileStatus_rcvCancelled(TypedDict): + type: Literal["rcvCancelled"] + +class CIFileStatus_rcvError(TypedDict): + type: Literal["rcvError"] + rcvFileError: "FileError" + +class CIFileStatus_rcvWarning(TypedDict): + type: Literal["rcvWarning"] + rcvFileError: "FileError" + +class CIFileStatus_invalid(TypedDict): + type: Literal["invalid"] + text: str + +CIFileStatus = ( + CIFileStatus_sndStored + | CIFileStatus_sndTransfer + | CIFileStatus_sndCancelled + | CIFileStatus_sndComplete + | CIFileStatus_sndError + | CIFileStatus_sndWarning + | CIFileStatus_rcvInvitation + | CIFileStatus_rcvAccepted + | CIFileStatus_rcvTransfer + | CIFileStatus_rcvAborted + | CIFileStatus_rcvComplete + | CIFileStatus_rcvCancelled + | CIFileStatus_rcvError + | CIFileStatus_rcvWarning + | CIFileStatus_invalid +) + +CIFileStatus_Tag = Literal["sndStored", "sndTransfer", "sndCancelled", "sndComplete", "sndError", "sndWarning", "rcvInvitation", "rcvAccepted", "rcvTransfer", "rcvAborted", "rcvComplete", "rcvCancelled", "rcvError", "rcvWarning", "invalid"] + +class CIForwardedFrom_unknown(TypedDict): + type: Literal["unknown"] + +class CIForwardedFrom_contact(TypedDict): + type: Literal["contact"] + chatName: str + msgDir: "MsgDirection" + contactId: NotRequired[int] # int64 + chatItemId: NotRequired[int] # int64 + +class CIForwardedFrom_group(TypedDict): + type: Literal["group"] + chatName: str + msgDir: "MsgDirection" + groupId: NotRequired[int] # int64 + chatItemId: NotRequired[int] # int64 + +CIForwardedFrom = CIForwardedFrom_unknown | CIForwardedFrom_contact | CIForwardedFrom_group + +CIForwardedFrom_Tag = Literal["unknown", "contact", "group"] + +class CIGroupInvitation(TypedDict): + groupId: int # int64 + groupMemberId: int # int64 + localDisplayName: str + groupProfile: "GroupProfile" + status: "CIGroupInvitationStatus" + +CIGroupInvitationStatus = Literal["pending", "accepted", "rejected", "expired"] + +class CIMention(TypedDict): + memberId: str + memberRef: NotRequired["CIMentionMember"] + +class CIMentionMember(TypedDict): + groupMemberId: int # int64 + displayName: str + localAlias: NotRequired[str] + memberRole: "GroupMemberRole" + +class CIMeta(TypedDict): + itemId: int # int64 + itemTs: str # ISO-8601 timestamp + itemText: str + itemStatus: "CIStatus" + sentViaProxy: NotRequired[bool] + itemSharedMsgId: NotRequired[str] + itemForwarded: NotRequired["CIForwardedFrom"] + itemDeleted: NotRequired["CIDeleted"] + itemEdited: bool + itemTimed: NotRequired["CITimed"] + itemLive: NotRequired[bool] + userMention: bool + hasLink: bool + deletable: bool + editable: bool + forwardedByMember: NotRequired[int] # int64 + showGroupAsSender: bool + msgSigned: NotRequired["MsgSigStatus"] + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + +class CIQuote(TypedDict): + chatDir: NotRequired["CIDirection"] + itemId: NotRequired[int] # int64 + sharedMsgId: NotRequired[str] + sentAt: str # ISO-8601 timestamp + content: "MsgContent" + formattedText: NotRequired[list["FormattedText"]] + +class CIReaction(TypedDict): + chatDir: "CIDirection" + chatItem: "ChatItem" + sentAt: str # ISO-8601 timestamp + reaction: "MsgReaction" + +class CIReactionCount(TypedDict): + reaction: "MsgReaction" + userReacted: bool + totalReacted: int # int + +class CIStatus_sndNew(TypedDict): + type: Literal["sndNew"] + +class CIStatus_sndSent(TypedDict): + type: Literal["sndSent"] + sndProgress: "SndCIStatusProgress" + +class CIStatus_sndRcvd(TypedDict): + type: Literal["sndRcvd"] + msgRcptStatus: "MsgReceiptStatus" + sndProgress: "SndCIStatusProgress" + +class CIStatus_sndErrorAuth(TypedDict): + type: Literal["sndErrorAuth"] + +class CIStatus_sndError(TypedDict): + type: Literal["sndError"] + agentError: "SndError" + +class CIStatus_sndWarning(TypedDict): + type: Literal["sndWarning"] + agentError: "SndError" + +class CIStatus_rcvNew(TypedDict): + type: Literal["rcvNew"] + +class CIStatus_rcvRead(TypedDict): + type: Literal["rcvRead"] + +class CIStatus_invalid(TypedDict): + type: Literal["invalid"] + text: str + +CIStatus = ( + CIStatus_sndNew + | CIStatus_sndSent + | CIStatus_sndRcvd + | CIStatus_sndErrorAuth + | CIStatus_sndError + | CIStatus_sndWarning + | CIStatus_rcvNew + | CIStatus_rcvRead + | CIStatus_invalid +) + +CIStatus_Tag = Literal["sndNew", "sndSent", "sndRcvd", "sndErrorAuth", "sndError", "sndWarning", "rcvNew", "rcvRead", "invalid"] + +class CITimed(TypedDict): + ttl: int # int + deleteAt: NotRequired[str] # ISO-8601 timestamp + +class ChatBotCommand_command(TypedDict): + type: Literal["command"] + keyword: str + label: str + params: NotRequired[str] + +class ChatBotCommand_menu(TypedDict): + type: Literal["menu"] + label: str + commands: list["ChatBotCommand"] + +ChatBotCommand = ChatBotCommand_command | ChatBotCommand_menu + +ChatBotCommand_Tag = Literal["command", "menu"] + +class ChatDeleteMode_full(TypedDict): + type: Literal["full"] + notify: bool + +class ChatDeleteMode_entity(TypedDict): + type: Literal["entity"] + notify: bool + +class ChatDeleteMode_messages(TypedDict): + type: Literal["messages"] + +ChatDeleteMode = ChatDeleteMode_full | ChatDeleteMode_entity | ChatDeleteMode_messages + +ChatDeleteMode_Tag = Literal["full", "entity", "messages"] + + +def ChatDeleteMode_cmd_string(self: ChatDeleteMode) -> str: + return str(self['type']) + ('' if str(self['type']) == 'messages' else (' notify=off' if not self['notify'] else '')) # type: ignore[typeddict-item] + +class ChatError_error(TypedDict): + type: Literal["error"] + errorType: "ChatErrorType" + +class ChatError_errorAgent(TypedDict): + type: Literal["errorAgent"] + agentError: "AgentErrorType" + agentConnId: str + connectionEntity_: NotRequired["ConnectionEntity"] + +class ChatError_errorStore(TypedDict): + type: Literal["errorStore"] + storeError: "StoreError" + +ChatError = ChatError_error | ChatError_errorAgent | ChatError_errorStore + +ChatError_Tag = Literal["error", "errorAgent", "errorStore"] + +class ChatErrorType_noActiveUser(TypedDict): + type: Literal["noActiveUser"] + +class ChatErrorType_noConnectionUser(TypedDict): + type: Literal["noConnectionUser"] + agentConnId: str + +class ChatErrorType_noSndFileUser(TypedDict): + type: Literal["noSndFileUser"] + agentSndFileId: str + +class ChatErrorType_noRcvFileUser(TypedDict): + type: Literal["noRcvFileUser"] + agentRcvFileId: str + +class ChatErrorType_userUnknown(TypedDict): + type: Literal["userUnknown"] + +class ChatErrorType_activeUserExists(TypedDict): + type: Literal["activeUserExists"] + +class ChatErrorType_userExists(TypedDict): + type: Literal["userExists"] + contactName: str + +class ChatErrorType_chatRelayExists(TypedDict): + type: Literal["chatRelayExists"] + +class ChatErrorType_differentActiveUser(TypedDict): + type: Literal["differentActiveUser"] + commandUserId: int # int64 + activeUserId: int # int64 + +class ChatErrorType_cantDeleteActiveUser(TypedDict): + type: Literal["cantDeleteActiveUser"] + userId: int # int64 + +class ChatErrorType_cantDeleteLastUser(TypedDict): + type: Literal["cantDeleteLastUser"] + userId: int # int64 + +class ChatErrorType_cantHideLastUser(TypedDict): + type: Literal["cantHideLastUser"] + userId: int # int64 + +class ChatErrorType_hiddenUserAlwaysMuted(TypedDict): + type: Literal["hiddenUserAlwaysMuted"] + userId: int # int64 + +class ChatErrorType_emptyUserPassword(TypedDict): + type: Literal["emptyUserPassword"] + userId: int # int64 + +class ChatErrorType_userAlreadyHidden(TypedDict): + type: Literal["userAlreadyHidden"] + userId: int # int64 + +class ChatErrorType_userNotHidden(TypedDict): + type: Literal["userNotHidden"] + userId: int # int64 + +class ChatErrorType_invalidDisplayName(TypedDict): + type: Literal["invalidDisplayName"] + displayName: str + validName: str + +class ChatErrorType_chatNotStarted(TypedDict): + type: Literal["chatNotStarted"] + +class ChatErrorType_chatNotStopped(TypedDict): + type: Literal["chatNotStopped"] + +class ChatErrorType_chatStoreChanged(TypedDict): + type: Literal["chatStoreChanged"] + +class ChatErrorType_invalidConnReq(TypedDict): + type: Literal["invalidConnReq"] + +class ChatErrorType_unsupportedConnReq(TypedDict): + type: Literal["unsupportedConnReq"] + +class ChatErrorType_connReqMessageProhibited(TypedDict): + type: Literal["connReqMessageProhibited"] + +class ChatErrorType_contactNotReady(TypedDict): + type: Literal["contactNotReady"] + contact: "Contact" + +class ChatErrorType_contactNotActive(TypedDict): + type: Literal["contactNotActive"] + contact: "Contact" + +class ChatErrorType_contactDisabled(TypedDict): + type: Literal["contactDisabled"] + contact: "Contact" + +class ChatErrorType_connectionDisabled(TypedDict): + type: Literal["connectionDisabled"] + connection: "Connection" + +class ChatErrorType_groupUserRole(TypedDict): + type: Literal["groupUserRole"] + groupInfo: "GroupInfo" + requiredRole: "GroupMemberRole" + +class ChatErrorType_groupMemberInitialRole(TypedDict): + type: Literal["groupMemberInitialRole"] + groupInfo: "GroupInfo" + initialRole: "GroupMemberRole" + +class ChatErrorType_contactIncognitoCantInvite(TypedDict): + type: Literal["contactIncognitoCantInvite"] + +class ChatErrorType_groupIncognitoCantInvite(TypedDict): + type: Literal["groupIncognitoCantInvite"] + +class ChatErrorType_groupContactRole(TypedDict): + type: Literal["groupContactRole"] + contactName: str + +class ChatErrorType_groupDuplicateMember(TypedDict): + type: Literal["groupDuplicateMember"] + contactName: str + +class ChatErrorType_groupDuplicateMemberId(TypedDict): + type: Literal["groupDuplicateMemberId"] + +class ChatErrorType_groupNotJoined(TypedDict): + type: Literal["groupNotJoined"] + groupInfo: "GroupInfo" + +class ChatErrorType_groupMemberNotActive(TypedDict): + type: Literal["groupMemberNotActive"] + +class ChatErrorType_cantBlockMemberForSelf(TypedDict): + type: Literal["cantBlockMemberForSelf"] + groupInfo: "GroupInfo" + member: "GroupMember" + setShowMessages: bool + +class ChatErrorType_groupMemberUserRemoved(TypedDict): + type: Literal["groupMemberUserRemoved"] + +class ChatErrorType_groupMemberNotFound(TypedDict): + type: Literal["groupMemberNotFound"] + +class ChatErrorType_groupCantResendInvitation(TypedDict): + type: Literal["groupCantResendInvitation"] + groupInfo: "GroupInfo" + contactName: str + +class ChatErrorType_groupInternal(TypedDict): + type: Literal["groupInternal"] + message: str + +class ChatErrorType_fileNotFound(TypedDict): + type: Literal["fileNotFound"] + message: str + +class ChatErrorType_fileSize(TypedDict): + type: Literal["fileSize"] + filePath: str + +class ChatErrorType_fileAlreadyReceiving(TypedDict): + type: Literal["fileAlreadyReceiving"] + message: str + +class ChatErrorType_fileCancelled(TypedDict): + type: Literal["fileCancelled"] + message: str + +class ChatErrorType_fileCancel(TypedDict): + type: Literal["fileCancel"] + fileId: int # int64 + message: str + +class ChatErrorType_fileAlreadyExists(TypedDict): + type: Literal["fileAlreadyExists"] + filePath: str + +class ChatErrorType_fileWrite(TypedDict): + type: Literal["fileWrite"] + filePath: str + message: str + +class ChatErrorType_fileSend(TypedDict): + type: Literal["fileSend"] + fileId: int # int64 + agentError: "AgentErrorType" + +class ChatErrorType_fileRcvChunk(TypedDict): + type: Literal["fileRcvChunk"] + message: str + +class ChatErrorType_fileInternal(TypedDict): + type: Literal["fileInternal"] + message: str + +class ChatErrorType_fileImageType(TypedDict): + type: Literal["fileImageType"] + filePath: str + +class ChatErrorType_fileImageSize(TypedDict): + type: Literal["fileImageSize"] + filePath: str + +class ChatErrorType_fileNotReceived(TypedDict): + type: Literal["fileNotReceived"] + fileId: int # int64 + +class ChatErrorType_fileNotApproved(TypedDict): + type: Literal["fileNotApproved"] + fileId: int # int64 + unknownServers: list[str] + +class ChatErrorType_fallbackToSMPProhibited(TypedDict): + type: Literal["fallbackToSMPProhibited"] + fileId: int # int64 + +class ChatErrorType_inlineFileProhibited(TypedDict): + type: Literal["inlineFileProhibited"] + fileId: int # int64 + +class ChatErrorType_invalidForward(TypedDict): + type: Literal["invalidForward"] + +class ChatErrorType_invalidChatItemUpdate(TypedDict): + type: Literal["invalidChatItemUpdate"] + +class ChatErrorType_invalidChatItemDelete(TypedDict): + type: Literal["invalidChatItemDelete"] + +class ChatErrorType_hasCurrentCall(TypedDict): + type: Literal["hasCurrentCall"] + +class ChatErrorType_noCurrentCall(TypedDict): + type: Literal["noCurrentCall"] + +class ChatErrorType_callContact(TypedDict): + type: Literal["callContact"] + contactId: int # int64 + +class ChatErrorType_directMessagesProhibited(TypedDict): + type: Literal["directMessagesProhibited"] + direction: "MsgDirection" + contact: "Contact" + +class ChatErrorType_agentVersion(TypedDict): + type: Literal["agentVersion"] + +class ChatErrorType_agentNoSubResult(TypedDict): + type: Literal["agentNoSubResult"] + agentConnId: str + +class ChatErrorType_commandError(TypedDict): + type: Literal["commandError"] + message: str + +class ChatErrorType_agentCommandError(TypedDict): + type: Literal["agentCommandError"] + message: str + +class ChatErrorType_invalidFileDescription(TypedDict): + type: Literal["invalidFileDescription"] + message: str + +class ChatErrorType_connectionIncognitoChangeProhibited(TypedDict): + type: Literal["connectionIncognitoChangeProhibited"] + +class ChatErrorType_connectionUserChangeProhibited(TypedDict): + type: Literal["connectionUserChangeProhibited"] + +class ChatErrorType_peerChatVRangeIncompatible(TypedDict): + type: Literal["peerChatVRangeIncompatible"] + +class ChatErrorType_relayTestError(TypedDict): + type: Literal["relayTestError"] + message: str + +class ChatErrorType_internalError(TypedDict): + type: Literal["internalError"] + message: str + +class ChatErrorType_exception(TypedDict): + type: Literal["exception"] + message: str + +ChatErrorType = ( + ChatErrorType_noActiveUser + | ChatErrorType_noConnectionUser + | ChatErrorType_noSndFileUser + | ChatErrorType_noRcvFileUser + | ChatErrorType_userUnknown + | ChatErrorType_activeUserExists + | ChatErrorType_userExists + | ChatErrorType_chatRelayExists + | ChatErrorType_differentActiveUser + | ChatErrorType_cantDeleteActiveUser + | ChatErrorType_cantDeleteLastUser + | ChatErrorType_cantHideLastUser + | ChatErrorType_hiddenUserAlwaysMuted + | ChatErrorType_emptyUserPassword + | ChatErrorType_userAlreadyHidden + | ChatErrorType_userNotHidden + | ChatErrorType_invalidDisplayName + | ChatErrorType_chatNotStarted + | ChatErrorType_chatNotStopped + | ChatErrorType_chatStoreChanged + | ChatErrorType_invalidConnReq + | ChatErrorType_unsupportedConnReq + | ChatErrorType_connReqMessageProhibited + | ChatErrorType_contactNotReady + | ChatErrorType_contactNotActive + | ChatErrorType_contactDisabled + | ChatErrorType_connectionDisabled + | ChatErrorType_groupUserRole + | ChatErrorType_groupMemberInitialRole + | ChatErrorType_contactIncognitoCantInvite + | ChatErrorType_groupIncognitoCantInvite + | ChatErrorType_groupContactRole + | ChatErrorType_groupDuplicateMember + | ChatErrorType_groupDuplicateMemberId + | ChatErrorType_groupNotJoined + | ChatErrorType_groupMemberNotActive + | ChatErrorType_cantBlockMemberForSelf + | ChatErrorType_groupMemberUserRemoved + | ChatErrorType_groupMemberNotFound + | ChatErrorType_groupCantResendInvitation + | ChatErrorType_groupInternal + | ChatErrorType_fileNotFound + | ChatErrorType_fileSize + | ChatErrorType_fileAlreadyReceiving + | ChatErrorType_fileCancelled + | ChatErrorType_fileCancel + | ChatErrorType_fileAlreadyExists + | ChatErrorType_fileWrite + | ChatErrorType_fileSend + | ChatErrorType_fileRcvChunk + | ChatErrorType_fileInternal + | ChatErrorType_fileImageType + | ChatErrorType_fileImageSize + | ChatErrorType_fileNotReceived + | ChatErrorType_fileNotApproved + | ChatErrorType_fallbackToSMPProhibited + | ChatErrorType_inlineFileProhibited + | ChatErrorType_invalidForward + | ChatErrorType_invalidChatItemUpdate + | ChatErrorType_invalidChatItemDelete + | ChatErrorType_hasCurrentCall + | ChatErrorType_noCurrentCall + | ChatErrorType_callContact + | ChatErrorType_directMessagesProhibited + | ChatErrorType_agentVersion + | ChatErrorType_agentNoSubResult + | ChatErrorType_commandError + | ChatErrorType_agentCommandError + | ChatErrorType_invalidFileDescription + | ChatErrorType_connectionIncognitoChangeProhibited + | ChatErrorType_connectionUserChangeProhibited + | ChatErrorType_peerChatVRangeIncompatible + | ChatErrorType_relayTestError + | ChatErrorType_internalError + | ChatErrorType_exception +) + +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "activeUserExists", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] + +ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] + +class ChatInfo_direct(TypedDict): + type: Literal["direct"] + contact: "Contact" + +class ChatInfo_group(TypedDict): + type: Literal["group"] + groupInfo: "GroupInfo" + groupChatScope: NotRequired["GroupChatScopeInfo"] + +class ChatInfo_local(TypedDict): + type: Literal["local"] + noteFolder: "NoteFolder" + +class ChatInfo_contactRequest(TypedDict): + type: Literal["contactRequest"] + contactRequest: "UserContactRequest" + +class ChatInfo_contactConnection(TypedDict): + type: Literal["contactConnection"] + contactConnection: "PendingContactConnection" + +ChatInfo = ( + ChatInfo_direct + | ChatInfo_group + | ChatInfo_local + | ChatInfo_contactRequest + | ChatInfo_contactConnection +) + +ChatInfo_Tag = Literal["direct", "group", "local", "contactRequest", "contactConnection"] + +class ChatItem(TypedDict): + chatDir: "CIDirection" + meta: "CIMeta" + content: "CIContent" + mentions: dict[str, "CIMention"] + formattedText: NotRequired[list["FormattedText"]] + quotedItem: NotRequired["CIQuote"] + reactions: list["CIReactionCount"] + file: NotRequired["CIFile"] + +# Message deletion result. + +class ChatItemDeletion(TypedDict): + deletedChatItem: "AChatItem" + toChatItem: NotRequired["AChatItem"] + +class ChatListQuery_filters(TypedDict): + type: Literal["filters"] + favorite: bool + unread: bool + +class ChatListQuery_search(TypedDict): + type: Literal["search"] + search: str + +ChatListQuery = ChatListQuery_filters | ChatListQuery_search + +ChatListQuery_Tag = Literal["filters", "search"] + +ChatPeerType = Literal["human", "bot"] + +# Used in API commands. Chat scope can only be passed with groups. + +class ChatRef(TypedDict): + chatType: "ChatType" + chatId: int # int64 + chatScope: NotRequired["GroupChatScope"] + + +def ChatRef_cmd_string(self: ChatRef) -> str: + return ChatType_cmd_string(self['chatType']) + str(self['chatId']) + ((GroupChatScope_cmd_string(self.get('chatScope'))) if self.get('chatScope') is not None else '') + +class ChatSettings(TypedDict): + enableNtfs: "MsgFilter" + sendRcpts: NotRequired[bool] + favorite: bool + +class ChatStats(TypedDict): + unreadCount: int # int + unreadMentions: int # int + reportsCount: int # int + minUnreadItemId: int # int64 + unreadChat: bool + +ChatType = Literal["direct", "group", "local"] + + +def ChatType_cmd_string(self: ChatType) -> str: + return '@' if str(self) == 'direct' else '#' if str(self) == 'group' else '*' if str(self) == 'local' else '' + +class ChatWallpaper(TypedDict): + preset: NotRequired[str] + imageFile: NotRequired[str] + background: NotRequired[str] + tint: NotRequired[str] + scaleType: NotRequired["ChatWallpaperScale"] + scale: NotRequired[float] # double + +ChatWallpaperScale = Literal["fill", "fit", "repeat"] + +class ClientNotice(TypedDict): + ttl: NotRequired[int] # int64 + +Color = Literal["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] + +class CommandError_UNKNOWN(TypedDict): + type: Literal["UNKNOWN"] + +class CommandError_SYNTAX(TypedDict): + type: Literal["SYNTAX"] + +class CommandError_PROHIBITED(TypedDict): + type: Literal["PROHIBITED"] + +class CommandError_NO_AUTH(TypedDict): + type: Literal["NO_AUTH"] + +class CommandError_HAS_AUTH(TypedDict): + type: Literal["HAS_AUTH"] + +class CommandError_NO_ENTITY(TypedDict): + type: Literal["NO_ENTITY"] + +CommandError = ( + CommandError_UNKNOWN + | CommandError_SYNTAX + | CommandError_PROHIBITED + | CommandError_NO_AUTH + | CommandError_HAS_AUTH + | CommandError_NO_ENTITY +) + +CommandError_Tag = Literal["UNKNOWN", "SYNTAX", "PROHIBITED", "NO_AUTH", "HAS_AUTH", "NO_ENTITY"] + +class CommandErrorType_PROHIBITED(TypedDict): + type: Literal["PROHIBITED"] + +class CommandErrorType_SYNTAX(TypedDict): + type: Literal["SYNTAX"] + +class CommandErrorType_NO_CONN(TypedDict): + type: Literal["NO_CONN"] + +class CommandErrorType_SIZE(TypedDict): + type: Literal["SIZE"] + +class CommandErrorType_LARGE(TypedDict): + type: Literal["LARGE"] + +CommandErrorType = ( + CommandErrorType_PROHIBITED + | CommandErrorType_SYNTAX + | CommandErrorType_NO_CONN + | CommandErrorType_SIZE + | CommandErrorType_LARGE +) + +CommandErrorType_Tag = Literal["PROHIBITED", "SYNTAX", "NO_CONN", "SIZE", "LARGE"] + +class CommentsGroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + duration: NotRequired[int] # int + +class ComposedMessage(TypedDict): + fileSource: NotRequired["CryptoFile"] + quotedItemId: NotRequired[int] # int64 + msgContent: "MsgContent" + mentions: dict[str, int] # str : int64 + +class ConnStatus_new(TypedDict): + type: Literal["new"] + +class ConnStatus_prepared(TypedDict): + type: Literal["prepared"] + +class ConnStatus_joined(TypedDict): + type: Literal["joined"] + +class ConnStatus_requested(TypedDict): + type: Literal["requested"] + +class ConnStatus_accepted(TypedDict): + type: Literal["accepted"] + +class ConnStatus_sndReady(TypedDict): + type: Literal["sndReady"] + +class ConnStatus_ready(TypedDict): + type: Literal["ready"] + +class ConnStatus_deleted(TypedDict): + type: Literal["deleted"] + +class ConnStatus_failed(TypedDict): + type: Literal["failed"] + connError: str + +ConnStatus = ( + ConnStatus_new + | ConnStatus_prepared + | ConnStatus_joined + | ConnStatus_requested + | ConnStatus_accepted + | ConnStatus_sndReady + | ConnStatus_ready + | ConnStatus_deleted + | ConnStatus_failed +) + +ConnStatus_Tag = Literal["new", "prepared", "joined", "requested", "accepted", "sndReady", "ready", "deleted", "failed"] + +ConnType = Literal["contact", "member", "user_contact"] + +class Connection(TypedDict): + connId: int # int64 + agentConnId: str + connChatVersion: int # int + peerChatVRange: "VersionRange" + connLevel: int # int + viaContact: NotRequired[int] # int64 + viaUserContactLink: NotRequired[int] # int64 + viaGroupLink: bool + groupLinkId: NotRequired[str] + xContactId: NotRequired[str] + customUserProfileId: NotRequired[int] # int64 + connType: "ConnType" + connStatus: "ConnStatus" + contactConnInitiated: bool + localAlias: str + entityId: NotRequired[int] # int64 + connectionCode: NotRequired["SecurityCode"] + pqSupport: bool + pqEncryption: bool + pqSndEnabled: NotRequired[bool] + pqRcvEnabled: NotRequired[bool] + authErrCounter: int # int + quotaErrCounter: int # int + createdAt: str # ISO-8601 timestamp + +class ConnectionEntity_rcvDirectMsgConnection(TypedDict): + type: Literal["rcvDirectMsgConnection"] + entityConnection: "Connection" + contact: NotRequired["Contact"] + +class ConnectionEntity_rcvGroupMsgConnection(TypedDict): + type: Literal["rcvGroupMsgConnection"] + entityConnection: "Connection" + groupInfo: "GroupInfo" + groupMember: "GroupMember" + +class ConnectionEntity_userContactConnection(TypedDict): + type: Literal["userContactConnection"] + entityConnection: "Connection" + userContact: "UserContact" + +ConnectionEntity = ( + ConnectionEntity_rcvDirectMsgConnection + | ConnectionEntity_rcvGroupMsgConnection + | ConnectionEntity_userContactConnection +) + +ConnectionEntity_Tag = Literal["rcvDirectMsgConnection", "rcvGroupMsgConnection", "userContactConnection"] + +class ConnectionErrorType_NOT_FOUND(TypedDict): + type: Literal["NOT_FOUND"] + +class ConnectionErrorType_DUPLICATE(TypedDict): + type: Literal["DUPLICATE"] + +class ConnectionErrorType_SIMPLEX(TypedDict): + type: Literal["SIMPLEX"] + +class ConnectionErrorType_NOT_ACCEPTED(TypedDict): + type: Literal["NOT_ACCEPTED"] + +class ConnectionErrorType_NOT_AVAILABLE(TypedDict): + type: Literal["NOT_AVAILABLE"] + +ConnectionErrorType = ( + ConnectionErrorType_NOT_FOUND + | ConnectionErrorType_DUPLICATE + | ConnectionErrorType_SIMPLEX + | ConnectionErrorType_NOT_ACCEPTED + | ConnectionErrorType_NOT_AVAILABLE +) + +ConnectionErrorType_Tag = Literal["NOT_FOUND", "DUPLICATE", "SIMPLEX", "NOT_ACCEPTED", "NOT_AVAILABLE"] + +ConnectionMode = Literal["INV", "CON"] + +class ConnectionPlan_invitationLink(TypedDict): + type: Literal["invitationLink"] + invitationLinkPlan: "InvitationLinkPlan" + +class ConnectionPlan_contactAddress(TypedDict): + type: Literal["contactAddress"] + contactAddressPlan: "ContactAddressPlan" + +class ConnectionPlan_groupLink(TypedDict): + type: Literal["groupLink"] + groupLinkPlan: "GroupLinkPlan" + +class ConnectionPlan_error(TypedDict): + type: Literal["error"] + chatError: "ChatError" + +ConnectionPlan = ( + ConnectionPlan_invitationLink + | ConnectionPlan_contactAddress + | ConnectionPlan_groupLink + | ConnectionPlan_error +) + +ConnectionPlan_Tag = Literal["invitationLink", "contactAddress", "groupLink", "error"] + +class Contact(TypedDict): + contactId: int # int64 + localDisplayName: str + profile: "LocalProfile" + activeConn: NotRequired["Connection"] + contactUsed: bool + contactStatus: "ContactStatus" + chatSettings: "ChatSettings" + userPreferences: "Preferences" + mergedPreferences: "ContactUserPreferences" + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + chatTs: NotRequired[str] # ISO-8601 timestamp + preparedContact: NotRequired["PreparedContact"] + contactRequestId: NotRequired[int] # int64 + contactGroupMemberId: NotRequired[int] # int64 + contactGrpInvSent: bool + groupDirectInv: NotRequired["GroupDirectInvitation"] + chatTags: list[int] # int64 + chatItemTTL: NotRequired[int] # int64 + uiThemes: NotRequired["UIThemeEntityOverrides"] + chatDeleted: bool + customData: NotRequired[dict[str, object]] + +class ContactAddressPlan_ok(TypedDict): + type: Literal["ok"] + contactSLinkData_: NotRequired["ContactShortLinkData"] + ownerVerification: NotRequired["OwnerVerification"] + +class ContactAddressPlan_ownLink(TypedDict): + type: Literal["ownLink"] + +class ContactAddressPlan_connectingConfirmReconnect(TypedDict): + type: Literal["connectingConfirmReconnect"] + +class ContactAddressPlan_connectingProhibit(TypedDict): + type: Literal["connectingProhibit"] + contact: "Contact" + +class ContactAddressPlan_known(TypedDict): + type: Literal["known"] + contact: "Contact" + +class ContactAddressPlan_contactViaAddress(TypedDict): + type: Literal["contactViaAddress"] + contact: "Contact" + +ContactAddressPlan = ( + ContactAddressPlan_ok + | ContactAddressPlan_ownLink + | ContactAddressPlan_connectingConfirmReconnect + | ContactAddressPlan_connectingProhibit + | ContactAddressPlan_known + | ContactAddressPlan_contactViaAddress +) + +ContactAddressPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "contactViaAddress"] + +class ContactShortLinkData(TypedDict): + profile: "Profile" + message: NotRequired["MsgContent"] + business: bool + +ContactStatus = Literal["active", "deleted", "deletedByUser"] + +class ContactUserPref_contact(TypedDict): + type: Literal["contact"] + preference: "SimplePreference" + +class ContactUserPref_user(TypedDict): + type: Literal["user"] + preference: "SimplePreference" + +ContactUserPref = ContactUserPref_contact | ContactUserPref_user + +ContactUserPref_Tag = Literal["contact", "user"] + +class ContactUserPreference(TypedDict): + enabled: "PrefEnabled" + userPreference: "ContactUserPref" + contactPreference: "SimplePreference" + +class ContactUserPreferences(TypedDict): + timedMessages: "ContactUserPreference" + fullDelete: "ContactUserPreference" + reactions: "ContactUserPreference" + voice: "ContactUserPreference" + files: "ContactUserPreference" + calls: "ContactUserPreference" + sessions: "ContactUserPreference" + commands: NotRequired[list["ChatBotCommand"]] + +class CreatedConnLink(TypedDict): + connFullLink: str + connShortLink: NotRequired[str] + + +def CreatedConnLink_cmd_string(self: CreatedConnLink) -> str: + return self['connFullLink'] + ((' ' + self.get('connShortLink')) if self.get('connShortLink') is not None else '') + +class CryptoFile(TypedDict): + filePath: str + cryptoArgs: NotRequired["CryptoFileArgs"] + +class CryptoFileArgs(TypedDict): + fileKey: str + fileNonce: str + +class DroppedMsg(TypedDict): + brokerTs: str # ISO-8601 timestamp + attempts: int # int + +class E2EInfo(TypedDict): + public: NotRequired[bool] + pqEnabled: NotRequired[bool] + +class ErrorType_BLOCK(TypedDict): + type: Literal["BLOCK"] + +class ErrorType_SESSION(TypedDict): + type: Literal["SESSION"] + +class ErrorType_CMD(TypedDict): + type: Literal["CMD"] + cmdErr: "CommandError" + +class ErrorType_PROXY(TypedDict): + type: Literal["PROXY"] + proxyErr: "ProxyError" + +class ErrorType_AUTH(TypedDict): + type: Literal["AUTH"] + +class ErrorType_BLOCKED(TypedDict): + type: Literal["BLOCKED"] + blockInfo: "BlockingInfo" + +class ErrorType_SERVICE(TypedDict): + type: Literal["SERVICE"] + +class ErrorType_CRYPTO(TypedDict): + type: Literal["CRYPTO"] + +class ErrorType_QUOTA(TypedDict): + type: Literal["QUOTA"] + +class ErrorType_STORE(TypedDict): + type: Literal["STORE"] + storeErr: str + +class ErrorType_NO_MSG(TypedDict): + type: Literal["NO_MSG"] + +class ErrorType_LARGE_MSG(TypedDict): + type: Literal["LARGE_MSG"] + +class ErrorType_EXPIRED(TypedDict): + type: Literal["EXPIRED"] + +class ErrorType_INTERNAL(TypedDict): + type: Literal["INTERNAL"] + +class ErrorType_DUPLICATE_(TypedDict): + type: Literal["DUPLICATE_"] + +ErrorType = ( + ErrorType_BLOCK + | ErrorType_SESSION + | ErrorType_CMD + | ErrorType_PROXY + | ErrorType_AUTH + | ErrorType_BLOCKED + | ErrorType_SERVICE + | ErrorType_CRYPTO + | ErrorType_QUOTA + | ErrorType_STORE + | ErrorType_NO_MSG + | ErrorType_LARGE_MSG + | ErrorType_EXPIRED + | ErrorType_INTERNAL + | ErrorType_DUPLICATE_ +) + +ErrorType_Tag = Literal["BLOCK", "SESSION", "CMD", "PROXY", "AUTH", "BLOCKED", "SERVICE", "CRYPTO", "QUOTA", "STORE", "NO_MSG", "LARGE_MSG", "EXPIRED", "INTERNAL", "DUPLICATE_"] + +FeatureAllowed = Literal["always", "yes", "no"] + +class FileDescr(TypedDict): + fileDescrText: str + fileDescrPartNo: int # int + fileDescrComplete: bool + +class FileError_auth(TypedDict): + type: Literal["auth"] + +class FileError_blocked(TypedDict): + type: Literal["blocked"] + server: str + blockInfo: "BlockingInfo" + +class FileError_noFile(TypedDict): + type: Literal["noFile"] + +class FileError_relay(TypedDict): + type: Literal["relay"] + srvError: "SrvError" + +class FileError_other(TypedDict): + type: Literal["other"] + fileError: str + +FileError = ( + FileError_auth + | FileError_blocked + | FileError_noFile + | FileError_relay + | FileError_other +) + +FileError_Tag = Literal["auth", "blocked", "noFile", "relay", "other"] + +class FileErrorType_NOT_APPROVED(TypedDict): + type: Literal["NOT_APPROVED"] + +class FileErrorType_SIZE(TypedDict): + type: Literal["SIZE"] + +class FileErrorType_REDIRECT(TypedDict): + type: Literal["REDIRECT"] + redirectError: str + +class FileErrorType_FILE_IO(TypedDict): + type: Literal["FILE_IO"] + fileIOError: str + +class FileErrorType_NO_FILE(TypedDict): + type: Literal["NO_FILE"] + +FileErrorType = ( + FileErrorType_NOT_APPROVED + | FileErrorType_SIZE + | FileErrorType_REDIRECT + | FileErrorType_FILE_IO + | FileErrorType_NO_FILE +) + +FileErrorType_Tag = Literal["NOT_APPROVED", "SIZE", "REDIRECT", "FILE_IO", "NO_FILE"] + +class FileInvitation(TypedDict): + fileName: str + fileSize: int # int64 + fileDigest: NotRequired[str] + fileConnReq: NotRequired[str] + fileInline: NotRequired["InlineFileMode"] + fileDescr: NotRequired["FileDescr"] + +FileProtocol = Literal["SMP", "XFTP", "LOCAL"] + +FileStatus = Literal["new", "accepted", "connected", "complete", "cancelled"] + +class FileTransferMeta(TypedDict): + fileId: int # int64 + xftpSndFile: NotRequired["XFTPSndFile"] + xftpRedirectFor: NotRequired[int] # int64 + fileName: str + filePath: str + fileSize: int # int64 + fileInline: NotRequired["InlineFileMode"] + chunkSize: int # int64 + cancelled: bool + +class Format_bold(TypedDict): + type: Literal["bold"] + +class Format_italic(TypedDict): + type: Literal["italic"] + +class Format_strikeThrough(TypedDict): + type: Literal["strikeThrough"] + +class Format_snippet(TypedDict): + type: Literal["snippet"] + +class Format_secret(TypedDict): + type: Literal["secret"] + +class Format_small(TypedDict): + type: Literal["small"] + +class Format_colored(TypedDict): + type: Literal["colored"] + color: "Color" + +class Format_uri(TypedDict): + type: Literal["uri"] + +class Format_hyperLink(TypedDict): + type: Literal["hyperLink"] + showText: NotRequired[str] + linkUri: str + +class Format_simplexLink(TypedDict): + type: Literal["simplexLink"] + showText: NotRequired[str] + linkType: "SimplexLinkType" + simplexUri: str + smpHosts: list[str] # non-empty + +class Format_command(TypedDict): + type: Literal["command"] + commandStr: str + +class Format_mention(TypedDict): + type: Literal["mention"] + memberName: str + +class Format_email(TypedDict): + type: Literal["email"] + +class Format_phone(TypedDict): + type: Literal["phone"] + +Format = ( + Format_bold + | Format_italic + | Format_strikeThrough + | Format_snippet + | Format_secret + | Format_small + | Format_colored + | Format_uri + | Format_hyperLink + | Format_simplexLink + | Format_command + | Format_mention + | Format_email + | Format_phone +) + +Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "command", "mention", "email", "phone"] + +class FormattedText(TypedDict): + format: NotRequired["Format"] + text: str + +class FullGroupPreferences(TypedDict): + timedMessages: "TimedMessagesGroupPreference" + directMessages: "RoleGroupPreference" + fullDelete: "GroupPreference" + reactions: "GroupPreference" + voice: "RoleGroupPreference" + files: "RoleGroupPreference" + simplexLinks: "RoleGroupPreference" + reports: "GroupPreference" + history: "GroupPreference" + support: "SupportGroupPreference" + sessions: "RoleGroupPreference" + comments: "CommentsGroupPreference" + commands: list["ChatBotCommand"] + +class FullPreferences(TypedDict): + timedMessages: "TimedMessagesPreference" + fullDelete: "SimplePreference" + reactions: "SimplePreference" + voice: "SimplePreference" + files: "SimplePreference" + calls: "SimplePreference" + sessions: "SimplePreference" + commands: list["ChatBotCommand"] + +class Group(TypedDict): + groupInfo: "GroupInfo" + members: list["GroupMember"] + +class GroupChatScope_memberSupport(TypedDict): + type: Literal["memberSupport"] + groupMemberId_: NotRequired[int] # int64 + +GroupChatScope = GroupChatScope_memberSupport + +GroupChatScope_Tag = Literal["memberSupport"] + + +def GroupChatScope_cmd_string(self: GroupChatScope) -> str: + return '(_support' + ((':' + str(self.get('groupMemberId_'))) if self.get('groupMemberId_') is not None else '') + ')' # type: ignore[typeddict-item] + +class GroupChatScopeInfo_memberSupport(TypedDict): + type: Literal["memberSupport"] + groupMember_: NotRequired["GroupMember"] + +GroupChatScopeInfo = GroupChatScopeInfo_memberSupport + +GroupChatScopeInfo_Tag = Literal["memberSupport"] + +class GroupDirectInvitation(TypedDict): + groupDirectInvLink: str + fromGroupId_: NotRequired[int] # int64 + fromGroupMemberId_: NotRequired[int] # int64 + fromGroupMemberConnId_: NotRequired[int] # int64 + groupDirectInvStartedConnection: bool + +GroupFeature = Literal["timedMessages", "directMessages", "fullDelete", "reactions", "voice", "files", "simplexLinks", "reports", "history", "support", "sessions", "comments"] + +GroupFeatureEnabled = Literal["on", "off"] + +class GroupInfo(TypedDict): + groupId: int # int64 + useRelays: bool + relayOwnStatus: NotRequired["RelayStatus"] + localDisplayName: str + groupProfile: "GroupProfile" + localAlias: str + businessChat: NotRequired["BusinessChatInfo"] + fullGroupPreferences: "FullGroupPreferences" + membership: "GroupMember" + chatSettings: "ChatSettings" + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + chatTs: NotRequired[str] # ISO-8601 timestamp + userMemberProfileSentAt: NotRequired[str] # ISO-8601 timestamp + preparedGroup: NotRequired["PreparedGroup"] + chatTags: list[int] # int64 + chatItemTTL: NotRequired[int] # int64 + uiThemes: NotRequired["UIThemeEntityOverrides"] + customData: NotRequired[dict[str, object]] + groupSummary: "GroupSummary" + membersRequireAttention: int # int + viaGroupLinkUri: NotRequired[str] + groupKeys: NotRequired["GroupKeys"] + +class GroupKeys(TypedDict): + publicGroupId: str + groupRootKey: "GroupRootKey" + memberPrivKey: str + +class GroupLink(TypedDict): + userContactLinkId: int # int64 + connLinkContact: "CreatedConnLink" + shortLinkDataSet: bool + shortLinkLargeDataSet: bool + groupLinkId: str + acceptMemberRole: "GroupMemberRole" + +class GroupLinkOwner(TypedDict): + memberId: str + memberKey: str + +class GroupLinkPlan_ok(TypedDict): + type: Literal["ok"] + groupSLinkInfo_: NotRequired["GroupShortLinkInfo"] + groupSLinkData_: NotRequired["GroupShortLinkData"] + ownerVerification: NotRequired["OwnerVerification"] + +class GroupLinkPlan_ownLink(TypedDict): + type: Literal["ownLink"] + groupInfo: "GroupInfo" + +class GroupLinkPlan_connectingConfirmReconnect(TypedDict): + type: Literal["connectingConfirmReconnect"] + +class GroupLinkPlan_connectingProhibit(TypedDict): + type: Literal["connectingProhibit"] + groupInfo_: NotRequired["GroupInfo"] + +class GroupLinkPlan_known(TypedDict): + type: Literal["known"] + groupInfo: "GroupInfo" + groupUpdated: bool + ownerVerification: NotRequired["OwnerVerification"] + linkOwners: list["GroupLinkOwner"] + +class GroupLinkPlan_noRelays(TypedDict): + type: Literal["noRelays"] + groupSLinkData_: NotRequired["GroupShortLinkData"] + +GroupLinkPlan = ( + GroupLinkPlan_ok + | GroupLinkPlan_ownLink + | GroupLinkPlan_connectingConfirmReconnect + | GroupLinkPlan_connectingProhibit + | GroupLinkPlan_known + | GroupLinkPlan_noRelays +) + +GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays"] + +class GroupMember(TypedDict): + groupMemberId: int # int64 + groupId: int # int64 + indexInGroup: int # int64 + memberId: str + memberRole: "GroupMemberRole" + memberCategory: "GroupMemberCategory" + memberStatus: "GroupMemberStatus" + memberSettings: "GroupMemberSettings" + blockedByAdmin: bool + invitedBy: "InvitedBy" + invitedByGroupMemberId: NotRequired[int] # int64 + localDisplayName: str + memberProfile: "LocalProfile" + memberContactId: NotRequired[int] # int64 + memberContactProfileId: int # int64 + activeConn: NotRequired["Connection"] + memberChatVRange: "VersionRange" + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + supportChat: NotRequired["GroupSupportChat"] + memberPubKey: NotRequired[str] + relayLink: NotRequired[str] + +class GroupMemberAdmission(TypedDict): + review: NotRequired["MemberCriteria"] + +GroupMemberCategory = Literal["user", "invitee", "host", "pre", "post"] + +class GroupMemberRef(TypedDict): + groupMemberId: int # int64 + profile: "Profile" + +GroupMemberRole = Literal["relay", "observer", "author", "member", "moderator", "admin", "owner"] + +class GroupMemberSettings(TypedDict): + showMessages: bool + +GroupMemberStatus = Literal["rejected", "removed", "left", "deleted", "unknown", "invited", "pending_approval", "pending_review", "introduced", "intro-inv", "accepted", "announced", "connected", "complete", "creator"] + +class GroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + +class GroupPreferences(TypedDict): + timedMessages: NotRequired["TimedMessagesGroupPreference"] + directMessages: NotRequired["RoleGroupPreference"] + fullDelete: NotRequired["GroupPreference"] + reactions: NotRequired["GroupPreference"] + voice: NotRequired["RoleGroupPreference"] + files: NotRequired["RoleGroupPreference"] + simplexLinks: NotRequired["RoleGroupPreference"] + reports: NotRequired["GroupPreference"] + history: NotRequired["GroupPreference"] + support: NotRequired["SupportGroupPreference"] + sessions: NotRequired["RoleGroupPreference"] + comments: NotRequired["CommentsGroupPreference"] + commands: NotRequired[list["ChatBotCommand"]] + +class GroupProfile(TypedDict): + displayName: str + fullName: str + shortDescr: NotRequired[str] + description: NotRequired[str] + image: NotRequired[str] + publicGroup: NotRequired["PublicGroupProfile"] + groupPreferences: NotRequired["GroupPreferences"] + memberAdmission: NotRequired["GroupMemberAdmission"] + +class GroupRelay(TypedDict): + groupRelayId: int # int64 + groupMemberId: int # int64 + userChatRelay: "UserChatRelay" + relayStatus: "RelayStatus" + relayLink: NotRequired[str] + +class GroupRootKey_private(TypedDict): + type: Literal["private"] + rootPrivKey: str + +class GroupRootKey_public(TypedDict): + type: Literal["public"] + rootPubKey: str + +GroupRootKey = GroupRootKey_private | GroupRootKey_public + +GroupRootKey_Tag = Literal["private", "public"] + +class GroupShortLinkData(TypedDict): + groupProfile: "GroupProfile" + publicGroupData: NotRequired["PublicGroupData"] + +class GroupShortLinkInfo(TypedDict): + direct: bool + groupRelays: list[str] + publicGroupId: NotRequired[str] + +class GroupSummary(TypedDict): + currentMembers: int # int64 + publicMemberCount: NotRequired[int] # int64 + +class GroupSupportChat(TypedDict): + chatTs: str # ISO-8601 timestamp + unread: int # int64 + memberAttention: int # int64 + mentions: int # int64 + lastMsgFromMemberTs: NotRequired[str] # ISO-8601 timestamp + +GroupType = Literal["channel", "group"] + +HandshakeError = Literal["PARSE", "IDENTITY", "BAD_AUTH", "BAD_SERVICE"] + +InlineFileMode = Literal["offer", "sent"] + +class InvitationLinkPlan_ok(TypedDict): + type: Literal["ok"] + contactSLinkData_: NotRequired["ContactShortLinkData"] + ownerVerification: NotRequired["OwnerVerification"] + +class InvitationLinkPlan_ownLink(TypedDict): + type: Literal["ownLink"] + +class InvitationLinkPlan_connecting(TypedDict): + type: Literal["connecting"] + contact_: NotRequired["Contact"] + +class InvitationLinkPlan_known(TypedDict): + type: Literal["known"] + contact: "Contact" + +InvitationLinkPlan = ( + InvitationLinkPlan_ok + | InvitationLinkPlan_ownLink + | InvitationLinkPlan_connecting + | InvitationLinkPlan_known +) + +InvitationLinkPlan_Tag = Literal["ok", "ownLink", "connecting", "known"] + +class InvitedBy_contact(TypedDict): + type: Literal["contact"] + byContactId: int # int64 + +class InvitedBy_user(TypedDict): + type: Literal["user"] + +class InvitedBy_unknown(TypedDict): + type: Literal["unknown"] + +InvitedBy = InvitedBy_contact | InvitedBy_user | InvitedBy_unknown + +InvitedBy_Tag = Literal["contact", "user", "unknown"] + +class LinkContent_page(TypedDict): + type: Literal["page"] + +class LinkContent_image(TypedDict): + type: Literal["image"] + +class LinkContent_video(TypedDict): + type: Literal["video"] + duration: NotRequired[int] # int + +class LinkContent_unknown(TypedDict): + type: Literal["unknown"] + tag: str + json: dict[str, object] + +LinkContent = LinkContent_page | LinkContent_image | LinkContent_video | LinkContent_unknown + +LinkContent_Tag = Literal["page", "image", "video", "unknown"] + +class LinkOwnerSig(TypedDict): + ownerId: NotRequired[str] + chatBinding: str + ownerSig: str + +class LinkPreview(TypedDict): + uri: str + title: str + description: str + image: str + content: NotRequired["LinkContent"] + +class LocalProfile(TypedDict): + profileId: int # int64 + displayName: str + fullName: str + shortDescr: NotRequired[str] + image: NotRequired[str] + contactLink: NotRequired[str] + preferences: NotRequired["Preferences"] + peerType: NotRequired["ChatPeerType"] + localAlias: str + +MemberCriteria = Literal["all"] + +# Connection link sent in a message - only short links are allowed. + +class MsgChatLink_contact(TypedDict): + type: Literal["contact"] + connLink: str + profile: "Profile" + business: bool + +class MsgChatLink_invitation(TypedDict): + type: Literal["invitation"] + invLink: str + profile: "Profile" + +class MsgChatLink_group(TypedDict): + type: Literal["group"] + connLink: str + groupProfile: "GroupProfile" + +MsgChatLink = MsgChatLink_contact | MsgChatLink_invitation | MsgChatLink_group + +MsgChatLink_Tag = Literal["contact", "invitation", "group"] + +class MsgContent_text(TypedDict): + type: Literal["text"] + text: str + +class MsgContent_link(TypedDict): + type: Literal["link"] + text: str + preview: "LinkPreview" + +class MsgContent_image(TypedDict): + type: Literal["image"] + text: str + image: str + +class MsgContent_video(TypedDict): + type: Literal["video"] + text: str + image: str + duration: int # int + +class MsgContent_voice(TypedDict): + type: Literal["voice"] + text: str + duration: int # int + +class MsgContent_file(TypedDict): + type: Literal["file"] + text: str + +class MsgContent_report(TypedDict): + type: Literal["report"] + text: str + reason: "ReportReason" + +class MsgContent_chat(TypedDict): + type: Literal["chat"] + text: str + chatLink: "MsgChatLink" + ownerSig: NotRequired["LinkOwnerSig"] + +class MsgContent_unknown(TypedDict): + type: Literal["unknown"] + tag: str + text: str + json: dict[str, object] + +MsgContent = ( + MsgContent_text + | MsgContent_link + | MsgContent_image + | MsgContent_video + | MsgContent_voice + | MsgContent_file + | MsgContent_report + | MsgContent_chat + | MsgContent_unknown +) + +MsgContent_Tag = Literal["text", "link", "image", "video", "voice", "file", "report", "chat", "unknown"] + +MsgDecryptError = Literal["ratchetHeader", "tooManySkipped", "ratchetEarlier", "other", "ratchetSync"] + +MsgDirection = Literal["rcv", "snd"] + +class MsgErrorType_msgSkipped(TypedDict): + type: Literal["msgSkipped"] + fromMsgId: int # int64 + toMsgId: int # int64 + +class MsgErrorType_msgBadId(TypedDict): + type: Literal["msgBadId"] + msgId: int # int64 + +class MsgErrorType_msgBadHash(TypedDict): + type: Literal["msgBadHash"] + +class MsgErrorType_msgDuplicate(TypedDict): + type: Literal["msgDuplicate"] + +MsgErrorType = ( + MsgErrorType_msgSkipped + | MsgErrorType_msgBadId + | MsgErrorType_msgBadHash + | MsgErrorType_msgDuplicate +) + +MsgErrorType_Tag = Literal["msgSkipped", "msgBadId", "msgBadHash", "msgDuplicate"] + +MsgFilter = Literal["none", "all", "mentions"] + +class MsgReaction_emoji(TypedDict): + type: Literal["emoji"] + emoji: str + +class MsgReaction_unknown(TypedDict): + type: Literal["unknown"] + tag: str + json: dict[str, object] + +MsgReaction = MsgReaction_emoji | MsgReaction_unknown + +MsgReaction_Tag = Literal["emoji", "unknown"] + +MsgReceiptStatus = Literal["ok", "badMsgHash"] + +MsgSigStatus = Literal["verified", "signedNoKey"] + +class NetworkError_connectError(TypedDict): + type: Literal["connectError"] + connectError: str + +class NetworkError_tLSError(TypedDict): + type: Literal["tLSError"] + tlsError: str + +class NetworkError_unknownCAError(TypedDict): + type: Literal["unknownCAError"] + +class NetworkError_failedError(TypedDict): + type: Literal["failedError"] + +class NetworkError_timeoutError(TypedDict): + type: Literal["timeoutError"] + +class NetworkError_subscribeError(TypedDict): + type: Literal["subscribeError"] + subscribeError: str + +NetworkError = ( + NetworkError_connectError + | NetworkError_tLSError + | NetworkError_unknownCAError + | NetworkError_failedError + | NetworkError_timeoutError + | NetworkError_subscribeError +) + +NetworkError_Tag = Literal["connectError", "tLSError", "unknownCAError", "failedError", "timeoutError", "subscribeError"] + +class NewUser(TypedDict): + profile: NotRequired["Profile"] + pastTimestamp: bool + userChatRelay: bool + +class NoteFolder(TypedDict): + noteFolderId: int # int64 + userId: int # int64 + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + chatTs: str # ISO-8601 timestamp + favorite: bool + unread: bool + +class OwnerVerification_verified(TypedDict): + type: Literal["verified"] + +class OwnerVerification_failed(TypedDict): + type: Literal["failed"] + reason: str + +OwnerVerification = OwnerVerification_verified | OwnerVerification_failed + +OwnerVerification_Tag = Literal["verified", "failed"] + +class PaginationByTime_last(TypedDict): + type: Literal["last"] + count: int # int + +PaginationByTime = PaginationByTime_last + +PaginationByTime_Tag = Literal["last"] + + +def PaginationByTime_cmd_string(self: PaginationByTime) -> str: + return 'count=' + str(self['count']) # type: ignore[typeddict-item] + +class PendingContactConnection(TypedDict): + pccConnId: int # int64 + pccAgentConnId: str + pccConnStatus: "ConnStatus" + viaContactUri: bool + viaUserContactLink: NotRequired[int] # int64 + groupLinkId: NotRequired[str] + customUserProfileId: NotRequired[int] # int64 + connLinkInv: NotRequired["CreatedConnLink"] + localAlias: str + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + +class PrefEnabled(TypedDict): + forUser: bool + forContact: bool + +class Preferences(TypedDict): + timedMessages: NotRequired["TimedMessagesPreference"] + fullDelete: NotRequired["SimplePreference"] + reactions: NotRequired["SimplePreference"] + voice: NotRequired["SimplePreference"] + files: NotRequired["SimplePreference"] + calls: NotRequired["SimplePreference"] + sessions: NotRequired["SimplePreference"] + commands: NotRequired[list["ChatBotCommand"]] + +class PreparedContact(TypedDict): + connLinkToConnect: "CreatedConnLink" + uiConnLinkType: "ConnectionMode" + welcomeSharedMsgId: NotRequired[str] + requestSharedMsgId: NotRequired[str] + +class PreparedGroup(TypedDict): + connLinkToConnect: "CreatedConnLink" + connLinkPreparedConnection: bool + connLinkStartedConnection: bool + welcomeSharedMsgId: NotRequired[str] + requestSharedMsgId: NotRequired[str] + +class Profile(TypedDict): + displayName: str + fullName: str + shortDescr: NotRequired[str] + image: NotRequired[str] + contactLink: NotRequired[str] + preferences: NotRequired["Preferences"] + peerType: NotRequired["ChatPeerType"] + +class ProxyClientError_protocolError(TypedDict): + type: Literal["protocolError"] + protocolErr: "ErrorType" + +class ProxyClientError_unexpectedResponse(TypedDict): + type: Literal["unexpectedResponse"] + responseStr: str + +class ProxyClientError_responseError(TypedDict): + type: Literal["responseError"] + responseErr: "ErrorType" + +ProxyClientError = ( + ProxyClientError_protocolError + | ProxyClientError_unexpectedResponse + | ProxyClientError_responseError +) + +ProxyClientError_Tag = Literal["protocolError", "unexpectedResponse", "responseError"] + +class ProxyError_PROTOCOL(TypedDict): + type: Literal["PROTOCOL"] + protocolErr: "ErrorType" + +class ProxyError_BROKER(TypedDict): + type: Literal["BROKER"] + brokerErr: "BrokerErrorType" + +class ProxyError_BASIC_AUTH(TypedDict): + type: Literal["BASIC_AUTH"] + +class ProxyError_NO_SESSION(TypedDict): + type: Literal["NO_SESSION"] + +ProxyError = ProxyError_PROTOCOL | ProxyError_BROKER | ProxyError_BASIC_AUTH | ProxyError_NO_SESSION + +ProxyError_Tag = Literal["PROTOCOL", "BROKER", "BASIC_AUTH", "NO_SESSION"] + +class PublicGroupData(TypedDict): + publicMemberCount: int # int64 + +class PublicGroupProfile(TypedDict): + groupType: "GroupType" + groupLink: str + publicGroupId: str + +class RCErrorType_internal(TypedDict): + type: Literal["internal"] + internalErr: str + +class RCErrorType_identity(TypedDict): + type: Literal["identity"] + +class RCErrorType_noLocalAddress(TypedDict): + type: Literal["noLocalAddress"] + +class RCErrorType_newController(TypedDict): + type: Literal["newController"] + +class RCErrorType_notDiscovered(TypedDict): + type: Literal["notDiscovered"] + +class RCErrorType_tLSStartFailed(TypedDict): + type: Literal["tLSStartFailed"] + +class RCErrorType_exception(TypedDict): + type: Literal["exception"] + exception: str + +class RCErrorType_ctrlAuth(TypedDict): + type: Literal["ctrlAuth"] + +class RCErrorType_ctrlNotFound(TypedDict): + type: Literal["ctrlNotFound"] + +class RCErrorType_ctrlError(TypedDict): + type: Literal["ctrlError"] + ctrlErr: str + +class RCErrorType_invitation(TypedDict): + type: Literal["invitation"] + +class RCErrorType_version(TypedDict): + type: Literal["version"] + +class RCErrorType_encrypt(TypedDict): + type: Literal["encrypt"] + +class RCErrorType_decrypt(TypedDict): + type: Literal["decrypt"] + +class RCErrorType_blockSize(TypedDict): + type: Literal["blockSize"] + +class RCErrorType_syntax(TypedDict): + type: Literal["syntax"] + syntaxErr: str + +RCErrorType = ( + RCErrorType_internal + | RCErrorType_identity + | RCErrorType_noLocalAddress + | RCErrorType_newController + | RCErrorType_notDiscovered + | RCErrorType_tLSStartFailed + | RCErrorType_exception + | RCErrorType_ctrlAuth + | RCErrorType_ctrlNotFound + | RCErrorType_ctrlError + | RCErrorType_invitation + | RCErrorType_version + | RCErrorType_encrypt + | RCErrorType_decrypt + | RCErrorType_blockSize + | RCErrorType_syntax +) + +RCErrorType_Tag = Literal["internal", "identity", "noLocalAddress", "newController", "notDiscovered", "tLSStartFailed", "exception", "ctrlAuth", "ctrlNotFound", "ctrlError", "invitation", "version", "encrypt", "decrypt", "blockSize", "syntax"] + +RatchetSyncState = Literal["ok", "allowed", "required", "started", "agreed"] + +class RcvConnEvent_switchQueue(TypedDict): + type: Literal["switchQueue"] + phase: "SwitchPhase" + +class RcvConnEvent_ratchetSync(TypedDict): + type: Literal["ratchetSync"] + syncStatus: "RatchetSyncState" + +class RcvConnEvent_verificationCodeReset(TypedDict): + type: Literal["verificationCodeReset"] + +class RcvConnEvent_pqEnabled(TypedDict): + type: Literal["pqEnabled"] + enabled: bool + +RcvConnEvent = ( + RcvConnEvent_switchQueue + | RcvConnEvent_ratchetSync + | RcvConnEvent_verificationCodeReset + | RcvConnEvent_pqEnabled +) + +RcvConnEvent_Tag = Literal["switchQueue", "ratchetSync", "verificationCodeReset", "pqEnabled"] + +class RcvDirectEvent_contactDeleted(TypedDict): + type: Literal["contactDeleted"] + +class RcvDirectEvent_profileUpdated(TypedDict): + type: Literal["profileUpdated"] + fromProfile: "Profile" + toProfile: "Profile" + +class RcvDirectEvent_groupInvLinkReceived(TypedDict): + type: Literal["groupInvLinkReceived"] + groupProfile: "GroupProfile" + +RcvDirectEvent = ( + RcvDirectEvent_contactDeleted + | RcvDirectEvent_profileUpdated + | RcvDirectEvent_groupInvLinkReceived +) + +RcvDirectEvent_Tag = Literal["contactDeleted", "profileUpdated", "groupInvLinkReceived"] + +class RcvFileDescr(TypedDict): + fileDescrId: int # int64 + fileDescrText: str + fileDescrPartNo: int # int + fileDescrComplete: bool + +class RcvFileStatus_new(TypedDict): + type: Literal["new"] + +class RcvFileStatus_accepted(TypedDict): + type: Literal["accepted"] + filePath: str + +class RcvFileStatus_connected(TypedDict): + type: Literal["connected"] + filePath: str + +class RcvFileStatus_complete(TypedDict): + type: Literal["complete"] + filePath: str + +class RcvFileStatus_cancelled(TypedDict): + type: Literal["cancelled"] + filePath_: NotRequired[str] + +RcvFileStatus = ( + RcvFileStatus_new + | RcvFileStatus_accepted + | RcvFileStatus_connected + | RcvFileStatus_complete + | RcvFileStatus_cancelled +) + +RcvFileStatus_Tag = Literal["new", "accepted", "connected", "complete", "cancelled"] + +class RcvFileTransfer(TypedDict): + fileId: int # int64 + xftpRcvFile: NotRequired["XFTPRcvFile"] + fileInvitation: "FileInvitation" + fileStatus: "RcvFileStatus" + rcvFileInline: NotRequired["InlineFileMode"] + senderDisplayName: str + chunkSize: int # int64 + cancelled: bool + grpMemberId: NotRequired[int] # int64 + cryptoArgs: NotRequired["CryptoFileArgs"] + +class RcvGroupEvent_memberAdded(TypedDict): + type: Literal["memberAdded"] + groupMemberId: int # int64 + profile: "Profile" + +class RcvGroupEvent_memberConnected(TypedDict): + type: Literal["memberConnected"] + +class RcvGroupEvent_memberAccepted(TypedDict): + type: Literal["memberAccepted"] + groupMemberId: int # int64 + profile: "Profile" + +class RcvGroupEvent_userAccepted(TypedDict): + type: Literal["userAccepted"] + +class RcvGroupEvent_memberLeft(TypedDict): + type: Literal["memberLeft"] + +class RcvGroupEvent_memberRole(TypedDict): + type: Literal["memberRole"] + groupMemberId: int # int64 + profile: "Profile" + role: "GroupMemberRole" + +class RcvGroupEvent_memberBlocked(TypedDict): + type: Literal["memberBlocked"] + groupMemberId: int # int64 + profile: "Profile" + blocked: bool + +class RcvGroupEvent_userRole(TypedDict): + type: Literal["userRole"] + role: "GroupMemberRole" + +class RcvGroupEvent_memberDeleted(TypedDict): + type: Literal["memberDeleted"] + groupMemberId: int # int64 + profile: "Profile" + +class RcvGroupEvent_userDeleted(TypedDict): + type: Literal["userDeleted"] + +class RcvGroupEvent_groupDeleted(TypedDict): + type: Literal["groupDeleted"] + +class RcvGroupEvent_groupUpdated(TypedDict): + type: Literal["groupUpdated"] + groupProfile: "GroupProfile" + +class RcvGroupEvent_invitedViaGroupLink(TypedDict): + type: Literal["invitedViaGroupLink"] + +class RcvGroupEvent_memberCreatedContact(TypedDict): + type: Literal["memberCreatedContact"] + +class RcvGroupEvent_memberProfileUpdated(TypedDict): + type: Literal["memberProfileUpdated"] + fromProfile: "Profile" + toProfile: "Profile" + +class RcvGroupEvent_newMemberPendingReview(TypedDict): + type: Literal["newMemberPendingReview"] + +class RcvGroupEvent_msgBadSignature(TypedDict): + type: Literal["msgBadSignature"] + +RcvGroupEvent = ( + RcvGroupEvent_memberAdded + | RcvGroupEvent_memberConnected + | RcvGroupEvent_memberAccepted + | RcvGroupEvent_userAccepted + | RcvGroupEvent_memberLeft + | RcvGroupEvent_memberRole + | RcvGroupEvent_memberBlocked + | RcvGroupEvent_userRole + | RcvGroupEvent_memberDeleted + | RcvGroupEvent_userDeleted + | RcvGroupEvent_groupDeleted + | RcvGroupEvent_groupUpdated + | RcvGroupEvent_invitedViaGroupLink + | RcvGroupEvent_memberCreatedContact + | RcvGroupEvent_memberProfileUpdated + | RcvGroupEvent_newMemberPendingReview + | RcvGroupEvent_msgBadSignature +) + +RcvGroupEvent_Tag = Literal["memberAdded", "memberConnected", "memberAccepted", "userAccepted", "memberLeft", "memberRole", "memberBlocked", "userRole", "memberDeleted", "userDeleted", "groupDeleted", "groupUpdated", "invitedViaGroupLink", "memberCreatedContact", "memberProfileUpdated", "newMemberPendingReview", "msgBadSignature"] + +class RcvMsgError_dropped(TypedDict): + type: Literal["dropped"] + attempts: int # int + +class RcvMsgError_parseError(TypedDict): + type: Literal["parseError"] + parseError: str + +RcvMsgError = RcvMsgError_dropped | RcvMsgError_parseError + +RcvMsgError_Tag = Literal["dropped", "parseError"] + +class RelayProfile(TypedDict): + displayName: str + fullName: str + shortDescr: NotRequired[str] + image: NotRequired[str] + +RelayStatus = Literal["new", "invited", "accepted", "active", "inactive", "rejected"] + +ReportReason = Literal["spam", "content", "community", "profile", "other"] + +class RoleGroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + role: NotRequired["GroupMemberRole"] + +class SMPAgentError_A_MESSAGE(TypedDict): + type: Literal["A_MESSAGE"] + +class SMPAgentError_A_PROHIBITED(TypedDict): + type: Literal["A_PROHIBITED"] + prohibitedErr: str + +class SMPAgentError_A_VERSION(TypedDict): + type: Literal["A_VERSION"] + +class SMPAgentError_A_LINK(TypedDict): + type: Literal["A_LINK"] + linkErr: str + +class SMPAgentError_A_CRYPTO(TypedDict): + type: Literal["A_CRYPTO"] + cryptoErr: "AgentCryptoError" + +class SMPAgentError_A_DUPLICATE(TypedDict): + type: Literal["A_DUPLICATE"] + droppedMsg_: NotRequired["DroppedMsg"] + +class SMPAgentError_A_QUEUE(TypedDict): + type: Literal["A_QUEUE"] + queueErr: str + +SMPAgentError = ( + SMPAgentError_A_MESSAGE + | SMPAgentError_A_PROHIBITED + | SMPAgentError_A_VERSION + | SMPAgentError_A_LINK + | SMPAgentError_A_CRYPTO + | SMPAgentError_A_DUPLICATE + | SMPAgentError_A_QUEUE +) + +SMPAgentError_Tag = Literal["A_MESSAGE", "A_PROHIBITED", "A_VERSION", "A_LINK", "A_CRYPTO", "A_DUPLICATE", "A_QUEUE"] + +class SecurityCode(TypedDict): + securityCode: str + verifiedAt: str # ISO-8601 timestamp + +class SimplePreference(TypedDict): + allow: "FeatureAllowed" + +SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"] + +SndCIStatusProgress = Literal["partial", "complete"] + +class SndConnEvent_switchQueue(TypedDict): + type: Literal["switchQueue"] + phase: "SwitchPhase" + member: NotRequired["GroupMemberRef"] + +class SndConnEvent_ratchetSync(TypedDict): + type: Literal["ratchetSync"] + syncStatus: "RatchetSyncState" + member: NotRequired["GroupMemberRef"] + +class SndConnEvent_pqEnabled(TypedDict): + type: Literal["pqEnabled"] + enabled: bool + +SndConnEvent = SndConnEvent_switchQueue | SndConnEvent_ratchetSync | SndConnEvent_pqEnabled + +SndConnEvent_Tag = Literal["switchQueue", "ratchetSync", "pqEnabled"] + +class SndError_auth(TypedDict): + type: Literal["auth"] + +class SndError_quota(TypedDict): + type: Literal["quota"] + +class SndError_expired(TypedDict): + type: Literal["expired"] + +class SndError_relay(TypedDict): + type: Literal["relay"] + srvError: "SrvError" + +class SndError_proxy(TypedDict): + type: Literal["proxy"] + proxyServer: str + srvError: "SrvError" + +class SndError_proxyRelay(TypedDict): + type: Literal["proxyRelay"] + proxyServer: str + srvError: "SrvError" + +class SndError_other(TypedDict): + type: Literal["other"] + sndError: str + +SndError = ( + SndError_auth + | SndError_quota + | SndError_expired + | SndError_relay + | SndError_proxy + | SndError_proxyRelay + | SndError_other +) + +SndError_Tag = Literal["auth", "quota", "expired", "relay", "proxy", "proxyRelay", "other"] + +class SndFileTransfer(TypedDict): + fileId: int # int64 + fileName: str + filePath: str + fileSize: int # int64 + chunkSize: int # int64 + recipientDisplayName: str + connId: int # int64 + agentConnId: str + groupMemberId: NotRequired[int] # int64 + fileStatus: "FileStatus" + fileDescrId: NotRequired[int] # int64 + fileInline: NotRequired["InlineFileMode"] + +class SndGroupEvent_memberRole(TypedDict): + type: Literal["memberRole"] + groupMemberId: int # int64 + profile: "Profile" + role: "GroupMemberRole" + +class SndGroupEvent_memberBlocked(TypedDict): + type: Literal["memberBlocked"] + groupMemberId: int # int64 + profile: "Profile" + blocked: bool + +class SndGroupEvent_userRole(TypedDict): + type: Literal["userRole"] + role: "GroupMemberRole" + +class SndGroupEvent_memberDeleted(TypedDict): + type: Literal["memberDeleted"] + groupMemberId: int # int64 + profile: "Profile" + +class SndGroupEvent_userLeft(TypedDict): + type: Literal["userLeft"] + +class SndGroupEvent_groupUpdated(TypedDict): + type: Literal["groupUpdated"] + groupProfile: "GroupProfile" + +class SndGroupEvent_memberAccepted(TypedDict): + type: Literal["memberAccepted"] + groupMemberId: int # int64 + profile: "Profile" + +class SndGroupEvent_userPendingReview(TypedDict): + type: Literal["userPendingReview"] + +SndGroupEvent = ( + SndGroupEvent_memberRole + | SndGroupEvent_memberBlocked + | SndGroupEvent_userRole + | SndGroupEvent_memberDeleted + | SndGroupEvent_userLeft + | SndGroupEvent_groupUpdated + | SndGroupEvent_memberAccepted + | SndGroupEvent_userPendingReview +) + +SndGroupEvent_Tag = Literal["memberRole", "memberBlocked", "userRole", "memberDeleted", "userLeft", "groupUpdated", "memberAccepted", "userPendingReview"] + +class SrvError_host(TypedDict): + type: Literal["host"] + +class SrvError_version(TypedDict): + type: Literal["version"] + +class SrvError_other(TypedDict): + type: Literal["other"] + srvError: str + +SrvError = SrvError_host | SrvError_version | SrvError_other + +SrvError_Tag = Literal["host", "version", "other"] + +class StoreError_duplicateName(TypedDict): + type: Literal["duplicateName"] + +class StoreError_userNotFound(TypedDict): + type: Literal["userNotFound"] + userId: int # int64 + +class StoreError_relayUserNotFound(TypedDict): + type: Literal["relayUserNotFound"] + +class StoreError_userNotFoundByName(TypedDict): + type: Literal["userNotFoundByName"] + contactName: str + +class StoreError_userNotFoundByContactId(TypedDict): + type: Literal["userNotFoundByContactId"] + contactId: int # int64 + +class StoreError_userNotFoundByGroupId(TypedDict): + type: Literal["userNotFoundByGroupId"] + groupId: int # int64 + +class StoreError_userNotFoundByFileId(TypedDict): + type: Literal["userNotFoundByFileId"] + fileId: int # int64 + +class StoreError_userNotFoundByContactRequestId(TypedDict): + type: Literal["userNotFoundByContactRequestId"] + contactRequestId: int # int64 + +class StoreError_contactNotFound(TypedDict): + type: Literal["contactNotFound"] + contactId: int # int64 + +class StoreError_contactNotFoundByName(TypedDict): + type: Literal["contactNotFoundByName"] + contactName: str + +class StoreError_contactNotFoundByMemberId(TypedDict): + type: Literal["contactNotFoundByMemberId"] + groupMemberId: int # int64 + +class StoreError_contactNotReady(TypedDict): + type: Literal["contactNotReady"] + contactName: str + +class StoreError_duplicateContactLink(TypedDict): + type: Literal["duplicateContactLink"] + +class StoreError_userContactLinkNotFound(TypedDict): + type: Literal["userContactLinkNotFound"] + +class StoreError_contactRequestNotFound(TypedDict): + type: Literal["contactRequestNotFound"] + contactRequestId: int # int64 + +class StoreError_contactRequestNotFoundByName(TypedDict): + type: Literal["contactRequestNotFoundByName"] + contactName: str + +class StoreError_invalidContactRequestEntity(TypedDict): + type: Literal["invalidContactRequestEntity"] + contactRequestId: int # int64 + +class StoreError_invalidBusinessChatContactRequest(TypedDict): + type: Literal["invalidBusinessChatContactRequest"] + +class StoreError_groupNotFound(TypedDict): + type: Literal["groupNotFound"] + groupId: int # int64 + +class StoreError_groupNotFoundByName(TypedDict): + type: Literal["groupNotFoundByName"] + groupName: str + +class StoreError_groupMemberNameNotFound(TypedDict): + type: Literal["groupMemberNameNotFound"] + groupId: int # int64 + groupMemberName: str + +class StoreError_groupMemberNotFound(TypedDict): + type: Literal["groupMemberNotFound"] + groupMemberId: int # int64 + +class StoreError_groupMemberNotFoundByIndex(TypedDict): + type: Literal["groupMemberNotFoundByIndex"] + groupMemberIndex: int # int64 + +class StoreError_memberRelationsVectorNotFound(TypedDict): + type: Literal["memberRelationsVectorNotFound"] + groupMemberId: int # int64 + +class StoreError_groupHostMemberNotFound(TypedDict): + type: Literal["groupHostMemberNotFound"] + groupId: int # int64 + +class StoreError_groupMemberNotFoundByMemberId(TypedDict): + type: Literal["groupMemberNotFoundByMemberId"] + memberId: str + +class StoreError_memberContactGroupMemberNotFound(TypedDict): + type: Literal["memberContactGroupMemberNotFound"] + contactId: int # int64 + +class StoreError_invalidMemberRelationUpdate(TypedDict): + type: Literal["invalidMemberRelationUpdate"] + +class StoreError_groupWithoutUser(TypedDict): + type: Literal["groupWithoutUser"] + +class StoreError_duplicateGroupMember(TypedDict): + type: Literal["duplicateGroupMember"] + +class StoreError_duplicateMemberId(TypedDict): + type: Literal["duplicateMemberId"] + +class StoreError_groupAlreadyJoined(TypedDict): + type: Literal["groupAlreadyJoined"] + +class StoreError_groupInvitationNotFound(TypedDict): + type: Literal["groupInvitationNotFound"] + +class StoreError_noteFolderAlreadyExists(TypedDict): + type: Literal["noteFolderAlreadyExists"] + noteFolderId: int # int64 + +class StoreError_noteFolderNotFound(TypedDict): + type: Literal["noteFolderNotFound"] + noteFolderId: int # int64 + +class StoreError_userNoteFolderNotFound(TypedDict): + type: Literal["userNoteFolderNotFound"] + +class StoreError_sndFileNotFound(TypedDict): + type: Literal["sndFileNotFound"] + fileId: int # int64 + +class StoreError_sndFileInvalid(TypedDict): + type: Literal["sndFileInvalid"] + fileId: int # int64 + +class StoreError_rcvFileNotFound(TypedDict): + type: Literal["rcvFileNotFound"] + fileId: int # int64 + +class StoreError_rcvFileDescrNotFound(TypedDict): + type: Literal["rcvFileDescrNotFound"] + fileId: int # int64 + +class StoreError_fileNotFound(TypedDict): + type: Literal["fileNotFound"] + fileId: int # int64 + +class StoreError_rcvFileInvalid(TypedDict): + type: Literal["rcvFileInvalid"] + fileId: int # int64 + +class StoreError_rcvFileInvalidDescrPart(TypedDict): + type: Literal["rcvFileInvalidDescrPart"] + +class StoreError_localFileNoTransfer(TypedDict): + type: Literal["localFileNoTransfer"] + fileId: int # int64 + +class StoreError_sharedMsgIdNotFoundByFileId(TypedDict): + type: Literal["sharedMsgIdNotFoundByFileId"] + fileId: int # int64 + +class StoreError_fileIdNotFoundBySharedMsgId(TypedDict): + type: Literal["fileIdNotFoundBySharedMsgId"] + sharedMsgId: str + +class StoreError_sndFileNotFoundXFTP(TypedDict): + type: Literal["sndFileNotFoundXFTP"] + agentSndFileId: str + +class StoreError_rcvFileNotFoundXFTP(TypedDict): + type: Literal["rcvFileNotFoundXFTP"] + agentRcvFileId: str + +class StoreError_connectionNotFound(TypedDict): + type: Literal["connectionNotFound"] + agentConnId: str + +class StoreError_connectionNotFoundById(TypedDict): + type: Literal["connectionNotFoundById"] + connId: int # int64 + +class StoreError_connectionNotFoundByMemberId(TypedDict): + type: Literal["connectionNotFoundByMemberId"] + groupMemberId: int # int64 + +class StoreError_pendingConnectionNotFound(TypedDict): + type: Literal["pendingConnectionNotFound"] + connId: int # int64 + +class StoreError_uniqueID(TypedDict): + type: Literal["uniqueID"] + +class StoreError_largeMsg(TypedDict): + type: Literal["largeMsg"] + +class StoreError_internalError(TypedDict): + type: Literal["internalError"] + message: str + +class StoreError_dBException(TypedDict): + type: Literal["dBException"] + message: str + +class StoreError_dBBusyError(TypedDict): + type: Literal["dBBusyError"] + message: str + +class StoreError_badChatItem(TypedDict): + type: Literal["badChatItem"] + itemId: int # int64 + itemTs: NotRequired[str] # ISO-8601 timestamp + +class StoreError_chatItemNotFound(TypedDict): + type: Literal["chatItemNotFound"] + itemId: int # int64 + +class StoreError_chatItemNotFoundByText(TypedDict): + type: Literal["chatItemNotFoundByText"] + text: str + +class StoreError_chatItemSharedMsgIdNotFound(TypedDict): + type: Literal["chatItemSharedMsgIdNotFound"] + sharedMsgId: str + +class StoreError_chatItemNotFoundByFileId(TypedDict): + type: Literal["chatItemNotFoundByFileId"] + fileId: int # int64 + +class StoreError_chatItemNotFoundByContactId(TypedDict): + type: Literal["chatItemNotFoundByContactId"] + contactId: int # int64 + +class StoreError_chatItemNotFoundByGroupId(TypedDict): + type: Literal["chatItemNotFoundByGroupId"] + groupId: int # int64 + +class StoreError_profileNotFound(TypedDict): + type: Literal["profileNotFound"] + profileId: int # int64 + +class StoreError_duplicateGroupLink(TypedDict): + type: Literal["duplicateGroupLink"] + groupInfo: "GroupInfo" + +class StoreError_groupLinkNotFound(TypedDict): + type: Literal["groupLinkNotFound"] + groupInfo: "GroupInfo" + +class StoreError_hostMemberIdNotFound(TypedDict): + type: Literal["hostMemberIdNotFound"] + groupId: int # int64 + +class StoreError_contactNotFoundByFileId(TypedDict): + type: Literal["contactNotFoundByFileId"] + fileId: int # int64 + +class StoreError_noGroupSndStatus(TypedDict): + type: Literal["noGroupSndStatus"] + itemId: int # int64 + groupMemberId: int # int64 + +class StoreError_duplicateGroupMessage(TypedDict): + type: Literal["duplicateGroupMessage"] + groupId: int # int64 + sharedMsgId: str + authorGroupMemberId: NotRequired[int] # int64 + forwardedByGroupMemberId: NotRequired[int] # int64 + +class StoreError_remoteHostNotFound(TypedDict): + type: Literal["remoteHostNotFound"] + remoteHostId: int # int64 + +class StoreError_remoteHostUnknown(TypedDict): + type: Literal["remoteHostUnknown"] + +class StoreError_remoteHostDuplicateCA(TypedDict): + type: Literal["remoteHostDuplicateCA"] + +class StoreError_remoteCtrlNotFound(TypedDict): + type: Literal["remoteCtrlNotFound"] + remoteCtrlId: int # int64 + +class StoreError_remoteCtrlDuplicateCA(TypedDict): + type: Literal["remoteCtrlDuplicateCA"] + +class StoreError_prohibitedDeleteUser(TypedDict): + type: Literal["prohibitedDeleteUser"] + userId: int # int64 + contactId: int # int64 + +class StoreError_operatorNotFound(TypedDict): + type: Literal["operatorNotFound"] + serverOperatorId: int # int64 + +class StoreError_usageConditionsNotFound(TypedDict): + type: Literal["usageConditionsNotFound"] + +class StoreError_userChatRelayNotFound(TypedDict): + type: Literal["userChatRelayNotFound"] + chatRelayId: int # int64 + +class StoreError_groupRelayNotFound(TypedDict): + type: Literal["groupRelayNotFound"] + groupRelayId: int # int64 + +class StoreError_groupRelayNotFoundByMemberId(TypedDict): + type: Literal["groupRelayNotFoundByMemberId"] + groupMemberId: int # int64 + +class StoreError_invalidQuote(TypedDict): + type: Literal["invalidQuote"] + +class StoreError_invalidMention(TypedDict): + type: Literal["invalidMention"] + +class StoreError_invalidDeliveryTask(TypedDict): + type: Literal["invalidDeliveryTask"] + taskId: int # int64 + +class StoreError_deliveryTaskNotFound(TypedDict): + type: Literal["deliveryTaskNotFound"] + taskId: int # int64 + +class StoreError_invalidDeliveryJob(TypedDict): + type: Literal["invalidDeliveryJob"] + jobId: int # int64 + +class StoreError_deliveryJobNotFound(TypedDict): + type: Literal["deliveryJobNotFound"] + jobId: int # int64 + +class StoreError_workItemError(TypedDict): + type: Literal["workItemError"] + errContext: str + +StoreError = ( + StoreError_duplicateName + | StoreError_userNotFound + | StoreError_relayUserNotFound + | StoreError_userNotFoundByName + | StoreError_userNotFoundByContactId + | StoreError_userNotFoundByGroupId + | StoreError_userNotFoundByFileId + | StoreError_userNotFoundByContactRequestId + | StoreError_contactNotFound + | StoreError_contactNotFoundByName + | StoreError_contactNotFoundByMemberId + | StoreError_contactNotReady + | StoreError_duplicateContactLink + | StoreError_userContactLinkNotFound + | StoreError_contactRequestNotFound + | StoreError_contactRequestNotFoundByName + | StoreError_invalidContactRequestEntity + | StoreError_invalidBusinessChatContactRequest + | StoreError_groupNotFound + | StoreError_groupNotFoundByName + | StoreError_groupMemberNameNotFound + | StoreError_groupMemberNotFound + | StoreError_groupMemberNotFoundByIndex + | StoreError_memberRelationsVectorNotFound + | StoreError_groupHostMemberNotFound + | StoreError_groupMemberNotFoundByMemberId + | StoreError_memberContactGroupMemberNotFound + | StoreError_invalidMemberRelationUpdate + | StoreError_groupWithoutUser + | StoreError_duplicateGroupMember + | StoreError_duplicateMemberId + | StoreError_groupAlreadyJoined + | StoreError_groupInvitationNotFound + | StoreError_noteFolderAlreadyExists + | StoreError_noteFolderNotFound + | StoreError_userNoteFolderNotFound + | StoreError_sndFileNotFound + | StoreError_sndFileInvalid + | StoreError_rcvFileNotFound + | StoreError_rcvFileDescrNotFound + | StoreError_fileNotFound + | StoreError_rcvFileInvalid + | StoreError_rcvFileInvalidDescrPart + | StoreError_localFileNoTransfer + | StoreError_sharedMsgIdNotFoundByFileId + | StoreError_fileIdNotFoundBySharedMsgId + | StoreError_sndFileNotFoundXFTP + | StoreError_rcvFileNotFoundXFTP + | StoreError_connectionNotFound + | StoreError_connectionNotFoundById + | StoreError_connectionNotFoundByMemberId + | StoreError_pendingConnectionNotFound + | StoreError_uniqueID + | StoreError_largeMsg + | StoreError_internalError + | StoreError_dBException + | StoreError_dBBusyError + | StoreError_badChatItem + | StoreError_chatItemNotFound + | StoreError_chatItemNotFoundByText + | StoreError_chatItemSharedMsgIdNotFound + | StoreError_chatItemNotFoundByFileId + | StoreError_chatItemNotFoundByContactId + | StoreError_chatItemNotFoundByGroupId + | StoreError_profileNotFound + | StoreError_duplicateGroupLink + | StoreError_groupLinkNotFound + | StoreError_hostMemberIdNotFound + | StoreError_contactNotFoundByFileId + | StoreError_noGroupSndStatus + | StoreError_duplicateGroupMessage + | StoreError_remoteHostNotFound + | StoreError_remoteHostUnknown + | StoreError_remoteHostDuplicateCA + | StoreError_remoteCtrlNotFound + | StoreError_remoteCtrlDuplicateCA + | StoreError_prohibitedDeleteUser + | StoreError_operatorNotFound + | StoreError_usageConditionsNotFound + | StoreError_userChatRelayNotFound + | StoreError_groupRelayNotFound + | StoreError_groupRelayNotFoundByMemberId + | StoreError_invalidQuote + | StoreError_invalidMention + | StoreError_invalidDeliveryTask + | StoreError_deliveryTaskNotFound + | StoreError_invalidDeliveryJob + | StoreError_deliveryJobNotFound + | StoreError_workItemError +) + +StoreError_Tag = Literal["duplicateName", "userNotFound", "relayUserNotFound", "userNotFoundByName", "userNotFoundByContactId", "userNotFoundByGroupId", "userNotFoundByFileId", "userNotFoundByContactRequestId", "contactNotFound", "contactNotFoundByName", "contactNotFoundByMemberId", "contactNotReady", "duplicateContactLink", "userContactLinkNotFound", "contactRequestNotFound", "contactRequestNotFoundByName", "invalidContactRequestEntity", "invalidBusinessChatContactRequest", "groupNotFound", "groupNotFoundByName", "groupMemberNameNotFound", "groupMemberNotFound", "groupMemberNotFoundByIndex", "memberRelationsVectorNotFound", "groupHostMemberNotFound", "groupMemberNotFoundByMemberId", "memberContactGroupMemberNotFound", "invalidMemberRelationUpdate", "groupWithoutUser", "duplicateGroupMember", "duplicateMemberId", "groupAlreadyJoined", "groupInvitationNotFound", "noteFolderAlreadyExists", "noteFolderNotFound", "userNoteFolderNotFound", "sndFileNotFound", "sndFileInvalid", "rcvFileNotFound", "rcvFileDescrNotFound", "fileNotFound", "rcvFileInvalid", "rcvFileInvalidDescrPart", "localFileNoTransfer", "sharedMsgIdNotFoundByFileId", "fileIdNotFoundBySharedMsgId", "sndFileNotFoundXFTP", "rcvFileNotFoundXFTP", "connectionNotFound", "connectionNotFoundById", "connectionNotFoundByMemberId", "pendingConnectionNotFound", "uniqueID", "largeMsg", "internalError", "dBException", "dBBusyError", "badChatItem", "chatItemNotFound", "chatItemNotFoundByText", "chatItemSharedMsgIdNotFound", "chatItemNotFoundByFileId", "chatItemNotFoundByContactId", "chatItemNotFoundByGroupId", "profileNotFound", "duplicateGroupLink", "groupLinkNotFound", "hostMemberIdNotFound", "contactNotFoundByFileId", "noGroupSndStatus", "duplicateGroupMessage", "remoteHostNotFound", "remoteHostUnknown", "remoteHostDuplicateCA", "remoteCtrlNotFound", "remoteCtrlDuplicateCA", "prohibitedDeleteUser", "operatorNotFound", "usageConditionsNotFound", "userChatRelayNotFound", "groupRelayNotFound", "groupRelayNotFoundByMemberId", "invalidQuote", "invalidMention", "invalidDeliveryTask", "deliveryTaskNotFound", "invalidDeliveryJob", "deliveryJobNotFound", "workItemError"] + +class SubscriptionStatus_active(TypedDict): + type: Literal["active"] + +class SubscriptionStatus_pending(TypedDict): + type: Literal["pending"] + +class SubscriptionStatus_removed(TypedDict): + type: Literal["removed"] + subError: str + +class SubscriptionStatus_noSub(TypedDict): + type: Literal["noSub"] + +SubscriptionStatus = ( + SubscriptionStatus_active + | SubscriptionStatus_pending + | SubscriptionStatus_removed + | SubscriptionStatus_noSub +) + +SubscriptionStatus_Tag = Literal["active", "pending", "removed", "noSub"] + +class SupportGroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + +SwitchPhase = Literal["started", "confirmed", "secured", "completed"] + +class TimedMessagesGroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + ttl: NotRequired[int] # int + +class TimedMessagesPreference(TypedDict): + allow: "FeatureAllowed" + ttl: NotRequired[int] # int + +class TransportError_badBlock(TypedDict): + type: Literal["badBlock"] + +class TransportError_version(TypedDict): + type: Literal["version"] + +class TransportError_largeMsg(TypedDict): + type: Literal["largeMsg"] + +class TransportError_badSession(TypedDict): + type: Literal["badSession"] + +class TransportError_noServerAuth(TypedDict): + type: Literal["noServerAuth"] + +class TransportError_handshake(TypedDict): + type: Literal["handshake"] + handshakeErr: "HandshakeError" + +TransportError = ( + TransportError_badBlock + | TransportError_version + | TransportError_largeMsg + | TransportError_badSession + | TransportError_noServerAuth + | TransportError_handshake +) + +TransportError_Tag = Literal["badBlock", "version", "largeMsg", "badSession", "noServerAuth", "handshake"] + +UIColorMode = Literal["light", "dark"] + +class UIColors(TypedDict): + accent: NotRequired[str] + accentVariant: NotRequired[str] + secondary: NotRequired[str] + secondaryVariant: NotRequired[str] + background: NotRequired[str] + menus: NotRequired[str] + title: NotRequired[str] + accentVariant2: NotRequired[str] + sentMessage: NotRequired[str] + sentReply: NotRequired[str] + receivedMessage: NotRequired[str] + receivedReply: NotRequired[str] + +class UIThemeEntityOverride(TypedDict): + mode: "UIColorMode" + wallpaper: NotRequired["ChatWallpaper"] + colors: "UIColors" + +class UIThemeEntityOverrides(TypedDict): + light: NotRequired["UIThemeEntityOverride"] + dark: NotRequired["UIThemeEntityOverride"] + +class UpdatedMessage(TypedDict): + msgContent: "MsgContent" + mentions: dict[str, int] # str : int64 + +class User(TypedDict): + userId: int # int64 + agentUserId: int # int64 + userContactId: int # int64 + localDisplayName: str + profile: "LocalProfile" + fullPreferences: "FullPreferences" + activeUser: bool + activeOrder: int # int64 + viewPwdHash: NotRequired["UserPwdHash"] + showNtfs: bool + sendRcptsContacts: bool + sendRcptsSmallGroups: bool + autoAcceptMemberContacts: bool + userMemberProfileUpdatedAt: NotRequired[str] # ISO-8601 timestamp + uiThemes: NotRequired["UIThemeEntityOverrides"] + userChatRelay: bool + +class UserChatRelay(TypedDict): + chatRelayId: int # int64 + address: str + relayProfile: "RelayProfile" + domains: list[str] + preset: bool + tested: NotRequired[bool] + enabled: bool + deleted: bool + +class UserContact(TypedDict): + userContactLinkId: int # int64 + connReqContact: str + groupId: NotRequired[int] # int64 + +class UserContactLink(TypedDict): + userContactLinkId: int # int64 + connLinkContact: "CreatedConnLink" + shortLinkDataSet: bool + shortLinkLargeDataSet: bool + addressSettings: "AddressSettings" + +class UserContactRequest(TypedDict): + contactRequestId: int # int64 + agentInvitationId: str + contactId_: NotRequired[int] # int64 + businessGroupId_: NotRequired[int] # int64 + userContactLinkId_: NotRequired[int] # int64 + cReqChatVRange: "VersionRange" + localDisplayName: str + profileId: int # int64 + profile: "Profile" + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + xContactId: NotRequired[str] + pqSupport: bool + welcomeSharedMsgId: NotRequired[str] + requestSharedMsgId: NotRequired[str] + +class UserInfo(TypedDict): + user: "User" + unreadCount: int # int + +class UserProfileUpdateSummary(TypedDict): + updateSuccesses: int # int + updateFailures: int # int + changedContacts: list["Contact"] + +class UserPwdHash(TypedDict): + hash: str + salt: str + +class VersionRange(TypedDict): + minVersion: int # int + maxVersion: int # int + +class XFTPErrorType_BLOCK(TypedDict): + type: Literal["BLOCK"] + +class XFTPErrorType_SESSION(TypedDict): + type: Literal["SESSION"] + +class XFTPErrorType_HANDSHAKE(TypedDict): + type: Literal["HANDSHAKE"] + +class XFTPErrorType_CMD(TypedDict): + type: Literal["CMD"] + cmdErr: "CommandError" + +class XFTPErrorType_AUTH(TypedDict): + type: Literal["AUTH"] + +class XFTPErrorType_BLOCKED(TypedDict): + type: Literal["BLOCKED"] + blockInfo: "BlockingInfo" + +class XFTPErrorType_SIZE(TypedDict): + type: Literal["SIZE"] + +class XFTPErrorType_QUOTA(TypedDict): + type: Literal["QUOTA"] + +class XFTPErrorType_DIGEST(TypedDict): + type: Literal["DIGEST"] + +class XFTPErrorType_CRYPTO(TypedDict): + type: Literal["CRYPTO"] + +class XFTPErrorType_NO_FILE(TypedDict): + type: Literal["NO_FILE"] + +class XFTPErrorType_HAS_FILE(TypedDict): + type: Literal["HAS_FILE"] + +class XFTPErrorType_FILE_IO(TypedDict): + type: Literal["FILE_IO"] + +class XFTPErrorType_TIMEOUT(TypedDict): + type: Literal["TIMEOUT"] + +class XFTPErrorType_INTERNAL(TypedDict): + type: Literal["INTERNAL"] + +class XFTPErrorType_DUPLICATE_(TypedDict): + type: Literal["DUPLICATE_"] + +XFTPErrorType = ( + XFTPErrorType_BLOCK + | XFTPErrorType_SESSION + | XFTPErrorType_HANDSHAKE + | XFTPErrorType_CMD + | XFTPErrorType_AUTH + | XFTPErrorType_BLOCKED + | XFTPErrorType_SIZE + | XFTPErrorType_QUOTA + | XFTPErrorType_DIGEST + | XFTPErrorType_CRYPTO + | XFTPErrorType_NO_FILE + | XFTPErrorType_HAS_FILE + | XFTPErrorType_FILE_IO + | XFTPErrorType_TIMEOUT + | XFTPErrorType_INTERNAL + | XFTPErrorType_DUPLICATE_ +) + +XFTPErrorType_Tag = Literal["BLOCK", "SESSION", "HANDSHAKE", "CMD", "AUTH", "BLOCKED", "SIZE", "QUOTA", "DIGEST", "CRYPTO", "NO_FILE", "HAS_FILE", "FILE_IO", "TIMEOUT", "INTERNAL", "DUPLICATE_"] + +class XFTPRcvFile(TypedDict): + rcvFileDescription: "RcvFileDescr" + agentRcvFileId: NotRequired[str] + agentRcvFileDeleted: bool + userApprovedRelays: bool + +class XFTPSndFile(TypedDict): + agentSndFileId: str + privateSndFileDescr: NotRequired[str] + agentSndFileDeleted: bool + cryptoArgs: NotRequired["CryptoFileArgs"] diff --git a/packages/simplex-chat-python/src/simplex_chat/util.py b/packages/simplex-chat-python/src/simplex_chat/util.py new file mode 100644 index 0000000000..158bb72a79 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/util.py @@ -0,0 +1,128 @@ +"""Reusable helpers for working with chat events, types, and message content. + +Mirrors the Node `util.ts` exports — provides the same primitives bot +authors typically reach for: command parsing, sender display strings, +message-content extraction, profile field cleanup, and ChatRef extraction +from a ChatInfo (handy when echoing into a different chat). +""" + +from __future__ import annotations + +import re +from typing import Any + +from .types import T + + +def chat_info_ref(c_info: T.ChatInfo) -> T.ChatRef | None: + """Extract a wire-format `ChatRef` from a `ChatInfo`. + + Returns `None` for non-chat infos (contactRequest, contactConnection) + that can't be the target of `api_send_messages`. For groups, the + `memberSupport` scope is forwarded so messages land in the right + thread; other scopes are dropped (matches Node `util.chatInfoRef`). + """ + t = c_info["type"] + if t == "direct": + return {"chatType": "direct", "chatId": c_info["contact"]["contactId"]} # type: ignore[index] + if t == "group": + ref: T.ChatRef = {"chatType": "group", "chatId": c_info["groupInfo"]["groupId"]} # type: ignore[index] + scope = c_info.get("groupChatScope") # type: ignore[union-attr] + if scope and scope.get("type") == "memberSupport": + member = scope.get("groupMember_") + ms_scope: T.GroupChatScope_memberSupport = {"type": "memberSupport"} + if member is not None: + ms_scope["groupMemberId_"] = member["groupMemberId"] + ref["chatScope"] = ms_scope + return ref + return None + + +def chat_info_name(c_info: T.ChatInfo) -> str: + """Display string for a chat: `@Alice`, `#GroupName`, `private notes`, etc.""" + t = c_info["type"] + if t == "direct": + return f"@{c_info['contact']['profile']['displayName']}" # type: ignore[index] + if t == "group": + scope = c_info.get("groupChatScope") # type: ignore[union-attr] + if scope and scope.get("type") == "memberSupport": + member = scope.get("groupMember_") + scope_name = f" {member['memberProfile']['displayName']}" if member else "" + return f"#{c_info['groupInfo']['groupProfile']['displayName']}(support{scope_name})" # type: ignore[index] + return f"#{c_info['groupInfo']['groupProfile']['displayName']}" # type: ignore[index] + if t == "local": + return "private notes" + if t == "contactRequest": + return f"request from @{c_info['contactRequest']['profile']['displayName']}" # type: ignore[index] + if t == "contactConnection": + alias = c_info["contactConnection"].get("localAlias") # type: ignore[index] + return f"pending connection ({alias})" if alias else "pending connection" + return f"<{t}>" + + +def sender_name(c_info: T.ChatInfo, chat_dir: T.CIDirection) -> str: + """Sender display: chat name plus group sender suffix when applicable.""" + base = chat_info_name(c_info) + if chat_dir["type"] == "groupRcv": + sender = chat_dir["groupMember"]["memberProfile"]["displayName"] # type: ignore[index] + return f"{base} @{sender}" + return base + + +def contact_address_str(link: T.CreatedConnLink) -> str: + """Prefer the short link, fall back to the full link.""" + return link.get("connShortLink") or link["connFullLink"] + + +def from_local_profile(local: T.LocalProfile) -> T.Profile: + """Strip extra LocalProfile fields (profileId, localAlias) and undefined values.""" + p: dict[str, Any] = {} + for key in ( + "displayName", + "fullName", + "shortDescr", + "image", + "contactLink", + "preferences", + "peerType", + ): + v = local.get(key) # type: ignore[misc] + if v is not None: + p[key] = v + return p # type: ignore[return-value] + + +def ci_content_text(chat_item: T.ChatItem) -> str | None: + """Extract the message text from a sent or received message item, if any.""" + content = chat_item["content"] + if content["type"] in ("sndMsgContent", "rcvMsgContent"): + msg = content.get("msgContent", {}) # type: ignore[union-attr] + return msg.get("text") + return None + + +_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$") + + +def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None: + """Parse a `/keyword args...` slash-command from a chat item. + + Returns `(keyword, trimmed_params)` or `None` if the message isn't a + slash command. Mirrors Node `util.ciBotCommand` semantics. + """ + text = ci_content_text(chat_item) + if not text: + return None + text = text.strip() + m = _BOT_COMMAND_RE.match(text) + if not m: + return None + return m.group(1), m.group(2).strip() + + +def reaction_text(reaction: T.ACIReaction) -> str: + """Format an `ACIReaction` as the emoji character or tag string.""" + r = reaction["chatReaction"]["reaction"] # type: ignore[index] + if r["type"] == "emoji": + return r["emoji"] # type: ignore[index] + return r.get("tag", "") # type: ignore[union-attr] diff --git a/packages/simplex-chat-python/tests/test_bot_registration.py b/packages/simplex-chat-python/tests/test_bot_registration.py new file mode 100644 index 0000000000..f6f245c344 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_bot_registration.py @@ -0,0 +1,351 @@ +import pytest + +from simplex_chat import Bot, BotCommand, BotProfile, Client, Middleware, Profile, SqliteDb +from simplex_chat.api import ChatApi + + +def _bot() -> Bot: + return Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + +def test_decorator_registers_message_handler(): + bot = _bot() + + @bot.on_message(content_type="text") + async def h(msg): + pass + + assert len(bot._message_handlers) == 1 + + +def test_decorator_registers_command_handler(): + bot = _bot() + + @bot.on_command("ping") + async def h(msg, cmd): + pass + + assert len(bot._command_handlers) == 1 + assert bot._command_handlers[0][0] == ("ping",) + + +def test_decorator_registers_event_handler(): + bot = _bot() + + @bot.on_event("newChatItems") + async def h(evt): + pass + + assert "newChatItems" in bot._event_handlers + assert len(bot._event_handlers["newChatItems"]) == 1 + + +def test_api_property_raises_before_init(): + bot = _bot() + with pytest.raises(RuntimeError, match="not initialized"): + _ = bot.api + + +def test_command_keyword_tuple(): + bot = _bot() + + @bot.on_command(("p", "ping")) + async def h(msg, cmd): + pass + + assert bot._command_handlers[0][0] == ("p", "ping") + + +def test_bot_profile_to_wire_default(): + """Bot's profile wire-form sets peerType=bot and disables calls/voice.""" + bot = _bot() + p = bot._profile_to_wire() + assert p["displayName"] == "x" + assert p.get("peerType") == "bot" + prefs = p.get("preferences") or {} + assert prefs.get("calls", {}).get("allow") == "no" + assert prefs.get("voice", {}).get("allow") == "no" + assert prefs.get("files", {}).get("allow") == "no" # allow_files defaults to False + + +def test_bot_profile_to_wire_allow_files(): + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + allow_files=True, + ) + prefs = bot._profile_to_wire().get("preferences") or {} + assert prefs.get("files", {}).get("allow") == "yes" + + +def test_bot_profile_to_wire_with_commands(): + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + commands=[BotCommand(keyword="ping", label="Ping bot"), BotCommand("help", "Show help")], + ) + cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or [] + assert len(cmds) == 2 + assert cmds[0] == {"type": "command", "keyword": "ping", "label": "Ping bot"} + assert cmds[1] == {"type": "command", "keyword": "help", "label": "Show help"} + + +def test_client_profile_to_wire_has_no_bot_extras(): + """Client's wire profile has no peerType=bot, no command list, no calls/voice prefs. + That's the whole point of having Client as a separate class.""" + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + p = c._profile_to_wire() + assert p["displayName"] == "x" + assert "peerType" not in p + assert "preferences" not in p + + +def test_bot_profile_alias_is_profile(): + """`BotProfile` is kept as an alias for backwards compatibility.""" + assert BotProfile is Profile + assert BotProfile(display_name="x") == Profile(display_name="x") + + +def test_dispatch_message_first_match_wins(): + """Two matching message handlers — only the first registered fires.""" + import asyncio + import re + + bot = _bot() + calls: list[str] = [] + + @bot.on_message(content_type="text", text=re.compile(r"^\d+$")) + async def number(_msg): + calls.append("number") + + @bot.on_message(content_type="text") + async def fallback(_msg): + calls.append("fallback") + + class M: + pass + + m = M() + m.content = {"type": "text", "text": "42"} + m.chat_item = { + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "42"}} + }, + "chatInfo": {"type": "direct"}, + } + m.text = "42" + + asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type] + assert calls == ["number"], f"expected only 'number' for '42', got {calls}" + + +def test_dispatch_message_falls_to_second_when_first_doesnt_match(): + """If the first handler's filter doesn't match, the second one fires.""" + import asyncio + import re + + bot = _bot() + calls: list[str] = [] + + @bot.on_message(content_type="text", text=re.compile(r"^\d+$")) + async def number(_msg): + calls.append("number") + + @bot.on_message(content_type="text") + async def fallback(_msg): + calls.append("fallback") + + class M: + pass + + m = M() + m.content = {"type": "text", "text": "hello"} + m.chat_item = { + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}} + }, + "chatInfo": {"type": "direct"}, + } + m.text = "hello" + + asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type] + assert calls == ["fallback"], f"expected 'fallback' for 'hello', got {calls}" + + +def test_register_log_handlers_idempotent(): + """Calling _register_log_handlers twice doesn't duplicate handlers.""" + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + log_contacts=True, + log_network=True, + ) + bot._register_log_handlers() + counts1 = {tag: len(hs) for tag, hs in bot._event_handlers.items()} + bot._register_log_handlers() + counts2 = {tag: len(hs) for tag, hs in bot._event_handlers.items()} + assert counts1 == counts2, f"handler count changed across calls: {counts1} -> {counts2}" + + +def test_default_error_handlers_always_registered(): + """messageError/chatError/chatErrors get default loggers regardless of opts.""" + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + log_contacts=False, + log_network=False, + ) + bot._register_log_handlers() + assert "messageError" in bot._event_handlers + assert "chatError" in bot._event_handlers + assert "chatErrors" in bot._event_handlers + + +def test_dispatch_command_suppresses_matching_message_handlers(): + """A `/help` message routed to a command handler must NOT also fire the + generic on_message text handler.""" + import asyncio + + bot = _bot() + calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(_msg): + calls.append("message") + + @bot.on_command("help") + async def help_cmd(_msg, _cmd): + calls.append("command") + + # Build a minimal Message-shaped object (handlers only inspect chat_item / text). + class M: + pass + + m = M() + m.content = {"type": "text", "text": "/help"} + m.chat_item = { + "chatItem": { + "content": { + "type": "rcvMsgContent", + "msgContent": {"type": "text", "text": "/help"}, + } + }, + "chatInfo": {"type": "direct"}, + } + m.text = "/help" + + asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type] + assert calls == ["command"], f"expected only 'command' to fire for /help, got {calls}" + + +def test_dispatch_unknown_command_falls_through_to_message_handlers(): + """A `/unknown` slash-command with no handler should still fire on_message.""" + import asyncio + + bot = _bot() + calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(_msg): + calls.append("message") + + @bot.on_command("help") + async def help_cmd(_msg, _cmd): + calls.append("command") + + class M: + pass + + m = M() + m.content = {"type": "text", "text": "/unknown"} + m.chat_item = { + "chatItem": { + "content": { + "type": "rcvMsgContent", + "msgContent": {"type": "text", "text": "/unknown"}, + } + }, + "chatInfo": {"type": "direct"}, + } + m.text = "/unknown" + + asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type] + assert calls == ["message"], f"expected message fallback to fire for /unknown, got {calls}" + + +def test_chat_api_status_properties(): + """`initialized` and `started` reflect lifecycle state without invoking the FFI.""" + api = ChatApi(ctrl=12345) + assert api.initialized is True + assert api.started is False + assert api.ctrl == 12345 + # Simulate close: ctrl wiped, both properties false. + api._ctrl = None + api._started = False + assert api.initialized is False + assert api.started is False + with pytest.raises(RuntimeError, match="not initialized"): + _ = api.ctrl + + +def test_log_contacts_registers_handlers(): + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + log_contacts=True, + log_network=False, + ) + bot._register_log_handlers() + assert "contactConnected" in bot._event_handlers + assert "contactDeletedByContact" in bot._event_handlers + assert "hostConnected" not in bot._event_handlers + + +def test_log_network_registers_handlers(): + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + log_contacts=False, + log_network=True, + ) + bot._register_log_handlers() + assert "hostConnected" in bot._event_handlers + assert "hostDisconnected" in bot._event_handlers + assert "subscriptionStatus" in bot._event_handlers + assert "contactConnected" not in bot._event_handlers + + +def test_middleware_registration_and_invocation_order(): + """Middleware registered first wraps middleware registered later (outer first).""" + bot = _bot() + calls: list[str] = [] + + class Outer(Middleware): + async def __call__(self, handler, message, data): + calls.append("outer-before") + await handler(message, data) + calls.append("outer-after") + + class Inner(Middleware): + async def __call__(self, handler, message, data): + calls.append("inner-before") + await handler(message, data) + calls.append("inner-after") + + bot.use(Outer()) + bot.use(Inner()) + assert len(bot._middleware) == 2 + + async def handler(msg): + calls.append("handler") + + import asyncio + + asyncio.run(bot._invoke_with_middleware(handler, message=object())) # type: ignore[arg-type] + assert calls == [ + "outer-before", + "inner-before", + "handler", + "inner-after", + "outer-after", + ] diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py new file mode 100644 index 0000000000..7c01ae576a --- /dev/null +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -0,0 +1,616 @@ +"""Tests for Client class + connect_to / send_and_wait / events plumbing. + +Stubs out ChatApi so we exercise the dispatch and waiter logic without +spinning up the native libsimplex controller. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from simplex_chat import ( + Bot, + BotProfile, + Client, + ContactAlreadyExistsError, + Profile, + SqliteDb, +) + + +class FakeApi: + """Drop-in replacement for ChatApi for tests that don't need the FFI. + + Records api_send_text_message calls; supports scripting api_connect_plan + and api_connect_active_user behaviour. + """ + + def __init__(self) -> None: + self.sent: list[tuple[Any, str]] = [] + self.connect_plan_result: Any = ("error", None) # default: no known contact + self.connect_should_raise: Exception | None = None + self.active_user: dict[str, Any] = {"userId": 1, "profile": {"displayName": "x"}} + + async def api_send_text_message(self, chat, text, in_reply_to=None): + self.sent.append((chat, text)) + return [] + + async def api_connect_plan(self, _user_id, _link): + kind = self.connect_plan_result[0] + if kind == "known_contact_address": + return ( + { + "type": "contactAddress", + "contactAddressPlan": {"type": "known", "contact": self.connect_plan_result[1]}, + }, + {}, + ) + if kind == "known_invitation": + return ( + { + "type": "invitationLink", + "invitationLinkPlan": {"type": "known", "contact": self.connect_plan_result[1]}, + }, + {}, + ) + if kind == "ok": + return ( + { + "type": "contactAddress", + "contactAddressPlan": {"type": "ok"}, + }, + {}, + ) + # default "error" + return ({"type": "error", "chatError": {}}, {}) + + async def api_connect_active_user(self, _link): + if self.connect_should_raise is not None: + raise self.connect_should_raise + return "contact" + + async def api_get_active_user(self): + return self.active_user + + +def _bot_with_fake_api() -> tuple[Bot, FakeApi]: + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + api = FakeApi() + bot._api = api # type: ignore[assignment] + bot._serving = True # pretend receive loop is up + return bot, api + + +# --------------------------------------------------------------------------- +# Client class +# --------------------------------------------------------------------------- + + +def test_client_has_no_address_or_bot_profile_attributes(): + """Client should not carry bot-side state (address creation, auto-accept, + welcome, commands). That's the whole point of separating Client from Bot.""" + c = Client(profile=Profile(display_name="monitor"), db=SqliteDb(file_prefix="/tmp/test")) + for attr in ("_create_address", "_update_address", "_auto_accept", "_welcome", "_commands"): + assert not hasattr(c, attr), f"Client unexpectedly has Bot-only attribute {attr}" + # And the wire profile has no bot peerType + p = c._profile_to_wire() + assert "peerType" not in p + assert "preferences" not in p + + +def test_bot_is_a_client_subclass(): + """Bot should extend Client, so anywhere a Client is accepted, a Bot fits too.""" + assert issubclass(Bot, Client) + + +def test_client_exposes_messaging_methods(): + c = Client(profile=Profile(display_name="m"), db=SqliteDb(file_prefix="/tmp/test")) + assert hasattr(c, "connect_to") + assert hasattr(c, "send_and_wait") + assert hasattr(c, "events") + assert hasattr(c, "on_message") # decorators available on Client too + + +# --------------------------------------------------------------------------- +# send_and_wait +# --------------------------------------------------------------------------- + + +def test_send_and_wait_requires_serving(): + """Without the receive loop running, send_and_wait must raise — otherwise + callers would silently hang waiting for a reply that's never dispatched.""" + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + bot._api = FakeApi() # type: ignore[assignment] + # _serving is False by default + with pytest.raises(RuntimeError, match="receive loop"): + asyncio.run(bot.send_and_wait(1, "hi")) + + +def test_send_and_wait_resolves_on_matching_reply(): + """A reply from the awaited contact should resolve the Future and skip + regular message dispatch.""" + bot, api = _bot_with_fake_api() + fallback_calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(_msg): + fallback_calls.append("fallback") + + async def go() -> str: + send_task = asyncio.create_task(bot.send_and_wait(42, "ping", timeout=2.0)) + # Yield so the task gets to register its waiter. + await asyncio.sleep(0) + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "pong"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + reply = await send_task + return reply.text or "" + + result = asyncio.run(go()) + assert result == "pong" + assert api.sent == [(["direct", 42], "ping")] + assert fallback_calls == [], "fallback handler should NOT fire when a waiter consumed the reply" + + +def test_send_and_wait_ignores_other_contacts(): + """Replies from a different contact must not resolve the waiter — that + would mis-correlate responses and is the bug send_and_wait exists to + prevent users from writing themselves.""" + bot, _api = _bot_with_fake_api() + + async def go(): + send_task = asyncio.create_task(bot.send_and_wait(42, "ping", timeout=0.5)) + await asyncio.sleep(0) + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 99}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "not for you"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + with pytest.raises(asyncio.TimeoutError): + await send_task + + asyncio.run(go()) + + +def test_send_and_wait_fifo_within_contact(): + """Two concurrent waiters on the same contact should resolve in send order.""" + bot, _api = _bot_with_fake_api() + + async def go() -> tuple[str, str]: + first = asyncio.create_task(bot.send_and_wait(42, "first", timeout=2.0)) + await asyncio.sleep(0) + second = asyncio.create_task(bot.send_and_wait(42, "second", timeout=2.0)) + await asyncio.sleep(0) + for text in ("reply1", "reply2"): + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + return (await first).text or "", (await second).text or "" + + a, b = asyncio.run(go()) + assert (a, b) == ("reply1", "reply2") + + +def test_send_and_wait_cleans_up_state_on_timeout(): + """Timed-out waiters must be removed so they don't accidentally consume + later replies.""" + bot, _api = _bot_with_fake_api() + + async def go(): + with pytest.raises(asyncio.TimeoutError): + await bot.send_and_wait(42, "ping", timeout=0.05) + assert 42 not in bot._reply_waiters, f"leaked waiters: {bot._reply_waiters}" + + asyncio.run(go()) + + +def test_dispatch_skips_cancelled_waiters_and_falls_through_to_handlers(): + """Race fix: if a waiter is cancelled (wait_for timed out) but still in + the FIFO when a reply arrives, the dispatcher must skip it and either + resolve a live waiter OR fall through to user message handlers — not + silently drop the message.""" + bot, _api = _bot_with_fake_api() + fallback_calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(msg): + fallback_calls.append(msg.text or "") + + async def go(): + # Manually inject a cancelled waiter (simulating wait_for timeout + # cleanup losing the race with the inbound message). + loop = asyncio.get_running_loop() + stale: asyncio.Future = loop.create_future() + stale.cancel() + bot._reply_waiters[42] = [stale] + + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "racing reply"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + + asyncio.run(go()) + assert fallback_calls == ["racing reply"], ( + "dispatcher dropped the message instead of falling through to user handlers; " + f"got {fallback_calls}" + ) + assert 42 not in bot._reply_waiters, "cancelled waiter wasn't cleaned up" + + +def test_send_and_wait_parallel_different_contacts(): + """Concurrent send_and_wait to different contacts must not block each other. + + The library docstring promises this; this test pins the behaviour so a + future refactor (e.g., adding a single lock) can't quietly break it.""" + bot, _api = _bot_with_fake_api() + + async def go() -> tuple[str, str]: + t_a = asyncio.create_task(bot.send_and_wait(10, "a", timeout=2.0)) + await asyncio.sleep(0) + t_b = asyncio.create_task(bot.send_and_wait(20, "b", timeout=2.0)) + await asyncio.sleep(0) + # Deliver reply for B first — order shouldn't matter. + await bot._dispatch_event({"type": "newChatItems", "chatItems": [ # type: ignore[arg-type] + { + "chatInfo": {"type": "direct", "contact": {"contactId": 20}}, + "chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "B"}}}, + } + ]}) + await bot._dispatch_event({"type": "newChatItems", "chatItems": [ # type: ignore[arg-type] + { + "chatInfo": {"type": "direct", "contact": {"contactId": 10}}, + "chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "A"}}}, + } + ]}) + return (await t_a).text or "", (await t_b).text or "" + + a, b = asyncio.run(go()) + assert (a, b) == ("A", "B") + + +# --------------------------------------------------------------------------- +# connect_to +# --------------------------------------------------------------------------- + + +def test_connect_to_returns_known_contact_without_handshake(): + """If the link is already known, connect_to skips api_connect entirely.""" + bot, api = _bot_with_fake_api() + existing = {"contactId": 7, "profile": {"displayName": "SimpleX Directory"}} + api.connect_plan_result = ("known_contact_address", existing) + + contact = asyncio.run(bot.connect_to("link", timeout=2.0)) + assert contact["contactId"] == 7 + # No connect issued: send buffer untouched. + assert api.sent == [] + + +def test_connect_to_waits_for_contactConnected(): + """For unknown links, connect_to issues the handshake and waits for the + contactConnected event before returning.""" + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + new_contact = {"contactId": 11, "profile": {"displayName": "Friend"}} + + async def go(): + connect_task = asyncio.create_task(bot.connect_to("link", timeout=2.0)) + await asyncio.sleep(0) + await bot._dispatch_event({"type": "contactConnected", "contact": new_contact}) # type: ignore[arg-type] + return await connect_task + + contact = asyncio.run(go()) + assert contact["contactId"] == 11 + + +def test_connect_to_tolerates_contact_already_exists(): + """ContactAlreadyExistsError must NOT abort connect_to — a previous + incomplete attempt may have left the connection mid-handshake; the + contactConnected event will still arrive.""" + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + api.connect_should_raise = ContactAlreadyExistsError( + "exists", {"type": "contactAlreadyExists"} # type: ignore[arg-type] + ) + + async def go(): + connect_task = asyncio.create_task(bot.connect_to("link", timeout=2.0)) + await asyncio.sleep(0) + await bot._dispatch_event({"type": "contactConnected", "contact": {"contactId": 5, "profile": {"displayName": "Friend"}}}) # type: ignore[arg-type] + return await connect_task + + contact = asyncio.run(go()) + assert contact["contactId"] == 5 + + +def test_connect_to_requires_serving(): + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + bot._api = FakeApi() # type: ignore[assignment] + with pytest.raises(RuntimeError, match="receive loop"): + asyncio.run(bot.connect_to("link")) + + +def test_connect_to_timeout_cleans_up_waiter(): + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + + async def go(): + with pytest.raises(asyncio.TimeoutError): + await bot.connect_to("link", timeout=0.05) + assert bot._connect_waiters == [], "leaked connect waiter" + + asyncio.run(go()) + + +def test_connect_to_rejects_non_positive_timeout(): + """timeout<=0 must fail upfront — otherwise wait_for raises after the + handshake side-effect has already gone over the wire.""" + bot, _api = _bot_with_fake_api() + + async def go(): + for bad in (0, -1, -0.001): + with pytest.raises(ValueError, match="timeout must be positive"): + await bot.connect_to("link", timeout=bad) + + asyncio.run(go()) + + +def test_send_and_wait_rejects_non_positive_timeout(): + """Same as connect_to: timeout<=0 would surprise the caller with a sent + message and no Future to await.""" + bot, api = _bot_with_fake_api() + + async def go(): + for bad in (0, -1, -0.5): + with pytest.raises(ValueError, match="timeout must be positive"): + await bot.send_and_wait(42, "ping", timeout=bad) + # And nothing was sent. + assert api.sent == [] + + asyncio.run(go()) + + +def test_stop_before_serve_forever_is_preserved(monkeypatch): + """If stop() is called between __aenter__ and serve_forever (e.g. a + signal handler fires during the window where run() wires SIGINT), the + pre-set _stop_event must NOT be cleared by serve_forever — otherwise + the signal is silently lost and the loop runs indefinitely.""" + import simplex_chat.client as client_mod + + class _FakeApi: + @classmethod + async def init(cls, *_a, **_kw): + return cls() + + @property + def started(self): + return False + + async def start_chat(self): + pass + + async def stop_chat(self): + pass + + async def close(self): + pass + + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def recv_chat_event(self, wait_us=0): + # Should NOT be reached — the loop should exit on the pre-set + # stop event before it ever polls for an event. + raise AssertionError("receive loop should have exited immediately") + + # _ensure_active_user / _maybe_sync_profile pokes + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", _FakeApi) + + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + async with c: + c.stop() # signal fires before serve_forever + await c.serve_forever() # must not block + + asyncio.run(go()) + + +def test_aexit_nulls_api_even_if_close_raises(monkeypatch): + """If `close()` raises inside __aexit__, the Client must still appear + closed — `client.api` should refuse to hand back the half-shutdown + controller, and re-entering the context manager should re-init cleanly.""" + import simplex_chat.client as client_mod + + init_count = [0] + + class _BoomCloseApi: + @classmethod + async def init(cls, *_a, **_kw): + init_count[0] += 1 + return cls() + + @property + def started(self): + return False + + async def start_chat(self): + pass + + async def stop_chat(self): + pass + + async def close(self): + raise RuntimeError("close failed") + + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", _BoomCloseApi) + + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + with pytest.raises(RuntimeError, match="close failed"): + async with c: + pass + # _api must be None despite close() raising + assert c._api is None, "Client._api leaked after __aexit__ close() raised" + with pytest.raises(RuntimeError, match="not initialized"): + _ = c.api + # Re-enter must work + try: + async with c: + pass + except RuntimeError: + pass # close raises again, fine + assert init_count[0] == 2, "re-entry didn't re-init the controller" + + asyncio.run(go()) + + +def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch): + """If anything in __aenter__ raises after ChatApi.init succeeded — including + _post_start — the controller must be closed. Otherwise the with-block isn't + entered, __aexit__ never runs, and the FFI handle leaks.""" + import simplex_chat.client as client_mod + + closed: list[str] = [] + started: list[bool] = [False] + + class FakeChatApi: + @classmethod + async def init(cls, *_args, **_kwargs): + return cls() + + @property + def started(self) -> bool: + return started[0] + + async def start_chat(self): + started[0] = True + + async def stop_chat(self): + started[0] = False + closed.append("stop") + + async def close(self): + closed.append("close") + + # Stub the bits _ensure_active_user / _maybe_sync_profile reach for. + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", FakeChatApi) + + class Boom(RuntimeError): + pass + + class BoomClient(Client): + async def _post_start(self, user): + raise Boom("kaboom") + + c = BoomClient(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + with pytest.raises(Boom): + async with c: + pytest.fail("should not enter the with-block") + + asyncio.run(go()) + assert closed == ["stop", "close"], f"controller not cleaned up: {closed}" + assert c._api is None, "Client._api should be reset to None after rollback" + + +def test_lookup_known_contact_propagates_non_command_errors(): + """_lookup_known_contact must NOT mask transport / FFI errors as 'unknown + link' — only ChatCommandError (malformed link, etc.) should fall through + to the handshake path. Bare Exception catch would hide real bugs.""" + bot, api = _bot_with_fake_api() + + class BoomError(RuntimeError): + pass + + async def boom(_user_id, _link): + raise BoomError("FFI wedged") + + api.api_connect_plan = boom # type: ignore[assignment] + + async def go(): + with pytest.raises(BoomError): + await bot._lookup_known_contact(1, "link") + + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# Exception subclasses +# --------------------------------------------------------------------------- + + +def test_contact_already_exists_is_chat_command_error_subclass(): + """Callers should be able to catch the base class to handle all command + errors uniformly, and the specific subclass for targeted handling.""" + from simplex_chat import ChatCommandError, ContactAlreadyExistsError + + assert issubclass(ContactAlreadyExistsError, ChatCommandError) + + e = ContactAlreadyExistsError("x", {"type": "contactAlreadyExists"}) # type: ignore[arg-type] + assert isinstance(e, ChatCommandError) + assert e.response_type == "contactAlreadyExists" + + +def test_chat_command_error_response_type_property(): + from simplex_chat import ChatCommandError + + e = ChatCommandError("x", {"type": "someError"}) # type: ignore[arg-type] + assert e.response_type == "someError" + + +# --------------------------------------------------------------------------- +# events() mutual exclusion with serve_forever +# --------------------------------------------------------------------------- + + +def test_events_raises_if_already_serving(): + bot, _api = _bot_with_fake_api() + # _serving=True is set by _bot_with_fake_api + + async def go(): + with pytest.raises(RuntimeError, match="mutually exclusive"): + async for _ in bot.events(): + pass + + asyncio.run(go()) diff --git a/packages/simplex-chat-python/tests/test_codegen.py b/packages/simplex-chat-python/tests/test_codegen.py new file mode 100644 index 0000000000..509d919cfd --- /dev/null +++ b/packages/simplex-chat-python/tests/test_codegen.py @@ -0,0 +1,41 @@ +"""Sanity checks on auto-generated wire types — catches generator regressions.""" + +import typing + +from simplex_chat.types import CC, CEvt, CR, T + + +def test_types_module_imports(): + """Every generated module imports cleanly with no SyntaxError.""" + assert T is not None and CC is not None and CR is not None and CEvt is not None + + +def test_chat_type_is_literal_enum(): + """ChatType should be a Literal of expected member set.""" + args = typing.get_args(T.ChatType) + assert "direct" in args + assert "group" in args + assert "local" in args + + +def test_known_command_has_cmd_string(): + s = CC.APICreateMyAddress_cmd_string({"userId": 1}) + assert s == "/_address 1" + + +def test_chat_response_tag_alias_present(): + """ChatResponse_Tag union of literals exists.""" + assert hasattr(CR, "ChatResponse_Tag") + + +def test_chat_event_tag_alias_present(): + """ChatEvent_Tag exists; covers the on_event Literal annotation.""" + assert hasattr(CEvt, "ChatEvent_Tag") + args = typing.get_args(CEvt.ChatEvent_Tag) + assert "newChatItems" in args + + +def test_chat_ref_cmd_string_direct(): + """Sanity check the codegen fix for ChatRef-bearing commands.""" + assert T.ChatRef_cmd_string({"chatType": "direct", "chatId": 7}) == "@7" + assert T.ChatRef_cmd_string({"chatType": "group", "chatId": 42}) == "#42" diff --git a/packages/simplex-chat-python/tests/test_filters.py b/packages/simplex-chat-python/tests/test_filters.py new file mode 100644 index 0000000000..3c909df4df --- /dev/null +++ b/packages/simplex-chat-python/tests/test_filters.py @@ -0,0 +1,103 @@ +import re + +from simplex_chat.filters import compile_message_filter + + +def _msg(content_type="text", text=None, chat_type="direct", group_id=None, contact_id=None): + """Build a minimal mock Message-like object for filter testing.""" + + class M: + pass + + m = M() + m.content = {"type": content_type, "text": text} if text is not None else {"type": content_type} + chat_info: dict = {"type": chat_type} + if chat_type == "group": + chat_info["groupInfo"] = {"groupId": group_id} + elif chat_type == "direct" and contact_id is not None: + chat_info["contact"] = {"contactId": contact_id} + m.chat_item = {"chatInfo": chat_info} + return m + + +def test_no_filters_matches_all(): + f = compile_message_filter({}) + assert f(_msg(content_type="text")) + assert f(_msg(content_type="image")) + + +def test_content_type_singular(): + f = compile_message_filter({"content_type": "text"}) + assert f(_msg(content_type="text")) + assert not f(_msg(content_type="image")) + + +def test_content_type_tuple_or(): + f = compile_message_filter({"content_type": ("text", "image")}) + assert f(_msg(content_type="text")) + assert f(_msg(content_type="image")) + assert not f(_msg(content_type="voice")) + + +def test_text_exact(): + f = compile_message_filter({"text": "hello"}) + assert f(_msg(text="hello")) + assert not f(_msg(text="world")) + + +def test_text_regex(): + f = compile_message_filter({"text": re.compile(r"^\d+$")}) + assert f(_msg(text="123")) + assert not f(_msg(text="abc")) + + +def test_when_callable(): + f = compile_message_filter({"when": lambda m: m.content["type"] == "voice"}) + assert f(_msg(content_type="voice")) + assert not f(_msg(content_type="text")) + + +def test_combined_and(): + f = compile_message_filter({"content_type": "text", "text": re.compile(r"\d")}) + assert f(_msg(content_type="text", text="abc123")) + assert not f(_msg(content_type="text", text="abc")) + assert not f(_msg(content_type="image")) + + +def test_chat_type_filter(): + f = compile_message_filter({"chat_type": "group"}) + assert f(_msg(chat_type="group", group_id=1)) + assert not f(_msg(chat_type="direct")) + + +def test_group_id_filter(): + f = compile_message_filter({"group_id": 42}) + assert f(_msg(chat_type="group", group_id=42)) + assert not f(_msg(chat_type="group", group_id=99)) + assert not f(_msg(chat_type="direct")) + + +def test_group_id_tuple_or(): + f = compile_message_filter({"group_id": (1, 2, 3)}) + assert f(_msg(chat_type="group", group_id=2)) + assert not f(_msg(chat_type="group", group_id=99)) + + +def test_contact_id_filter(): + f = compile_message_filter({"contact_id": 7}) + assert f(_msg(chat_type="direct", contact_id=7)) + assert not f(_msg(chat_type="direct", contact_id=99)) + assert not f(_msg(chat_type="group", group_id=7)) + + +def test_contact_id_tuple_or(): + f = compile_message_filter({"contact_id": (1, 2, 3)}) + assert f(_msg(chat_type="direct", contact_id=2)) + assert not f(_msg(chat_type="direct", contact_id=99)) + + +def test_contact_id_combined_with_content_type(): + f = compile_message_filter({"content_type": "text", "contact_id": 5}) + assert f(_msg(content_type="text", chat_type="direct", contact_id=5)) + assert not f(_msg(content_type="image", chat_type="direct", contact_id=5)) + assert not f(_msg(content_type="text", chat_type="direct", contact_id=99)) diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py new file mode 100644 index 0000000000..30a1f43e2a --- /dev/null +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -0,0 +1,92 @@ +import zipfile +from pathlib import Path + +import pytest + +from simplex_chat._native import _cache_root, _resolve_libs_dir, _download + + +def test_cache_root_linux(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert _cache_root() == tmp_path / "simplex-chat" + + +def test_cache_root_macos(tmp_path, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + assert _cache_root() == tmp_path / "Library" / "Caches" / "simplex-chat" + + +def test_override_via_env(tmp_path, monkeypatch): + # _resolve_libs_dir intentionally does not validate the override directory — + # it returns it verbatim; the eventual ctypes.CDLL call surfaces any mistake. + monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert _resolve_libs_dir("sqlite") == tmp_path + + +def test_resolve_downloads_when_missing(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + + called = {} + + def fake_download(target_root: Path, backend: str) -> None: + called["target"] = target_root + called["backend"] = backend + target_root.mkdir(parents=True, exist_ok=True) + (target_root / "libsimplex.so").touch() + + monkeypatch.setattr("simplex_chat._native._download", fake_download) + libs_dir = _resolve_libs_dir("sqlite") + assert libs_dir == tmp_path / "simplex-chat" / "v6.5.2" / "sqlite" + assert called["backend"] == "sqlite" + assert (libs_dir / "libsimplex.so").exists() + + +def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + cached = tmp_path / "simplex-chat" / "v6.5.2" / "sqlite" + cached.mkdir(parents=True) + (cached / "libsimplex.so").touch() + # Should NOT call _download — use the cached file. + monkeypatch.setattr( + "simplex_chat._native._download", lambda *a: pytest.fail("download should not be called") + ) + assert _resolve_libs_dir("sqlite") == cached + + +def test_postgres_on_macos_rejected(monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "macos-aarch64") + with pytest.raises(RuntimeError, match="postgres.*linux-x86_64"): + _resolve_libs_dir("postgres") + + +def test_atomic_install(tmp_path, monkeypatch): + """Build a fake libs zip, mock _stream_to_file, verify extraction + atomic rename.""" + # Build zip: libs/libsimplex.so + libs/libHS-stub.so + src = tmp_path / "src" / "libs" + src.mkdir(parents=True) + (src / "libsimplex.so").write_text("fake-so") + (src / "libHS-stub.so").write_text("fake-hs") + zip_path = tmp_path / "fake-libs.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in src.iterdir(): + zf.write(f, f"libs/{f.name}") + + def fake_stream(url, dest, *, timeout=60.0): + import shutil + + shutil.copy(zip_path, dest) + + monkeypatch.setattr("simplex_chat._native._stream_to_file", fake_stream) + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + + target = tmp_path / "out" + _download(target, "sqlite") + assert (target / "libsimplex.so").read_text() == "fake-so" + assert (target / "libHS-stub.so").read_text() == "fake-hs" diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py new file mode 100644 index 0000000000..b27c3e09cf --- /dev/null +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -0,0 +1,55 @@ +from unittest.mock import patch +import pytest +from simplex_chat._native import _platform_tag, _libs_url, _libname + + +@patch("sys.platform", "linux") +@patch("platform.machine", return_value="x86_64") +def test_platform_linux_x64(_): + assert _platform_tag() == "linux-x86_64" + + +@patch("sys.platform", "darwin") +@patch("platform.machine", return_value="arm64") +def test_platform_macos_arm64(_): + assert _platform_tag() == "macos-aarch64" + + +@patch("sys.platform", "win32") +@patch("platform.machine", return_value="AMD64") +def test_platform_windows_x64(_): + assert _platform_tag() == "windows-x86_64" + + +@patch("sys.platform", "freebsd") +@patch("platform.machine", return_value="x86_64") +def test_platform_unsupported(_): + with pytest.raises(RuntimeError, match="Unsupported"): + _platform_tag() + + +def test_libname_per_platform(): + with patch("sys.platform", "linux"): + assert _libname() == "libsimplex.so" + with patch("sys.platform", "darwin"): + assert _libname() == "libsimplex.dylib" + with patch("sys.platform", "win32"): + assert _libname() == "libsimplex.dll" + + +@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64") +def test_url_sqlite(_): + assert ( + _libs_url("sqlite") + == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" + "v6.5.2/simplex-chat-libs-linux-x86_64.zip" + ) + + +@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64") +def test_url_postgres(_): + assert ( + _libs_url("postgres") + == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" + "v6.5.2/simplex-chat-libs-linux-x86_64-postgres.zip" + ) diff --git a/packages/simplex-chat-python/tests/test_util.py b/packages/simplex-chat-python/tests/test_util.py new file mode 100644 index 0000000000..983b1c2a56 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_util.py @@ -0,0 +1,175 @@ +from simplex_chat import util + + +def test_chat_info_ref_direct(): + ci = {"type": "direct", "contact": {"contactId": 7}} + assert util.chat_info_ref(ci) == {"chatType": "direct", "chatId": 7} + + +def test_chat_info_ref_group(): + ci = {"type": "group", "groupInfo": {"groupId": 42}} + assert util.chat_info_ref(ci) == {"chatType": "group", "chatId": 42} + + +def test_chat_info_ref_group_with_member_support_scope(): + ci = { + "type": "group", + "groupInfo": {"groupId": 42}, + "groupChatScope": {"type": "memberSupport", "groupMember_": {"groupMemberId": 99}}, + } + ref = util.chat_info_ref(ci) + assert ref == { + "chatType": "group", + "chatId": 42, + "chatScope": {"type": "memberSupport", "groupMemberId_": 99}, + } + + +def test_chat_info_ref_group_with_member_support_scope_no_member(): + ci = { + "type": "group", + "groupInfo": {"groupId": 42}, + "groupChatScope": {"type": "memberSupport"}, + } + ref = util.chat_info_ref(ci) + # No groupMember_ → no groupMemberId_ in the wire scope. + assert ref == { + "chatType": "group", + "chatId": 42, + "chatScope": {"type": "memberSupport"}, + } + + +def test_chat_info_ref_returns_none_for_non_targets(): + assert util.chat_info_ref({"type": "contactRequest"}) is None + assert util.chat_info_ref({"type": "contactConnection"}) is None + + +def test_chat_info_name_direct(): + ci = {"type": "direct", "contact": {"profile": {"displayName": "Alice"}}} + assert util.chat_info_name(ci) == "@Alice" + + +def test_chat_info_name_group(): + ci = {"type": "group", "groupInfo": {"groupProfile": {"displayName": "MyGroup"}}} + assert util.chat_info_name(ci) == "#MyGroup" + + +def test_chat_info_name_group_with_member_support(): + ci = { + "type": "group", + "groupInfo": {"groupProfile": {"displayName": "MyGroup"}}, + "groupChatScope": { + "type": "memberSupport", + "groupMember_": {"memberProfile": {"displayName": "Carol"}}, + }, + } + assert util.chat_info_name(ci) == "#MyGroup(support Carol)" + + +def test_chat_info_name_local(): + assert util.chat_info_name({"type": "local"}) == "private notes" + + +def test_chat_info_name_contact_request(): + ci = {"type": "contactRequest", "contactRequest": {"profile": {"displayName": "Eve"}}} + assert util.chat_info_name(ci) == "request from @Eve" + + +def test_chat_info_name_contact_connection(): + assert util.chat_info_name({"type": "contactConnection", "contactConnection": {}}) == ( + "pending connection" + ) + assert ( + util.chat_info_name({"type": "contactConnection", "contactConnection": {"localAlias": "X"}}) + == "pending connection (X)" + ) + + +def test_sender_name_direct_uses_chat_name(): + ci = {"type": "direct", "contact": {"profile": {"displayName": "Alice"}}} + chat_dir = {"type": "directRcv"} + assert util.sender_name(ci, chat_dir) == "@Alice" + + +def test_sender_name_group_appends_member(): + ci = {"type": "group", "groupInfo": {"groupProfile": {"displayName": "MyGroup"}}} + chat_dir = {"type": "groupRcv", "groupMember": {"memberProfile": {"displayName": "Bob"}}} + assert util.sender_name(ci, chat_dir) == "#MyGroup @Bob" + + +def test_contact_address_str_prefers_short(): + assert util.contact_address_str({"connFullLink": "full", "connShortLink": "short"}) == "short" + + +def test_contact_address_str_falls_back_to_full(): + assert util.contact_address_str({"connFullLink": "full"}) == "full" + + +def test_from_local_profile_strips_extras_and_undefined(): + local = { + "displayName": "x", + "fullName": "X Y", + "shortDescr": None, + "image": "data:image/png;base64,...", + "contactLink": None, + "preferences": {}, + "peerType": "bot", + "profileId": 99, # extra LocalProfile field + "localAlias": "alias", # extra LocalProfile field + } + p = util.from_local_profile(local) + assert p == { + "displayName": "x", + "fullName": "X Y", + "image": "data:image/png;base64,...", + "preferences": {}, + "peerType": "bot", + } + + +def test_ci_content_text_rcv(): + ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}}} + assert util.ci_content_text(ci) == "hello" + + +def test_ci_content_text_snd(): + ci = {"content": {"type": "sndMsgContent", "msgContent": {"type": "text", "text": "world"}}} + assert util.ci_content_text(ci) == "world" + + +def test_ci_content_text_other(): + ci = {"content": {"type": "rcvGroupEvent"}} + assert util.ci_content_text(ci) is None + + +def test_ci_bot_command_match(): + ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/ping"}}} + assert util.ci_bot_command(ci) == ("ping", "") + + +def test_ci_bot_command_with_args(): + ci = { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/echo hi "}} + } + assert util.ci_bot_command(ci) == ("echo", "hi") + + +def test_ci_bot_command_not_a_command(): + ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}}} + assert util.ci_bot_command(ci) is None + + +def test_ci_bot_command_no_text(): + ci = {"content": {"type": "rcvGroupEvent"}} + assert util.ci_bot_command(ci) is None + + +def test_reaction_text_emoji(): + r = {"chatReaction": {"reaction": {"type": "emoji", "emoji": "🎉"}}} + assert util.reaction_text(r) == "🎉" + + +def test_reaction_text_tag(): + r = {"chatReaction": {"reaction": {"type": "unknown", "tag": "thumbs_up"}}} + assert util.reaction_text(r) == "thumbs_up" diff --git a/plans/2026-02-17-ios-channels-product-plan.md b/plans/2026-02-17-ios-channels-product-plan.md new file mode 100644 index 0000000000..de448ec27e --- /dev/null +++ b/plans/2026-02-17-ios-channels-product-plan.md @@ -0,0 +1,506 @@ +# Channels on iOS — Product Plan + +## Contents +1. [Overview](#1-overview) +2. [Screens](#2-screens) + - 2.1 [Chat List](#21-chat-list) + - 2.2 [Channel Messages & Compose](#22-channel-messages--compose) + - 2.3 [Channel Creation](#23-channel-creation) + - 2.4 [Channel Info](#24-channel-info) + - 2.5 [Chat Relay Management (Network & Servers)](#25-chat-relay-management-network--servers) + - 2.6 [Joining a Channel](#26-joining-a-channel) +3. [Implementation Order](#3-implementation-order) + +--- + +## 1. Overview + +### What +Channels are one-to-many broadcast groups where messages flow **owner → chat relays → subscribers**. Unlike regular groups (N-to-N connections), channels use chat relay infrastructure to scale delivery — an owner sends once, chat relays fan out to all subscribers. + +Technically, a channel is a group with `useRelays = true`. All subscribers are observers (read-only). The owner posts as the channel identity. + +### Why +Regular SimpleX groups require direct connections between all members. While there is no hard technical limit, in practice large groups of even several hundred members become very inefficient — group state desynchronizes, delivery becomes inefficient and unreliable, and the experience degrades. Channels solve the broadcast use case: organizations, projects, and individuals publishing to large audiences while preserving SimpleX's privacy model (no user identifiers, relay-mediated delivery). + +### For Whom + +**Channel owners** — creators who want to broadcast to a large audience. They create channels, configure chat relays, post content. Their problem: no way to efficiently reach many people on SimpleX because large groups work badly in practice. + +**Channel subscribers** — readers who want to follow public content. They join via link and receive messages through chat relays. Their problem: can't follow public channels/announcements on SimpleX. + +--- + +## 2. Screens + +### 2.1 Chat List + +New icon (`antenna.radiowaves.left.and.right`) to differentiate channels. + +``` +┌────────────────────────────────────────┐ +│ [👥] Team Chat 3:42 PM │ +│ alice: Hey everyone... ● 1 │ +├────────────────────────────────────────┤ +│ [📡] SimpleX News 3:38 PM │ +│ Latest update about... ● 3 │ +├────────────────────────────────────────┤ +│ [👤] Bob 2:15 PM │ +│ See you tomorrow ✓✓ │ +└────────────────────────────────────────┘ +``` + +Chat header uses channel icon when no profile image, same as groups: + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +└────────────────────────────────────────┘ +``` + +--- + +### 2.2 Channel Messages & Compose + +Messages render with channel avatar + channel name as sender (via existing `showGroupAsSender` path). Consecutive messages group without repeating avatar/name. + +**Subscriber view** — compose disabled with "you are subscriber" label (vs. "you are observer" in groups): + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ [📡] SimpleX News │ +│ ┌──────────────────────────────────┐ │ +│ │ We're excited to announce v7.0! │ │ +│ │ New channel feature allows... │ │ +│ │ 3:42 PM │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Check out the blog post: │ │ +│ │ simplex.chat/blog/v7 │ │ +│ │ 3:45 PM │ │ +│ └──────────────────────────────────┘ │ +│ │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +**Owner view** — compose field shows "Broadcast" placeholder. Always sends `asGroup=true` (MVP). Backend also supports sending "as member" (like in regular groups), but this will not be available in MVP UI. + +``` +├────────────────────────────────────────┤ +│ ┌───────────────────────────────┐ │ +│ 📎 │ Broadcast ➤ │ │ +│ └───────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +**Note**: If all chat relays are removed or stop serving the channel, this won't be visible in the UI in MVP. + +--- + +### 2.3 Channel Creation + +Entry point: "Create channel" in New Chat menu, after "Create group". + +``` +┌────────────────────────────────────────┐ +│ New message │ +├────────────────────────────────────────┤ +│ 🔗 Create 1-time link > │ +│ 📷 Scan / Paste link > │ +│ 👥 Create group > │ +│ 📡 Create channel > │ +├────────────────────────────────────────┤ +│ 📦 Archived contacts > │ +└────────────────────────────────────────┘ +``` + +#### Step 1 — Channel profile + +``` +┌────────────────────────────────────────┐ +│ Cancel Create channel │ +├────────────────────────────────────────┤ +│ [ 📷 ] │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Enter channel name... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Configure relays... > │ +│ │ +│ Your profile will be shared with │ +│ chat relays and subscribers. │ +│ Random relays will be selected from │ +│ the list of enabled chat relays. │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Create channel │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +"Configure relays..." opens Network & Servers view (full settings view) where the user can enable/disable chat relays globally. + +There is no explicit relay selection — the app randomly selects from enabled chat relays, same as for SMP/XFTP servers. + +> **API note**: Currently `apiNewPublicGroup` takes an explicit list of chat relay IDs. Either the API should be reworked to select relays automatically (consistent with SMP/XFTP server selection), or the UI should randomly select from enabled relays and pass the IDs. + +"Create channel" disabled when name is invalid or no relays enabled. + +#### Step 2 — Relay connection progress + +After tapping "Create channel", chat relays are selected automatically and `apiNewPublicGroup` sends relay invitations. Progress shown as a progress bar with label. + +``` +┌────────────────────────────────────────┐ +│ Creating channel... │ +├────────────────────────────────────────┤ +│ [ 📷 ] │ +│ SimpleX News │ +│ │ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ 1/3 relays connected │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Channel link │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +Tap progress label to expand relay list: + +``` +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ ▼ 1/3 relays connected │ +│ relay1.simplex.im ✓ Active │ +│ relay2.simplex.im Connecting │ +│ relay3.simplex.im Connecting │ +``` + +"Channel link" button enabled when ≥1 relay is active. If tapped while relays are still connecting, warning alert: "Not all relays have connected yet. Channel will start working with N relays. Proceed?" — Proceed / Wait. + +#### Step 3 — Channel link + +Shown after tapping "Channel link" or auto-transition when all relays active. Standard `GroupLinkView` with QR code + share (same as group creation). + +``` +┌────────────────────────────────────────┐ +│ Back Channel link Continue │ +├────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ │ │ +│ │ [ QR CODE ] │ │ +│ │ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ https://simplex.chat/... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ^ Share link │ +└────────────────────────────────────────┘ +``` + +#### Failure modes (inline on Step 2) + +- **API call fails** (sync — relay invitation send failed): Alert "Error creating channel" + error detail. Retry / Cancel. +- **Partial relay error** (async — some relays don't connect): Progress shows "2/3 relays connected, 1 failed". Expanded view: failed relay with red ● Error. "Channel link" enabled — channel works with fewer relays. +- **All relays error** (async): Progress shows "0/3 relays connected, 3 failed" in red. Alert with Retry / Cancel. + +--- + +### 2.4 Channel Info + +Extends `GroupChatInfoView` with conditional sections for `useRelays = true`. + +**Design rationale:** Owners/subscribers lists live in a sub-view (not inline) to match patterns familiar from other messengers and reduce main info screen clutter. + +#### Owner view + +``` +┌────────────────────────────────────────┐ +│ Done SimpleX News Edit │ +├────────────────────────────────────────┤ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ Set chat name... │ +├────────────────────────────────────────┤ +│ 🔍 Search │ 🔇 Mute │ +├────────────────────────────────────────┤ +│ Channel link > │ +│ Owners & subscribers > │ +├────────────────────────────────────────┤ +│ Edit channel profile > │ +│ Welcome message > │ +├────────────────────────────────────────┤ +│ Chat theme > │ +│ Delete messages after > │ +├────────────────────────────────────────┤ +│ Chat relays > │ +│ Clear chat │ +│ Delete channel │ +└────────────────────────────────────────┘ +``` + +No "Leave channel" for single (last) owner. + +Post-MVP: "Chats with subscribers" navigation link in section 1 for subscriber support. + +TBC: share link button in action buttons row. + +#### Subscriber view + +``` +┌────────────────────────────────────────┐ +│ Done SimpleX News │ +├────────────────────────────────────────┤ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ Set chat name... │ +├────────────────────────────────────────┤ +│ 🔍 Search │ 🔇 Mute │ +├────────────────────────────────────────┤ +│ Channel link > │ +│ Owners > │ +├────────────────────────────────────────┤ +│ Welcome message > │ +├────────────────────────────────────────┤ +│ Chat theme > │ +│ Delete messages after > │ +├────────────────────────────────────────┤ +│ Chat relays > │ +│ Clear chat │ +│ Leave channel │ +└────────────────────────────────────────┘ +``` + +Differences from owner view: +- **Owners & subscribers**: replaced with **Owners** +- **Edit channel profile**: hidden +- **Delete channel**: replaced with **Leave channel** + +#### Owners & subscribers sub-view + +Separate sub-view following familiar channel UI patterns from other messengers to increase adoption. + +**Owner's view** ("Owners & subscribers"): + +``` +┌────────────────────────────────────────┐ +│ < Back Owners & subscribers │ +├────────────────────────────────────────┤ +│ OWNERS │ +│ alice (you) > │ +├────────────────────────────────────────┤ +│ 150 SUBSCRIBERS │ +│ bob > │ +│ charlie > │ +│ ... │ +└────────────────────────────────────────┘ +``` + +**Subscriber's view** ("Owners"): + +``` +┌────────────────────────────────────────┐ +│ < Back Owners │ +├────────────────────────────────────────┤ +│ OWNERS │ +│ alice > │ +└────────────────────────────────────────┘ +``` + +> **Protocol note**: Correct subscriber and owner lists with counts must be implemented for MVP. This requires protocol changes to support relay-reported subscriber counts and subscriber list synchronization. See launch plan §3.3. + +#### Chat relays sub-view + +``` +┌────────────────────────────────────────┐ +│ < Back Chat relays │ +├────────────────────────────────────────┤ +│ relay1.simplex.im ● Active │ +│ relay2.simplex.im ● Active │ +│ relay3.simplex.im ● Active │ +│ │ +│ Chat relays forward messages to │ +│ channel subscribers. │ +└────────────────────────────────────────┘ +``` + +Read-only for MVP. In future, owner will be able to manage (add, remove) relays from this view. + +Relay statuses differ by role: +- **Owner**: based on `RelayStatus` — New, Invited, Accepted, Active +- **Subscriber**: based on connection state — Connecting, Connected, Error (TBC: new type or inferred from connection status) + +--- + +### 2.5 Chat Relay Management (Network & Servers) + +Chat relays follow the same placement pattern as SMP/XFTP servers: preset relays appear inside each operator page, custom relays appear in "Your servers" page. + +#### Operator page (e.g. SimpleX Chat) + +New "Chat relays" section added after "Operator" section, before message and file server sections: + +``` +┌────────────────────────────────────────┐ +│ < Back SimpleX Chat servers │ +├────────────────────────────────────────┤ +│ OPERATOR │ +│ ... │ +├────────────────────────────────────────┤ +│ CHAT RELAYS │ +│ relay1.simplex.im ✓ │ +│ relay2.simplex.im ✓ │ +│ relay3.simplex.im ✓ │ +│ │ +│ Chat relays forward messages in │ +│ channels you create. │ +├────────────────────────────────────────┤ +│ (message server sections) │ +│ (file server sections) │ +├────────────────────────────────────────┤ +│ Test servers │ +└────────────────────────────────────────┘ +``` + +#### Your servers page + +New "Chat relays" section before "Message servers": + +``` +┌────────────────────────────────────────┐ +│ < Back Your servers │ +├────────────────────────────────────────┤ +│ CHAT RELAYS │ +│ myrelay.example.com ✗ │ +│ │ +│ Chat relays forward messages in │ +│ channels you create. │ +├────────────────────────────────────────┤ +│ MESSAGE SERVERS │ +│ ... │ +├────────────────────────────────────────┤ +│ MEDIA & FILE SERVERS │ +│ ... │ +├────────────────────────────────────────┤ +│ Add server... │ +│ Test servers │ +│ How to use your servers > │ +└────────────────────────────────────────┘ +``` + +#### Relay detail view + +Follows `ProtocolServerView` pattern. Preset: read-only address + test + enable toggle. Custom: editable address + test + enable + delete. TBC editable name (present in backend). + +``` +┌────────────────────────────────────────┐ +│ < Back relay1.simplex.im │ +├────────────────────────────────────────┤ +│ RELAY ADDRESS │ +│ ┌──────────────────────────────────┐ │ +│ │ https://relay1.simplex.im/... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Test relay ✓ │ +│ Use for new channels [ON] │ +├────────────────────────────────────────┤ +│ Delete relay │ +└────────────────────────────────────────┘ +``` + +If all relays are disabled: footer warning "No chat relays enabled. Channels require at least one relay." + +--- + +### 2.6 Joining a Channel + +User taps channel link → pre-join view. + +#### Pre-join + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ 3 relays ▶ │ +│ ┌──────────────────────────────────┐ │ +│ │ Join channel │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +Relay count visible (from link data). Tapping "3 relays" expands to show relay hostnames. + +**Why:** Subscriber can decide whether to join based on which relays are used. + +#### Connecting + +After "Join channel", relay connections proceed. Progress bar shown above "you are subscriber" — channel already functions with even a single relay connected. + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ (chat area — welcome message etc.) │ +│ │ +├────────────────────────────────────────┤ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ Connecting... 1/3 relays │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +Tap progress label to expand: + +``` +├────────────────────────────────────────┤ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ ▼ Connecting... 1/3 relays │ +│ relay1.simplex.im ✓ Connected │ +│ relay2.simplex.im Connecting │ +│ relay3.simplex.im Connecting │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +All connected → progress bar disappears. + +#### Failure modes (inline) +- **Sync failure** (all relays fail on connect call): Alert "Failed to join channel" + Retry / Cancel. +- **Partial failure**: "2/3 relays connected, 1 failed". Channel works. Expanded view shows failed relay with red indicator. +- **All relays fail async**: Red error bar "Channel not connected". TBC: programmatic retry, or only failure indication. + +--- + +## 3. Implementation Order + +| # | Screen | Backend Dependency | Complexity | +|---|--------|--------------------|------------| +| 1 | Chat List — channel icon | None | Low | +| 2 | Channel Messages — `CIChannelRcv` rendering | None | Low | +| 3 | Owner Compose — "Broadcast" placeholder + `asGroup` | None | Low | +| 4 | Channel Info — extended `GroupChatInfoView` | Subscriber/owner lists: protocol changes (§3.3) | Medium | +| 5 | Chat Relay Management — Network & Servers | `APITestChatRelay` (launch plan §2.5) | Medium | +| 6 | Channel Creation — 3-step flow | Relay state events (launch plan §3.2) | High | +| 7 | Join Channel — progress bar + relay states | Relay state events (launch plan §3.2) | Medium | + +Items 1–3 have no backend blockers and can start immediately. Item 4 requires protocol changes for subscriber/owner lists and counts. Items 5–7 depend on backend work. diff --git a/plans/2026-03-05-members-conn-errors.md b/plans/2026-03-05-members-conn-errors.md new file mode 100644 index 0000000000..b63923771a --- /dev/null +++ b/plans/2026-03-05-members-conn-errors.md @@ -0,0 +1,316 @@ +# Save Permanent Connection Errors for Group Members + +## Context + +When a group member's connection handshake fails with a permanent error (e.g., `CONN NOT_ACCEPTED`, `SMP AUTH`, `AGENT A_VERSION`), the ERR event is logged to the UI event stream and discarded. The member record stays stuck in a "connecting" `GroupMemberStatus` (like `memIntroduced`, `memAccepted`) forever. Users see perpetual "connecting" with no explanation and no way to know whether to wait or re-invite. + +**Root cause**: `agentMsgConnStatus` (Subscriber.hs:376) only maps success events (`CONF`, `INFO`, `JOINED`, `CON`) to status transitions. The ERR handler for group members (Subscriber.hs:1054-1056) only logs to UI and completes the command — no status or error is persisted. + +## Solution Summary + +Add `ConnError {connError :: Text}` constructor to `ConnStatus`. Error text is encoded in the `conn_status TEXT` column as `"error "` via `TextEncoding`, and in JSON via `sumTypeJSON` (following `GSSError`/`CIFileStatus` pattern). No new DB column, no migration. When a non-temporary ERR arrives before connection is ready, transition to `ConnError` and notify UI. Messages are not queued for errored connections. + +## Technical Design + +### Error classification + +Use `temporaryOrHostError` from `Simplex.Messaging.Agent.Client` (simplexmq Client.hs:1486, exported at line 60): +- Returns `True` for NETWORK, TIMEOUT, HOST, TEVersion, INACTIVE, CRITICAL-with-restart → **do not save** +- Returns `False` for AUTH, CONN errors, VERSION, INTERNAL, etc. → **save as permanent error** + +Guard: only save when connection is not `ConnReady` and not already `ConnError`. Post-handshake errors (when `connStatus == ConnReady`) are handled by existing `processConnMERR` (AUTH counters, QUOTA counters). + +### Data flow + +``` +Agent ERR event + → Subscriber.hs processGroupMessage ERR handler + → guard: connStatus is not ConnReady, not ConnError, not temporaryOrHostError + → DB: UPDATE connections SET conn_status = 'error ' + → emit: CEvtGroupMemberUpdated user gInfo m m' + → iOS: upsertGroupMember updates model → UI re-renders +``` + +### DB encoding + +`conn_status TEXT NOT NULL` already exists. `ConnError` encodes as `"error " <> errText` using `TextEncoding` (same as `GSSError`). No migration needed — new text values are valid in the existing column. + +### JSON encoding + +Replace manual `ToJSON`/`FromJSON` instances with `$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)`. This follows the `GroupSndStatus`/`CIFileStatus` pattern — `sumTypeJSON` is already imported in Types.hs (line 60). + +JSON format (platform-dependent via `sumTypeJSON`): +- iOS: `{"error": {"connError": "SMP AUTH"}}` (ObjectWithSingleField) +- Android/Desktop: `{"type": "error", "connError": "SMP AUTH"}` (TaggedObject) +- Nullary cases: `{"ready": {}}` / `{"type": "ready"}` (not plain `"ready"` strings) + +Note: `ConnSndReady` JSON tag changes from `"snd-ready"` to `"sndReady"` (`dropPrefix "Conn"` applies `fstToLower`). This is safe — JSON is core→UI within same build. Swift auto-synthesis matches on `sndReady` case name. + +### Clear on recovery + +When CON event arrives, `agentMsgConnStatus` returns `Just ConnReady`, `updateConnStatus` overwrites `conn_status` to `"ready"`. Error is implicitly cleared — no special cleanup needed. + +### ConnStatus state machine update + +``` +Existing transitions (unchanged): + ConnNew → ConnRequested → ConnAccepted → ConnSndReady → ConnReady + ConnNew → ConnJoined → ConnSndReady → ConnReady + ConnPrepared → ConnJoined → ConnSndReady → ConnReady + Any → ConnDeleted + +New transitions: + Any pre-ready state → ConnError (on permanent ERR) + ConnError → ConnReady (on successful CON — recovery) + ConnError → ConnDeleted (on connection deletion) +``` + +### Pattern match safety audit + +Traced every ConnStatus pattern match across Haskell (10 files), Swift (6 files), Kotlin (3 files). + +**Must update (exhaustive matches):** + +| Location | Change | +|---|---| +| Types.hs textEncode/textDecode (~1703) | Add ConnError encoding/decoding | +| Types.hs ToJSON/FromJSON (~1696) | Replace with `sumTypeJSON` TH splice | +| Swift ConnStatus.initiated | Add `case .error: return nil` | +| Kotlin ConnStatus.initiated | Add `Error -> null` (follow-up) | + +**Must update (behavioral):** + +| Location | Current behavior | Fix | +|---|---|---| +| Internal.hs memberSendAction (line 2041) | ConnError falls to `otherwise -> pendingOrForwarded` — messages queued for permanently errored connections | Add pattern guard `ConnError {} <- connStatus -> Nothing` | + +**Verified safe — no changes needed:** + +| Pattern | Sites | Why safe | +|---|---|---| +| `== ConnReady` / `== ConnSndReady` | 12 sites (connReady, Contact.ready, GroupMember.ready, sndReady, readyMemberConn, xftpSndFileTransfer) | ConnError ≠ these → excluded from "ready" paths | +| `== ConnPrepared` | 8 sites (joinPreparedConn, nextConnectPrepared, isContactCard, contactRequestPlan) | ConnError ≠ ConnPrepared → doesn't trigger join/prepare logic | +| `== ConnNew` | 4 sites (contactConnInitiated, nextAcceptContactRequest, APIPrepareContact) | ConnError ≠ ConnNew → doesn't trigger new-connection logic | +| `!= ConnDeleted` (DB WHERE) | 6 sites (getConnectionEntity, *ConnsToSub) | ConnError ≠ ConnDeleted → errored connections remain findable and subscribable (correct — enables recovery via CON). **Add TODO comments** at each site to consider whether ConnError connections should be excluded. | +| `updateConnectionStatusFromTo` | 3 sites | Compares current to specific `fromStatus` — ConnError won't accidentally match | +| `readyMemberConn` (Internal.hs:2078) | 1 site | `connStatus == ConnReady \|\| == ConnSndReady` — ConnError → `otherwise = Nothing` (correct) | +| `connDisabled`/`connInactive` | 6 sites | Derived from error counters, not connStatus | +| `agentMsgConnStatus` | 1 site | Only produces ConnSndReady/ConnRequested/ConnReady — no ConnError output | + +## Implementation Plan + +### 1. Haskell: ConnStatus type + +**File: `src/Simplex/Chat/Types.hs`** + +**ConnStatus** (~line 1673): Add constructor after `ConnDeleted`: +```haskell + | ConnError {connError :: Text} +``` +Record syntax for `sumTypeJSON` field name in JSON. `deriving (Eq, Show, Read)` unchanged. + +**TextEncoding instance** (~line 1703) — for DB storage: +```haskell + textEncode = \case + ... + ConnError err -> "error " <> err + textDecode s + | Just err <- T.stripPrefix "error " s = Just (ConnError err) + | otherwise = case s of + "new" -> Just ConnNew + ... (existing cases unchanged) + _ -> Nothing +``` + +Note: `textDecode` changes from `\case` to named parameter `s` to support `stripPrefix` guard. + +**JSON instances** (~lines 1696-1701): Replace manual instances with TH splice: +```haskell +-- Remove: +-- instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus" +-- instance ToJSON ConnStatus where toJSON = J.String . textEncode; toEncoding = JE.text . textEncode +-- Add: +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus) +``` + +`sumTypeJSON` and `dropPrefix` already imported (line 60). `FromField`/`ToField` instances unchanged — still use `TextEncoding` for DB. + +**`connReady`** (line 1597): No change — `== ConnReady || == ConnSndReady`, `ConnError _` naturally returns `False`. + +### 2. Haskell: Subscriber.hs — save error on permanent ERR + +**File: `src/Simplex/Chat/Library/Subscriber.hs`** + +Extend existing import (line 74): +```haskell +import Simplex.Messaging.Agent.Client (temporaryOrHostError, getAgentWorker, ...) +``` + +Update ERR handler in `processGroupMessage` (line 1054-1056). Current: +```haskell +ERR err -> do + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () +``` + +New: +```haskell +ERR err -> do + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () + let Connection {connStatus = cs} = conn + case cs of + ConnReady -> pure () + ConnError _ -> pure () + _ | temporaryOrHostError err -> pure () + | otherwise -> do + let errText = tshow err + withStore' $ \db -> updateConnectionStatus db conn (ConnError errText) + let conn' = conn {connStatus = ConnError errText} + m' = m {activeConn = Just conn'} + toView $ CEvtGroupMemberUpdated user gInfo m m' +``` + +Note: `let Connection {connStatus = cs} = conn` destructures via pattern binding, avoiding ambiguous `connStatus conn` field selector under `DuplicateRecordFields`. + +No new store function — reuses existing `updateConnectionStatus` (Direct.hs:937) which calls `updateConnectionStatus_` → `textEncode` → stores `"error SMP AUTH"` in `conn_status`. + +### 3. Haskell: memberSendAction — don't queue for errored connections + +**File: `src/Simplex/Chat/Library/Internal.hs`** + +Update `memberSendAction` (line 2040-2044). Current: +```haskell + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | connInactive conn -> Just MSAPending + | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn + | otherwise -> pendingOrForwarded +``` + +Add pattern guard after first guard (can't use `==` with associated data): +```haskell + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | ConnError {} <- connStatus -> Nothing + | connInactive conn -> Just MSAPending + | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn + | otherwise -> pendingOrForwarded +``` + +### 4. Swift: ConnStatus enum + +**File: `apps/ios/SimpleXChat/ChatTypes.swift`** (~line 2301) + +Change from `String`-backed raw value enum to enum with associated value. Auto-synthesized `Decodable` handles `sumTypeJSON` format (same as `GroupSndStatus`, `CIFileStatus`): + +```swift +public enum ConnStatus: Decodable, Hashable { + case new + case prepared + case joined + case requested + case accepted + case sndReady + case ready + case deleted + case error(connError: String) + + var initiated: Bool? { + switch self { + case .new: return true + case .prepared: return false + case .joined: return false + case .requested: return true + case .accepted: return true + case .sndReady: return nil + case .ready: return nil + case .deleted: return nil + case .error: return nil + } + } +} +``` + +No custom `init(from:)` needed. `Hashable`/`Equatable` auto-synthesized. Existing equality checks like `connStatus == .ready` still compile (nullary cases). + +### 5. Swift: Connection computed property + +**File: `apps/ios/SimpleXChat/ChatTypes.swift`** + +Add computed property to `Connection` struct (~line 2092, after `connStatus`): +```swift +public var connError: String? { + if case let .error(err) = connStatus { return err } + return nil +} +``` + +### 6. Swift: Member list status + +**File: `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift`** + +Update `memberConnStatus` function (~line 457). Insert error check FIRST (before `connDisabled`/`connInactive`): +```swift +private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { + if case .error = member.activeConn?.connStatus { + return "connection error" + } else if member.activeConn?.connDisabled ?? false { + return "disabled" + } else if member.activeConn?.connInactive ?? false { + return "inactive" + } else { + return member.memberStatus.shortText + } +} +``` + +**File: `apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift`** + +Update `memberStatus` function (line 198). Insert error check FIRST (before `connDisabled` at line 199): +```swift + if case .error = member.activeConn?.connStatus { + return "connection error" + } else if member.activeConn?.connDisabled ?? false { +``` + +### 7. Swift: Member info error display + +**File: `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift`** + +Add error display section after the `connStats` section (~line 190): +```swift +if let connError = member.activeConn?.connError { + Section(header: Text("Connection error").foregroundColor(theme.colors.secondary)) { + Text(connError) + .foregroundColor(theme.colors.secondary) + .font(.callout) + .textSelection(.enabled) + } +} +``` + +## Files Changed Summary + +| Layer | File | Change | +|-------|------|--------| +| Core | `Types.hs` | Add `ConnError {connError :: Text}` to ConnStatus, update TextEncoding, replace JSON with `sumTypeJSON` TH splice | +| Logic | `Subscriber.hs` | Import `temporaryOrHostError`, handle permanent ERR for group members | +| Logic | `Internal.hs` | Add `ConnError` guard to `memberSendAction` → return `Nothing` | +| iOS | `ChatTypes.swift` | ConnStatus: auto-synthesized Decodable with `.error(connError:)`, Connection: `connError` computed property | +| iOS | `GroupChatInfoView.swift` | Show "connection error" in `memberConnStatus` (first check) | +| iOS | `MemberSupportView.swift` | Show "connection error" in `memberStatus` (first check) | +| iOS | `GroupMemberInfoView.swift` | Show error description section | + +## Verification + +1. **Build Haskell**: `cabal build --ghc-options -O0` +2. **Build iOS**: Verify Swift compiles — existing `connStatus == .ready` comparisons still work (nullary cases) +3. **JSON format**: Verify `sumTypeJSON` output matches Swift auto-synthesis expectations (nullary: `{"ready": {}}`, error: `{"error": {"connError": "..."}}`) +4. **Backward compat**: New `"error ..."` values in `conn_status` only appear after code update. Old code cannot parse them (downgrade risk, same as any new enum value). +5. **Recovery**: CON event → `updateConnectionStatus_ ConnReady` → overwrites `"error ..."` with `"ready"` in DB +6. **memberSendAction**: Verify messages are NOT queued for ConnError connections + +## Out of Scope (immediate follow-up) + +**Kotlin/Android/Desktop**: `ConnStatus` enum in `ChatModel.kt:2640` needs custom serializer for `sumTypeJSON` format (TaggedObject: `{"type": "error", "connError": "..."}`) + `Connection` needs `connError` computed property + member status UI. Must be updated before Android/Desktop builds from this commit. Existing bug at `GroupChatInfoView.kt:883` (`connDisabled` checked twice, should be `connInactive` on second check). diff --git a/plans/2026-03-13-message-keys-forwarding.md b/plans/2026-03-13-message-keys-forwarding.md new file mode 100644 index 0000000000..d495b30201 --- /dev/null +++ b/plans/2026-03-13-message-keys-forwarding.md @@ -0,0 +1,473 @@ +# Plan: Signed Message Storage, Forwarding, and Verification + +## Context + +The protocol types for signatures exist (`MsgSignatures`, `MsgSigData`, `ChatBinding`), the parser handles `/`/`>`/`{` element prefixes, and `verifySig` checks signatures. What's missing: + +1. **Signing when sending** — members sign their messages before sending to the relay +2. **Signature storage** — persisting signatures alongside message content +3. **Signature forwarding** — relay preserves and forwards original signatures intact +4. **Binding correctness** — bindings aren't covered by signatures or validated +5. **Required signatures** — admin events must require valid signatures in relay groups +6. **Visibility** — expose signature verification status in chat items + +## Design + +### A. Binding: Reconstructed, Not Sent + +`CBGroup {groupRootKey, senderMemberId}` — both known to verifier from context. Replace with single-byte binding tag on wire. + +Wire: ` ()*` + +Signed payload (constructed by signer and verifier, not on wire): +``` +smpEncode 'G' <> smpEncode (groupRootKey, senderMemberId) <> jsonBody +``` + +The binding tag is separate from the binding-specific prefix. SMP tuple encoding is concatenation, so `smpEncode ('G', k, m) = smpEncode 'G' <> smpEncode (k, m)` — same bytes either way. + +### B. Signing Context — Data, Not Function + +A generic record carries key material and binding data for signing: + +```haskell +data MsgSigning = MsgSigning + { sigBindingTag :: BindingTag + , sigPrefix :: ByteString -- binding-specific, e.g. smpEncode (rootKey, memberId) + , sigPrivKey :: C.PrivateKeyEd25519 + } +``` + +`sigBindingTag` goes into `MsgSignatures` on the wire (tells verifier which binding to reconstruct). `sigPrefix` is the binding-specific bytes. The signing function combines: `smpEncode sigBindingTag <> sigPrefix <> jsonBody`. + +Group-specific constructor: +```haskell +groupMsgSigning :: GroupKeys -> GroupMember -> MsgSigning +groupMsgSigning GroupKeys {groupRootKey, memberPrivKey} GroupMember {memberId} = + MsgSigning BTGroup (smpEncode (groupRootPubKey groupRootKey, memberId)) memberPrivKey +``` + +For contacts in the future — different constructor, different binding tag, same `MsgSigning` record and same `createSndMessages` path. + +### C. Per-Event Signing Decision — Caller, Not Policy + +The decision of whether to sign each event lives with the caller, not inside `createSndMessages`. The caller provides `Maybe MsgSigning` per event: + +```haskell +createSndMessages :: (MsgEncodingI e, Traversable t) + => t (ConnOrGroupId, ChatMsgEvent e, Maybe MsgSigning) + -> CM' (t (Either ChatError SndMessage)) +``` + +In `sendGroupMessages_`: +```haskell +let signing evt = case groupKeys gInfo of + Just gk | requiresSignature (toCMEventTag evt) -> Just (groupMsgSigning gk (membership gInfo)) + _ -> Nothing + idsEvts = L.map (\evt -> (GroupId groupId, evt, signing evt)) events +``` + +`requiresSignature` is group policy — only roster-modifying events (`XGrpDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpMemDel`, `XGrpMemRole`, `XGrpMemRestrict`). Content is never signed (deniability). When contact signing is added, a different caller uses a different predicate — `createSndMessages` is mechanical. + +### D. Signature Storage — Persisted for History + +Signatures are persisted in `msg_sigs BLOB` column alongside `msg_body` in the same INSERT. One DB operation. + +**Why persist (not ephemeral):** History delivery needs original signatures. In relay groups, history is forwarded with signatures preserved. In non-relay groups (if signing is extended), own sent signatures must survive for delivery to new members. Persisting from the start avoids losing generality. + +`msg_body` remains unchanged (JSON, backward compatible). Content and authentication are orthogonal. + +### E. Signing Scope — Deniability vs Authentication + +Only roster-modifying messages are signed. Content messages (`XMsgNew` etc.) are NEVER signed. + +1. **Deniability** — signing content creates non-repudiable proof of authorship. Anyone with the message bytes could prove who wrote it. Antithetical to SimpleX's privacy model. + +2. **Threat model** — relay manipulation of content is detectable post-hoc via cross-relay consistency (multiple independent relays). Sufficient because content is not irreversible. Roster/profile changes are disruptive and irreversible (member removed, role changed, group deleted) — must be authenticated at processing time. + +### F. Symmetric Encoding + +```haskell +encodeMsgElement :: Maybe MsgSignatures -> ByteString -> ByteString +encodeMsgElement Nothing body = body +encodeMsgElement (Just sigs) body = "/" <> smpEncode sigs <> body +``` + +Dual of `elementP`'s `'/'`/`'{'` cases. Used by both send batcher (`batchMessages`) and forward batcher (`batchDeliveryTasks1`). No signing logic in any batcher — only structural encoding. + +### E. Delivery Tasks: `msgBody` not `chatMessage` + +`MessageDeliveryTask` carries `msgBody :: ByteString` (raw JSON from `msg_body`) + `msgSignatures_ :: Maybe MsgSignatures` — NOT `chatMessage :: ChatMessage 'Json`. + +**Why `msgBody` is sufficient:** +- All delivery task processing is structural — encode, batch, send. Content decisions happen at task CREATION time (in `processEvent`), not delivery time. +- `DJRelayRemoved` currently wraps `chatMessage` in JSON `XGrpMsgForward` — but should use binary encoding instead (same `>element` format as normal batching, just single-element). Binary encoding only needs raw bytes + signatures, not parsed ChatMessage. +- More general — works for any future message type without coupling to JSON. +- Eliminates a parse+re-encode cycle (raw bytes → ChatMessage → chatMsgToBody → bytes). + +### F. DJRelayRemoved: Binary Encoding + +Current: wraps chatMessage in JSON `XGrpMsgForward` event. New: produces binary batch with single `>/` element, same as normal forwarding. The receiver already handles binary forwarded elements through `elementP` → `xGrpMsgForward`. + +### G. Verification with Binding + +```haskell +verifySig gInfo GroupMember {memberPubKey = Just pk, memberId} + (Just MsgSigData {signatures = MsgSignatures {bindingTag = BTGroup, signatures}, signedBody}) + | Just gk <- groupKeys gInfo = + let binding = smpEncode ('G', groupRootPubKey (groupRootKey gk), memberId) + in all (\(MsgSignature KRMember sig) -> C.verify pk sig (binding <> signedBody)) signatures +verifySig _ _ _ = True +``` + +### H. Signature Enforcement + +**Must be signed** (reject if unsigned in relay groups with keys): +- `XGrpDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpMemDel`, `XGrpMemRole`, `XGrpMemRestrict` + +**Not signed** (deniability — see §E): +- `XMsgNew` and all other content events + +**Conditionally signed:** +- `XGrpMemNew` — not always signed because members/subscribers can join via chat relays. Signed when owners/admins add members directly. Enforcement is context-dependent (checks sender role, not just event tag). + +**Channel posts** (`FwdChannel`): validate if signed, strip before forwarding. + +### I. Expose in UI + +Two display paths in CLI: + +**Path 1: Chat item history** (also used by mobile UI) +- `CIMeta.msgSigned :: Bool` — set during chat item creation +- Flow: `VerifiedMsg` → `isJust signedMsg_` → `RcvMessage.msgSigned` → `createNewRcvChatItem` → `createNewChatItem_` (INSERT with `msg_signed`) → SELECT reads `msg_signed` → `mkCIMeta` → View.hs +- Migration: `ALTER TABLE chat_items ADD COLUMN msg_signed` (in `chat_relays` migration) +- Note: `RcvMessage` is a goner (see pending refactor). In future, `msgSigned` flows from `VerifiedMsg` directly. + +**Path 2: Immediate CLI events** (ChatEvent/ChatResponse) +- Receive events: add `Bool` to ChatEvent constructors that correspond to signed events + - `CEvtMemberRole` — XGrpMemRole + - `CEvtMemberBlockedForAll` — XGrpMemRestrict + - `CEvtDeletedMemberUser` — XGrpMemDel (self) + - `CEvtDeletedMember` — XGrpMemDel (other) + - `CEvtGroupDeleted` — XGrpDel + - `CEvtGroupUpdated` — XGrpInfo / XGrpPrefs +- Send responses: add `Bool` to ChatResponse constructors for send-side + - `CRMembersRoleUser` — APIMembersRole + - `CRMembersBlockedForAllUser` — APIBlockMembersForAll + - `CRUserDeletedMembers` — APIRemoveMembers + - `CRGroupDeletedUser` — APIDeleteChat (group) + - `CRGroupUpdated` — APIUpdateGroupProfile +- Source: receive `msgSigned` from `RcvMessage`; send from `useRelays' gInfo` +- View.hs: append " (signed)" to event text when Bool is True + +**Correlation: `requiresSignature` events ↔ CLI display** + +| Event | Receive ChatEvent | Send ChatResponse | +|-------|-------------------|-------------------| +| XGrpDel | CEvtGroupDeleted | CRGroupDeletedUser | +| XGrpInfo | CEvtGroupUpdated | CRGroupUpdated | +| XGrpPrefs | CEvtGroupUpdated | CRGroupUpdated | +| XGrpMemDel | CEvtDeletedMember[User] | CRUserDeletedMembers | +| XGrpMemRole | CEvtMemberRole | CRMembersRoleUser | +| XGrpMemRestrict | CEvtMemberBlockedForAll | CRMembersBlockedForAllUser | + +### J. Pending Refactor: Remove RcvMessage + +`RcvMessage` carries redundant fields (`msgBody`, `authorMember` never read; `chatMsgEvent`, `sharedMsgId_` derivable from `verifiedMsg`). Plan: +1. Remove `RcvMessage` type +2. `NewRcvMessage` = `verifiedMsg` + `brokerTs` + `forwardedByMember` (drop `chatMsgEvent`) +3. `createNewRcvMessage` returns just `msgId` +4. Consumers extract what they need from `verifiedMsg` already in scope + +## Implementation Steps + +### Step 1: Foundation — Types + Encoding + Storage Schema ✅ + +- `ChatBinding = CBGroup` with `Encoding` instance (was `BindingTag`) +- `MsgSignatures { chatBinding :: ChatBinding, signatures :: NonEmpty MsgSignature }` +- `MsgSigning { bindingTag, bindingData, keyRef, privKey }` — generic signing context record +- `encodeBatchElement` in `Batch.hs` (moved from Protocol.hs) +- `requiresSignature :: CMEventTag e -> Bool` +- Migration: `ALTER TABLE messages ADD COLUMN msg_sigs BLOB` +- `SndMessage` gains `msgSignatures_ :: Maybe MsgSignatures` +- `createNewRcvMessage`: already accepts and stores `Maybe MsgSignatures` + +### Step 2: Sign on Send + Verify with Binding ✅ + +- `groupMsgSigning :: GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning` in Internal.hs — takes GroupInfo, decides per-event +- `createSndMessages` takes `(ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent e)` triples +- `createNewSndMessage` accepts `Maybe MsgSigning`, signs inline, stores `msg_sigs` in same INSERT +- `batchMessages` encodes elements via `encodeBatchElement` (two parallel lists, encode once per message) +- `verifySig` in Subscriber.hs reconstructs binding prefix from `GroupInfo` + `memberId`, verifies with `C.verify` +- Removed dead code: `signGroupMessages`, `updateSndMsgSignatures`, `groupSignFn`, `signMsgBody` + +### Step 3: Store, Forward, Verify — End-to-End + +Steps 3-5 from the original plan are one flow. They must ship together because the e2e test — member A signs → relay stores → relay forwards → member B verifies — is the only meaningful test. + +#### Critical invariant: original bytes must be preserved + +JSON round-trip through aeson doesn't preserve key ordering. Currently `msg_body` is stored via `chatMsgToBody chatMsg` (re-encoded from parsed `ChatMessage`). These bytes may differ from what the sender signed. For signature verification after forwarding, the relay must store the **original** bytes in `msg_body`. + +When `elementP` parses a signed element (`/`), `A.match msgP` captures the exact JSON bytes as `signedBody` in `MsgSigData`. This is what must be stored as `msg_body` for signed messages. + +For unsigned messages, `chatMsgToBody chatMsg` is fine — no signature to preserve. + +#### E2E Flow + +``` +Member A Relay Member B +───────── ───── ──────── +sign(roster event) + ↓ +/ ──────────→ receive + parse (elementP) + msgSig_ has signedBody (exact bytes) + verify (withVerifiedSig) + store signedBody as msg_body ──(a) + store MsgSignatures as msg_sigs + ↓ + read msg_body + msg_sigs from DB ──(b) + >/ ──────→ receive + parse + elementP: > → / → json + msgSig_ has signedBody + verify (withVerifiedSig) + store signedBody + sigs ──(c) +``` + +#### (a) Relay receives signed message → stores with original bytes + +**Current call chain** (Subscriber.hs → Internal.hs → Store/Messages.hs): + +``` +processAChatMsg(line 920) — has msgSig_ (with signedBody), chatMsg + │ passes chatMsg only, msgSig_ not threaded + ▼ +processEvent(line 941) — has chatMsg only + │ body = chatMsgToBody chatMsg ← RE-ENCODES, loses original bytes + ▼ +saveGroupRcvMsg(Internal.hs:2218) — params: user, groupId, member, conn, msgMeta, body, chatMsg + │ no signature parameter + ▼ +createNewMessageAndRcvMsgDelivery(Store/Messages.hs:262) — no signature parameter + │ passes Nothing for msgSignatures_ + ▼ +createNewRcvMessage(Store/Messages.hs:294) — HAS Maybe MsgSignatures param, receives Nothing + │ + ▼ +INSERT INTO messages ... msg_body=RE-ENCODED, msg_sigs=Nothing +``` + +**Changes (6 functions):** + +1. **`processAChatMsg`** (Subscriber.hs:920→934): pass `msgSig_` to `processEvent` + - Current: `processEvent gInfo' m' chatMsg` + - New: `processEvent gInfo' m' chatMsg msgSig_` + +2. **`processEvent`** (Subscriber.hs:941): accept `Maybe MsgSigData`, use `signedBody` when signed + - Current sig: `GroupInfo -> GroupMember -> ChatMessage e -> CM (Maybe NewMessageDeliveryTask)` + - New sig: `GroupInfo -> GroupMember -> ChatMessage e -> Maybe MsgSigData -> CM (Maybe NewMessageDeliveryTask)` + - Current: `let body = chatMsgToBody chatMsg` + - New: `let body = maybe (chatMsgToBody chatMsg) signedBody msgSig_` + - Extract: `let sigs_ = signatures <$> msgSig_` (where `signatures :: MsgSigData -> MsgSignatures`) + - Pass both `body` and `sigs_` to `saveGroupRcvMsg` + +3. **`saveGroupRcvMsg`** (Internal.hs:2218): add `Maybe MsgSignatures` parameter + - Current sig: `User -> GroupId -> GroupMember -> Connection -> MsgMeta -> MsgBody -> ChatMessage e -> CM (...)` + - New sig: `User -> GroupId -> GroupMember -> Connection -> MsgMeta -> MsgBody -> ChatMessage e -> Maybe MsgSignatures -> CM (...)` + - Pass to `createNewMessageAndRcvMsgDelivery` + - 1 caller: Subscriber.hs:944 + +4. **`createNewMessageAndRcvMsgDelivery`** (Store/Messages.hs:262): add `Maybe MsgSignatures` parameter + - Current sig: `DB.Connection -> ConnOrGroupId -> NewRcvMessage e -> Maybe SharedMsgId -> RcvMsgDelivery -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage` + - New: add `Maybe MsgSignatures` after `Maybe SharedMsgId` + - Current: passes `Nothing` to `createNewRcvMessage` + - New: passes the received `Maybe MsgSignatures` + - 2 callers: `saveGroupRcvMsg` (Internal.hs:2226) and `saveDirectRcvMSG` (Internal.hs:2215) + - `saveDirectRcvMSG` passes `Nothing` (direct messages not signed yet) + +5. **`createNewRcvMessage`** (Store/Messages.hs:294): no change — already has `Maybe MsgSignatures` param + +After change: +``` +INSERT INTO messages ... msg_body=ORIGINAL_BYTES, msg_sigs=MsgSignatures +``` + +#### (b) Relay reads delivery tasks → forwards with preserved signatures + +**Current call chain** (Store/Delivery.hs → Delivery.hs → Batch.hs): + +``` +getMsgDeliveryTask_(Store/Delivery.hs:130) + │ SQL: SELECT ... msg.msg_body ... ← no msg_sigs + │ Row type: ... ChatMessage 'Json ... ← parsed via FromField, RE-ENCODES on read + ▼ +MessageDeliveryTask { chatMessage :: ChatMessage 'Json } (Delivery.hs:128) + ▼ +batchDeliveryTasks1(Batch.hs:73) + │ destructures: MessageDeliveryTask {taskId, fwdSender, brokerTs, chatMessage} + ▼ +encodeFwdElement(Batch.hs:96) — takes GrpMsgForward -> ChatMessage 'Json -> ByteString + │ ">" <> smpEncode fwd <> chatMsgToBody chatMessage ← RE-ENCODES AGAIN + ▼ +Wire: > ← signature would fail +``` + +**Changes (5 functions/types):** + +6. **`MessageDeliveryTask`** (Delivery.hs:128): replace `chatMessage` field + - Current: `chatMessage :: ChatMessage 'Json` + - New: `msgBody :: ByteString, msgSignatures_ :: Maybe MsgSignatures` + - `chatMessage` used only in 2 places: `batchDeliveryTasks1` (Batch.hs:86) and `DJRelayRemoved` (Subscriber.hs:3375) — both just encode, no content inspection + +7. **`MessageDeliveryTaskRow`** (Store/Delivery.hs:128): change column type + - Current: `... ChatMessage 'Json, BoolInt` + - New: `... DB.Binary, Maybe MsgSignatures, BoolInt` + +8. **`getMsgDeliveryTask_`** (Store/Delivery.hs:130): add `msg.msg_sigs` to SELECT + - Current SQL: `msg.msg_body, t.message_from_channel` + - New SQL: `msg.msg_body, msg.msg_sigs, t.message_from_channel` + - `toTask`: destructure `DB.Binary` as raw bytes, `Maybe MsgSignatures` from `msg_sigs` + +9. **`encodeFwdElement`** (Batch.hs:96): take raw bytes + signatures + - Current sig: `GrpMsgForward -> ChatMessage 'Json -> ByteString` + - New sig: `GrpMsgForward -> Maybe MsgSignatures -> ByteString -> ByteString` + - Body: `">" <> smpEncode fwd <> encodeBatchElement sigs_ msgBody` + +10. **`batchDeliveryTasks1`** (Batch.hs:73): use new task fields + - Current: `MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, chatMessage} = task` + - New: `MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, msgBody, msgSignatures_} = task` + - Current: `msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} chatMessage` + - New: `fwdBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} msgSignatures_ msgBody` + +After change: +``` +Wire: >/ ← signature valid +``` + +#### (c) Member receives forwarded message → stores with original bytes + +**Current call chain** (Subscriber.hs → Internal.hs → Store/Messages.hs): + +``` +xGrpMsgForward(Subscriber.hs:3159) — has chatMsg + msgSig_ (with signedBody) + ▼ +processForwardedMsg(Subscriber.hs:3172) — closure, has chatMsg, msgSig_ in scope but not used + │ body = chatMsgToBody chatMsg ← RE-ENCODES + ▼ +saveGroupFwdRcvMsg(Internal.hs:2237) — no signature parameter + │ passes Nothing to createNewRcvMessage + ▼ +createNewRcvMessage(Store/Messages.hs:294) — receives Nothing + ▼ +INSERT INTO messages ... msg_body=RE-ENCODED, msg_sigs=Nothing +``` + +**Changes (3 functions):** + +11. **`processForwardedMsg`** (Subscriber.hs:3172): use `signedBody` when signed, pass sigs + - `msgSig_` is in scope from `xGrpMsgForward` closure + - Current: `let body = chatMsgToBody chatMsg` + - New: `let body = maybe (chatMsgToBody chatMsg) signedBody msgSig_` + - Extract: `let sigs_ = signatures <$> msgSig_` + - Pass `sigs_` to `saveGroupFwdRcvMsg` + +12. **`saveGroupFwdRcvMsg`** (Internal.hs:2237): add `Maybe MsgSignatures` parameter + - Current sig: `User -> GroupInfo -> GroupMember -> Maybe GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage)` + - New: add `Maybe MsgSignatures` after `UTCTime` + - Current: passes `Nothing` to `createNewRcvMessage` + - New: passes the received `Maybe MsgSignatures` + - 1 caller: Subscriber.hs:3175 + +13. **`createNewRcvMessage`**: no change — already has param + +After change: +``` +INSERT INTO messages ... msg_body=ORIGINAL_BYTES, msg_sigs=MsgSignatures +``` + +#### (d) DJRelayRemoved — binary encoding + +**Current** (Subscriber.hs:3371-3382): +```haskell +let MessageDeliveryTask {senderGMId, fwdSender, brokerTs = fwdBrokerTs, chatMessage} = task + fwdEvt = XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} chatMessage ← JSON wrapping + cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} + body = chatMsgToBody cm ← RE-ENCODES +createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body +``` + +**Change** (1 function, same location): + +14. **DJRelayRemoved handler** (Subscriber.hs:3374): use binary encoding + ```haskell + let MessageDeliveryTask {senderGMId, fwdSender, brokerTs = fwdBrokerTs, msgBody, msgSignatures_} = task + fwd = GrpMsgForward {fwdSender, fwdBrokerTs} + body = encodeBinaryBatch [encodeFwdElement fwd msgSignatures_ msgBody] + createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + ``` + Receiver handles via `elementP` → same path as batched forwarding. + +#### (e) Enforcement — required signatures + +**Current**: `withVerifiedSig` (Subscriber.hs:3203) calls `verifySig` which returns `True` for `Nothing` (unsigned). All unsigned messages pass. + +**Change** (1 function): + +15. **`withVerifiedSig`** (Subscriber.hs:3203): add unsigned rejection + - Needs the event tag to check `requiresSignature` + - Current sig: `GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Maybe MsgSigData -> UTCTime -> CM a -> CM (Maybe a)` + - New: add `CMEventTag e` parameter, or pass from caller + - Logic: if `isNothing msgSig_` AND `groupKeys gInfo` is `Just` AND `requiresSignature tag` → reject + +#### (f) Channel stripping + +**Current** (Subscriber.hs:3169): `FwdChannel -> processForwardedMsg Nothing` — skips `withVerifiedSig` entirely. + +**Change** (in `xGrpMsgForward`): + +16. For `FwdChannel`: validate signature if present (call `verifySig`), then call `processForwardedMsg` with `msgSig_` replaced by `Nothing` — strips signatures before storage. Channel posts are anonymous; storing the author's signature would leak identity. + +#### Summary: 16 function changes + +| # | Function | File | Change | +|---|----------|------|--------| +| 1 | `processAChatMsg` | Subscriber.hs:920 | Pass `msgSig_` to `processEvent` | +| 2 | `processEvent` | Subscriber.hs:941 | Accept `Maybe MsgSigData`, use `signedBody` as body when signed | +| 3 | `saveGroupRcvMsg` | Internal.hs:2218 | Add `Maybe MsgSignatures` parameter (1 caller) | +| 4 | `createNewMessageAndRcvMsgDelivery` | Store/Messages.hs:262 | Add `Maybe MsgSignatures` parameter (2 callers: group passes sigs, direct passes Nothing) | +| 5 | `createNewRcvMessage` | Store/Messages.hs:294 | No change — already has param | +| 6 | `MessageDeliveryTask` | Delivery.hs:128 | `msgBody :: ByteString` + `msgSignatures_` instead of `chatMessage` | +| 7 | `MessageDeliveryTaskRow` | Store/Delivery.hs:128 | `DB.Binary` + `Maybe MsgSignatures` instead of `ChatMessage 'Json` | +| 8 | `getMsgDeliveryTask_` | Store/Delivery.hs:130 | Add `msg.msg_sigs` to SELECT, read `msg_body` as raw bytes | +| 9 | `encodeFwdElement` | Batch.hs:96 | `GrpMsgForward -> Maybe MsgSignatures -> ByteString -> ByteString` | +| 10 | `batchDeliveryTasks1` | Batch.hs:73 | Use task's `msgBody` + `msgSignatures_` | +| 11 | `processForwardedMsg` | Subscriber.hs:3172 | Use `signedBody` as body when signed, pass sigs | +| 12 | `saveGroupFwdRcvMsg` | Internal.hs:2237 | Add `Maybe MsgSignatures` parameter (1 caller) | +| 13 | `createNewRcvMessage` | Store/Messages.hs:294 | No change — already has param | +| 14 | DJRelayRemoved handler | Subscriber.hs:3374 | Binary encoding with `encodeFwdElement` | +| 15 | `withVerifiedSig` | Subscriber.hs:3203 | Reject unsigned messages when `requiresSignature` in relay group with keys | +| 16 | `xGrpMsgForward` FwdChannel | Subscriber.hs:3169 | Validate sig if present, strip before storage | + +#### Test + +E2E test in relay group with keys: +1. Member A sends `XGrpMemRole` (requires signature) → signed in DB on A +2. Relay receives → verifies → stores `signedBody` as `msg_body` + `MsgSignatures` as `msg_sigs` +3. Relay reads `msg_body` + `msg_sigs` from DB → `>/` on wire +4. Member B receives → `elementP` parses >→/→json → `signedBody` has original bytes → verifies → stores +5. Unsigned `XGrpDel` from member without keys → rejected by enforcement +6. Channel post with signature → signature stripped before storage + +## Files + +| File | Step | Changes | +|------|------|---------| +| `Protocol.hs` | 1,2 | `ChatBinding`, `MsgSignatures` encoding, `MsgSigning`, `requiresSignature` | +| `Messages.hs` | 1 | `SndMessage` + `msgSignatures_` | +| `Store/Messages.hs` | 1,2,3 | `createNewSndMessage` signs + stores; `createNewRcvMessage` already has sig param; `createNewMessageAndRcvMsgDelivery` add sig param | +| Migration | 1 | `msg_sigs` column | +| `Internal.hs` | 2,3 | `groupMsgSigning`; `createSndMessages` per-event signing; `saveGroupRcvMsg` + `saveGroupFwdRcvMsg` add sig params | +| `Batch.hs` | 2,3 | `encodeBatchElement` in `batchMessages`; `encodeFwdElement` takes sigs + raw bytes; `batchDeliveryTasks1` uses raw task fields | +| `Subscriber.hs` | 2,3 | `verifySig` with binding; `processAChatMsg`→`processEvent` thread `msgSig_`; `processForwardedMsg` use `signedBody`; `withVerifiedSig` enforcement; channel strip; DJRelayRemoved binary | +| `Delivery.hs` | 3 | `MessageDeliveryTask`: `msgBody` + `msgSignatures_` instead of `chatMessage` | +| `Store/Delivery.hs` | 3 | `MessageDeliveryTaskRow` + `getMsgDeliveryTask_`: read `msg_sigs` + raw `msg_body` | diff --git a/plans/2026-03-21-text-size-markdown.md b/plans/2026-03-21-text-size-markdown.md new file mode 100644 index 0000000000..15389a2c1b --- /dev/null +++ b/plans/2026-03-21-text-size-markdown.md @@ -0,0 +1,66 @@ +# Small Text Markdown + +Add `!- text!` syntax for small gray text — legal disclaimers, secondary commentary, LLM reasoning, etc. + +## Syntax + +`!- text!` — renders as small gray text. Uses the `!` style prefix family, `-` for "reduced." + +On old clients: `!- fine print!` shows as-is (old `coloredP` fails on `-`, falls to `wordP`). Readable. + +## Changes + +### Haskell — `src/Simplex/Chat/Markdown.hs` + +1. **`Format`**: add `Small` constructor (no fields). + +2. **`coloredP` parser**: before trying `colorP`, check for `-` followed by space. If matched, produce `Small`. Otherwise fall through to existing color parsing. + +3. **`markdownText`**: add `Small` case, reconstruct as `!- text!`. + +4. **JSON serialization**: TH-derived `ToJSON`/`FromJSON` via existing `sumTypeJSON fstToLower`. Produces `{"small": {}}`. Old Haskell `FromJSON Format` falls to `Unknown` via `<|> pure (Unknown v)`. + +### Haskell — `src/Simplex/Chat/Styled.hs` + +5. **`sgr`**: add `Small` case — map to `FaintIntensity` for terminal rendering. + +### Haskell — `tests/MarkdownTests.hs` + +6. Tests for: + - `!- text!` parses as `Small` + - `!- text!` with leading/trailing spaces in content → no format (same rule as other formats) + - Existing color syntax unchanged + - `markdownText` round-trip + +### iOS — `apps/ios/SimpleXChat/ChatTypes.swift` + +7. **`Format`** enum: add `case small`. + +### iOS — `apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift` + +8. **`messageText`**: render `Small` with smaller `UIFont` point size + gray color. + +### Android — `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` + +9. **`Format`**: add `@Serializable @SerialName("small") class Small: Format()`. +10. **`Format.style`**: `SpanStyle` with smaller font size + gray color. + +### Android — `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt` + +11. **`MarkdownText`**: add `is Format.Small` case — same pattern as `Bold`/`Italic` (apply style, append text). + +## Backward Compatibility + +### Local (old app receiving message with new syntax) +- Old app's bundled Haskell parses raw message text. Old `coloredP` doesn't know `-`, fails, falls to `wordP`. Text shows as `!- fine print!` — plain text with delimiters. + +### Remote desktop (old desktop, new mobile) +- New mobile Haskell parses `!- text!` as `Small`, serializes to JSON `{"small": {}}`. +- Old desktop Haskell re-parses JSON via `J.parseJSON` (`Remote/Protocol.hs:184`). Old `FromJSON Format` doesn't know `"small"` → `<|> pure (Unknown v)`. +- `Unknown` re-serializes to `{"type": "unknown", "json": ...}` → Kotlin `Format.Unknown` (`ignoreUnknownKeys` drops extra fields). Text renders without formatting. + +## Order of Implementation + +1. Haskell types + parser + tests +2. iOS types + rendering +3. Android types + rendering diff --git a/plans/2026-03-29-desktop-text-selection.md b/plans/2026-03-29-desktop-text-selection.md new file mode 100644 index 0000000000..888000ab4c --- /dev/null +++ b/plans/2026-03-29-desktop-text-selection.md @@ -0,0 +1,432 @@ +# Desktop Text Selection Plan + +## Goal +Cross-message text selection on desktop (Compose Multiplatform): +1. Click+drag to select message text, with auto-scroll +2. Only message text is selectable (no timestamps, names, quotes, dates — like Telegram web) +3. Ctrl+C and copy button +4. Selection persists across scroll + +## Architecture + +### Selection State + +Selection is two endpoints in the item list: + +```kotlin +data class SelectionRange( + val startIndex: Int, // anchor — where drag began, immutable during drag + val startOffset: Int, // character offset within anchor item + val endIndex: Int, // focus — where pointer is now + val endOffset: Int // character offset within focus item +) +``` + +```kotlin +enum class SelectionState { Idle, Selecting, Selected } +``` + +SelectionManager holds: +```kotlin +var selectionState: SelectionState // mutableStateOf +var range: SelectionRange? // mutableStateOf, null in Idle +var focusWindowY by mutableStateOf(0f) // pointer Y in window coords +var focusWindowX by mutableStateOf(0f) // pointer X in window coords +``` + +No captured map. No eager text extraction. +Indices are stable across scroll. Text extracted at copy time from live data. + +### State Machine + +``` + drag threshold + Idle ─────────────────→ Selecting + ↑ │ + │ click │ pointer up + │ ▼ + ←──────────────────── Selected +``` + +### Pointer Handler (on LazyColumn Modifier) + +`SelectionHandler` composable (BoxScope extension) returns a Modifier for +LazyColumnWithScrollBar. Contains `pointerInput`, `onGloballyPositioned`, +`focusRequester`, `focusable`, `onKeyEvent`. + +On every pointer move during Selecting: +1. Updates `focusWindowY/X` +2. Uses `listState.layoutInfo.visibleItemsInfo` to find item at pointer Y → updates `range.endIndex` + +Index resolution uses LazyListState directly — no map, no registration. + +### Pointer Handler Behavior Per State + +Non-press events (hover, scroll) skipped: `return@awaitEachGesture`. +State captured at gesture start (`wasSelected`). + +**Idle**: Down not consumed. Links/menus work. Drag threshold → Selecting. +**Selecting**: Pointer move → update focusWindowY/X, resolve endIndex via listState. + Pointer up → Selected. +**Selected**: Down consumed (prevents link activation). Click → Idle. Drag → new Selecting. + +### Anchor Char Offset Resolution + +The anchor item knows it's the anchor: `range.startIndex == myIndex`. +Resolves char offset ONCE at selection start via LaunchedEffect: + +```kotlin +val isAnchor = remember(myIndex) { + derivedStateOf { manager.range?.startIndex == myIndex && manager.selectionState == SelectionState.Selecting } +} +LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + val bounds = boundsState.value ?: return@LaunchedEffect + val layout = layoutResultState.value ?: return@LaunchedEffect + val offset = layout.getOffsetForPosition( + Offset(manager.focusWindowX - bounds.left, manager.focusWindowY - bounds.top) + ) + manager.setAnchorOffset(offset) +} +``` + +Fires once. No ongoing effect. + +### Focus Char Offset Resolution + +The focus item knows it's the focus: `range.endIndex == myIndex`. +Resolves char offset on every pointer move via snapshotFlow: + +```kotlin +val isFocus = remember(myIndex) { + derivedStateOf { manager.range?.endIndex == myIndex && manager.selectionState == SelectionState.Selecting } +} +if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { manager.focusWindowY to manager.focusWindowX } + .collect { (py, px) -> + val bounds = boundsState.value ?: return@collect + val layout = layoutResultState.value ?: return@collect + val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) + manager.updateFocusOffset(offset) + } + } +} +``` + +- Starts when item becomes focus, cancels when focus moves to different item +- snapshotFlow fires on pointer move, but only in ONE item +- Uses item's own local TextLayoutResult — no shared map + +### Highlight Rendering (Per Item) + +Each item computes highlight via derivedStateOf: + +```kotlin +val highlightRange = remember(myIndex) { + derivedStateOf { highlightedRange(manager.range, myIndex) } +} +``` + +`highlightedRange` is a standalone function: +```kotlin +fun highlightedRange(range: SelectionRange?, index: Int): IntRange? { + val r = range ?: return null + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + if (index < lo || index > hi) return null + val forward = r.startIndex <= r.endIndex + val startOff = if (forward) r.startOffset else r.endOffset + val endOff = if (forward) r.endOffset else r.startOffset + return when { + index == lo && index == hi -> minOf(startOff, endOff) until maxOf(startOff, endOff) + index == lo -> startOff until Int.MAX_VALUE // clamped by MarkdownText + index == hi -> 0 until endOff + else -> 0 until Int.MAX_VALUE // clamped by MarkdownText + } +} +``` + +derivedStateOf only triggers recomposition when the RESULT changes for this item. +Middle items don't recompose as range extends. Only boundary items recompose. + +### Highlight Drawing + +`getPathForRange(range.first, range.last + 1)` in `drawBehind` on BasicText. +`range.last + 1` because IntRange.last is inclusive, getPathForRange end is exclusive. + +Gated on `selectionRange != null`: +- When null (Android, or desktop without selection): original `Text()` used, no drawBehind. +- When non-null: `SelectableText` (BasicText + drawBehind + onTextLayout) or + `ClickableText` with added drawBehind. + +### Reserve Space Exclusion + +MarkdownText's `buildAnnotatedString` appends invisible reserve text after message +content. A local `var selectableEnd` is set to `this.length` inside `buildAnnotatedString` +right before reserve is appended. Used to clamp `selectionRange` before passing +downstream to rendering: + +```kotlin +var selectableEnd = 0 +val annotatedText = buildAnnotatedString { + // ... content ... + selectableEnd = this.length + // ... typing indicator, reserve ... +} +val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } +// pass clampedRange to ClickableText/SelectableText +``` + +`selectableEnd` is local to MarkdownText. Not passed upstream. +`highlightedRange` uses `Int.MAX_VALUE` for open-ended ranges; +MarkdownText resolves them to the actual content boundary. + +### Copy + +#### `displayText` function + +Non-composable function placed right next to MarkdownText in TextItemView.kt. +Computes the displayed text from `formattedText`, handling only the few Format +types that change the displayed string. All other formats use `ft.text` unchanged. +Used only at copy time. + +```kotlin +// Must be coordinated with MarkdownText — same text transformations for: +// Mention, HyperLink, SimplexLink, Command +fun displayText( + ci: ChatItem, + linkMode: SimplexLinkMode, + sendCommandMsg: Boolean +): String { + val formattedText = ci.formattedText + if (formattedText == null) return ci.text + return formattedText.joinToString("") { ft -> + when (ft.format) { + is Format.Mention -> { /* resolve display name from ci.mentions */ } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { /* showText or description + viaHosts */ } + is Format.Command -> if (sendCommandMsg) "/${ft.format.commandStr}" else ft.text + else -> ft.text + } + } +} +``` + +MarkdownText gets a corresponding comment noting these transformations must match. + +#### Copy text extraction + +On SelectionManager: +```kotlin +fun getSelectedText(items: List, linkMode: SimplexLinkMode): String { + val r = range ?: return "" + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + val forward = r.startIndex <= r.endIndex + val startOff = if (forward) r.startOffset else r.endOffset + val endOff = if (forward) r.endOffset else r.startOffset + return (lo..hi).mapNotNull { idx -> + val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + val text = displayText(ci, linkMode, sendCommandMsg = false) + when { + idx == lo && idx == hi -> text.substring( + startOff.coerceAtMost(text.length), + endOff.coerceAtMost(text.length) + ) + idx == lo -> text.substring(startOff.coerceAtMost(text.length)) + idx == hi -> text.substring(0, endOff.coerceAtMost(text.length)) + else -> text + } + }.joinToString("\n") +} +``` + +### Auto-Scroll + +Direction-aware: only the edge you're dragging toward. +After `scrollBy()`, re-resolve index from `listState.layoutInfo.visibleItemsInfo` +with same pointer Y. Different item may be under pointer → endIndex updates. +Indices don't shift on scroll. Focus item's snapshotFlow handles new charOffset. + +### Mouse Wheel During Drag + +Scroll event passes through to LazyColumn (not consumed by handler). +`snapshotFlow` on scroll offset fires → re-resolve index from listState → update endIndex. + +### Ctrl+C / Cmd+C + +`onKeyEvent` on LazyColumn modifier (inside SelectionHandler's returned Modifier). +Focus requested on selection start. When user taps compose box, focus moves there — +Ctrl+C goes to compose box handler. Copy button works regardless of focus. +Checks `isCtrlPressed || isMetaPressed`. + +### Copy Button + +Emitted by SelectionHandler in BoxScope. Visible in Selected state. +Copies without clearing. Click in chat clears selection. + +### Eviction Prevention + +`ChatItemsLoader.kt`: `allowedTrimming = !selectionActive` during selection. + +### Platform Gate + +All selection code gated on `appPlatform.isDesktop`. + +### Swipe-to-Reply + +Disabled on desktop: `if (appPlatform.isDesktop) Modifier else swipeableModifier`. + +### RTL Text + +`getOffsetForPosition` and `getPathForRange` are bidi-aware. No direction assumptions. + +--- + +## Effects Summary + +### Idle State +Zero effects. Items don't check anything. `range` is null. + +### Selecting State + +| What | Scope | Fires when | +|------|-------|-----------| +| Pointer event handling | LazyColumn pointerInput (total: 1) | Every pointer event | +| Index resolution | Pointer handler via listState (total: 1) | Every pointer move + scroll | +| Anchor char offset | Anchor item LaunchedEffect (1 item) | Once at selection start | +| Focus char offset | Focus item snapshotFlow (1 item) | Every pointer move | +| Highlight derivedStateOf | Per item (passive) | Only when result changes (~2 items) | +| Auto-scroll | Coroutine in pointer handler (total: 0 or 1) | Near edge during drag | +| Scroll re-evaluation | snapshotFlow on scroll offset (total: 1) | On scroll during drag | + +### Selected State +Zero effects. Frozen range. Items render highlight from derivedStateOf (no recomposition +unless range changes, which it doesn't in Selected state). + +--- + +## Changes From Master + +### NEW: TextSelection.kt + +New file: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt` + +Contains: +- `SelectionRange(startIndex, startOffset, endIndex, endOffset)` data class +- `SelectionState` enum (Idle, Selecting, Selected) +- `SelectionManager` — holds `selectionState`, `range`, `focusWindowY/X` (mutableStateOf), + methods: `startSelection`, `setAnchorOffset`, `updateFocusIndex`, `updateFocusOffset`, + `endSelection`, `clearSelection`, `getSelectedText(items, linkMode)` +- `highlightedRange(range, index)` standalone function +- `LocalSelectionManager` CompositionLocal +- `SelectionHandler` composable (BoxScope extension, returns Modifier for LazyColumn): + pointer input with state machine, auto-scroll, focus management, Ctrl+C/Cmd+C, copy button +- `SelectionCopyButton` composable +- `resolveIndexAtY` helper for pointer → item index via listState + +### TextItemView.kt + +**Add `displayText` function** right next to MarkdownText, with comment that it +must be coordinated with MarkdownText's text transformations. Takes `ChatItem`, +`linkMode`, `sendCommandMsg`. Used only by `getSelectedText` at copy time. + +**Add comment to MarkdownText** noting `displayText` must match its text transformations. + +**Add 2 parameters to MarkdownText**: +- `selectionRange: IntRange? = null` +- `onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null` + +**Inside MarkdownText** — local `var selectableEnd` set in both `buildAnnotatedString` +blocks (1 line each, right before typing indicator / reserve). Clamp selectionRange: +```kotlin +val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } +``` + +**Rendering** — gated on `clampedRange != null`: +- `Text()` call sites (2): `if (clampedRange != null) SelectableText(...) else Text(...)` + Original `Text(...)` call unchanged. +- `ClickableText` call: add `selectionRange = clampedRange`, + add `onTextLayout = { onTextLayoutResult?.invoke(it) }` + +**Add `selectionRange` parameter to `ClickableText`**, add `drawBehind` highlight +with `getPathForRange(range.first, range.last + 1)` before BasicText. + +**Add `SelectableText` private composable** — BasicText + drawBehind highlight + +onTextLayout. Used only when `selectionRange != null`. On Android, never reached. + +**MarkdownText is NOT restructured.** No code moved, no branches regrouped. + +### FramedItemView.kt — CIMarkdownText + +**Add `selectionIndex: Int = -1` parameter.** + +**Add** (gated on `selectionManager != null && selectionIndex >= 0 && !ci.meta.isLive`): +- `boundsState: MutableState` — from `onGloballyPositioned` on the Box +- `layoutResultState: MutableState` — from `onTextLayoutResult` +- `isAnchor` derivedStateOf + LaunchedEffect (resolves anchor offset once) +- `isFocus` derivedStateOf + LaunchedEffect with snapshotFlow (resolves focus offset) +- `highlightRange` via `derivedStateOf { highlightedRange(manager.range, selectionIndex) }` + +**MarkdownText call**: add `selectionRange = highlightRange`, +`onTextLayoutResult = { layoutResultState.value = it }` + +### EmojiItemView.kt + +**Add `selectionIndex: Int = -1` parameter.** + +**Add** (gated on `selectionManager != null && selectionIndex >= 0`): +- `isAnchor`/`isFocus` LaunchedEffects (full-selection only: offset 0 / emojiText.length) +- `isSelected` via `derivedStateOf { highlightedRange(manager.range, selectionIndex) != null }` +- Highlight via `Modifier.background(SelectionHighlightColor)` when selected + +### ChatView.kt + +- Create `SelectionManager`, provide via `LocalSelectionManager` +- `SelectionHandler` returns Modifier applied to LazyColumnWithScrollBar +- Pass `selectionIndex` from `itemsIndexed` through the call chain: + `ChatViewListItem` → `ChatItemViewShortHand` → `ChatItemView` (item/) → + `FramedItemView` → `CIMarkdownText`. Each gets `selectionIndex: Int = -1` param. +- Same for EmojiItemView path +- Gate SwipeToDismiss on desktop: `if (appPlatform.isDesktop) Modifier else swipeableModifier` +- Sync `selectionState != Idle` to `chatState.selectionActive` via LaunchedEffect + +### ChatItemsLoader.kt + +- `removeDuplicatesAndModifySplitsOnBeforePagination`: add `selectionActive: Boolean = false` param +- `allowedTrimming = !selectionActive` +- Call site passes `chatState.selectionActive` + +### ChatItemsMerger.kt + +- `ActiveChatState`: add `@Volatile var selectionActive: Boolean = false` + +### ChatModel.kt — no change + +### MarkdownHelpView.kt — no change + +--- + +## Testing + +1. Single message partial character selection +2. Multi-message selection with highlights +3. Direction reversal past anchor +4. Selection shrinks on reverse (items unhighlight) +5. Selection persists after drag end and across scroll +6. Auto-scroll extends selection correctly +7. Auto-scroll loads items from DB +8. Mouse wheel during drag extends selection +9. Items scrolling out and back in retain highlight +10. Click on links works (Idle state) +11. Click in chat clears selection (Selected state) +12. Right-click behavior +13. Ctrl+C / Cmd+C copies selected text +14. Copy button works +15. Highlight stops before invisible reserve space +16. Copy produces clean text +17. RTL text +18. Emoji-only messages +19. Live messages excluded +20. Edited messages during selection diff --git a/plans/2026-03-29-initial-open-last-unread-block.md b/plans/2026-03-29-initial-open-last-unread-block.md new file mode 100644 index 0000000000..034b57118b --- /dev/null +++ b/plans/2026-03-29-initial-open-last-unread-block.md @@ -0,0 +1,199 @@ +# Initial chat open: jump to last unread block + +## Problem + +When opening a chat with unread messages, the app always scrolls to the oldest unread message (`minUnreadItemId`). For casual group members with hundreds of unreads, this forces them to scroll through the entire backlog to reach new messages. + +The bottom circle scrolls to the latest messages without marking all as read (by design — moderators use this to reply quickly, then return to the top circle to read sequentially). But the next time the chat opens, it jumps back to the oldest unread. + +Users want the initial open to skip old unreads and land on the "new" ones — messages that arrived after their last interaction. + +## Design + +Change `CPInitial` to use a different pivot for `getDirectChatAround'` / `getGroupChatAround'`. + +Currently the pivot is `minUnreadItemId` (absolute first unread). Instead, try `maxViewedItemId` (last non-unread item in sort order) first. If not found, fall back to `minUnreadItemId`. The `getAround'` function is unchanged — it always loads `CRBefore`/`CRAfter` around the pivot and includes it. + +**maxViewedItemId**: the last item in sort order that is not `CISRcvNew`. +- "Viewed" means received read or sent — any `item_status != CISRcvNew`. + +### Why include works for both pivots + +`getDirectChatAround'` always includes the pivot in the result. When the pivot is maxViewed (a read item), including it adds one extra read item — harmless. The client scrolls to the first unread in the loaded items, which is the first item in `afterCIs` (the new unreads after the gap). The include/exclude distinction is unnecessary. + +### Sort order + +- Groups: `(item_ts, chat_item_id)` +- Direct chats: `(created_at, chat_item_id)` + +### Cases + +Items in display order (left = oldest/top, right = newest/bottom). U = unread (`CISRcvNew`), R = not unread (read, sent, event). + +**Case 1: Unreads contiguous from bottom, no gap** +``` +R...R U...U + ↑ maxViewed (last R) used as pivot +``` +`afterCIs` = the unreads. Same as current behavior. + +**Case 2: Gap, then new unreads at bottom** +``` +R...R U...U R...R U...U + ↑ maxViewed used as pivot +``` +`afterCIs` = new unreads only. Skips old unreads. This is the desired improvement. + +**Case 3: Gap at bottom, no new unreads** +``` +R...R U...U R...R + ↑ maxViewed used as pivot +``` +`afterCIs` = empty. Items loaded are the latest. Old unreads reachable via top circle. + +**Case 4: All unread** +``` +U...U +``` +maxViewed = NULL. Fall back to `minUnreadItemId` as pivot. Current behavior. + +**Case 5: No unreads** + +`maxViewedItemId` returns some item but `minUnreadItemId` returns `Nothing` — no unreads exist. Handled by stats showing zero unreads. `getAround'` loads items around maxViewed, which are the latest items. + +Actually: maxViewed is always found when items exist (every chat has at least sent items or read items unless case 4). So the flow is: maxViewed found → load around it → stats show 0 unreads → client shows latest items. + +### No UI changes needed + +Only `CPInitial` backend logic changes. The top circle, unread counter, unread separator, and all pagination continue to use `minUnreadItemId` as before. + +### Out-of-order delivery + +A late-arriving group message with old `item_ts` but recent `created_at` sorts into the old unread block in display order and gets skipped on initial open. This is acceptable — the top circle still reaches it. + +### Notes (local chat) + +Notes (`getLocalChatInitial_`) have unread handling in code but it's dead — all items are sent, `CISRcvNew` never occurs. No change needed. + +### Open concern + +In case 2, `CRBefore(maxViewed)` loads items before the gap, which may include old unreads. The client's scroll logic finds the first unread in loaded items (`lastIndex(where: hasUnread)` in reversed list), which could be an old unread from `beforeCIs` rather than a new unread from `afterCIs`. To be validated during testing — if problematic, may need client-side adjustment or limiting `beforeCIs` count. + +## Implementation + +### Files to modify + +`src/Simplex/Chat/Store/Messages.hs` — all changes are here. + +### New functions + +#### Direct chats + +```haskell +-- max viewed item: received read or sent (any item_status != CISRcvNew) +getContactMaxViewedItemId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId) +``` + +Query: +```sql +SELECT chat_item_id +FROM chat_items +WHERE user_id = ? AND contact_id = ? AND item_status != ? +ORDER BY created_at DESC, chat_item_id DESC +LIMIT 1 +``` + +#### Groups + +```haskell +-- max viewed item: received read or sent (any item_status != CISRcvNew) +getGroupMaxViewedItemId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) +``` + +Mirrors `queryUnreadGroupItems` structure but with `item_status != ?` instead of `item_status = ?`. Handles the same 4-case scope/content filter dispatch. New function `queryViewedGroupItems`. + +Query (for the no-scope, no-content-filter case): +```sql +SELECT chat_item_id +FROM chat_items +WHERE user_id = ? AND group_id = ? + AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL + AND item_status != ? +ORDER BY item_ts DESC, chat_item_id DESC +LIMIT 1 +``` + +### Modified functions + +#### `getDirectChatInitial_` + +Current: +```haskell +getDirectChatInitial_ db user ct contentFilter count = do + liftIO (getContactMinUnreadId_ db user ct) >>= \case + Just minUnreadItemId -> do + unreadCount <- liftIO $ getContactUnreadCount_ db user ct + let stats = emptyChatStats {unreadCount, minUnreadItemId} + getDirectChatAround' db user ct contentFilter minUnreadItemId count "" stats + Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count "" +``` + +New — only the pivot source changes, rest stays the same: +```haskell +getDirectChatInitial_ db user ct contentFilter count = do + liftIO (getContactMaxViewedItemId_ db user ct >>= maybe (getContactMinUnreadId_ db user ct) (pure . Just)) >>= \case + Just pivotId -> do + unreadCount <- liftIO $ getContactUnreadCount_ db user ct + minUnreadItemId <- fromMaybe 0 <$> liftIO (getContactMinUnreadId_ db user ct) + let stats = emptyChatStats {unreadCount, minUnreadItemId} + getDirectChatAround' db user ct contentFilter pivotId count "" stats + Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count "" +``` + +#### `getGroupChatInitial_` + +Same minimal change — only the pivot source: +```haskell +getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do + (getGroupMaxViewedItemId_ db user g scopeInfo_ contentFilter >>= maybe (getGroupMinUnreadId_ db user g scopeInfo_ contentFilter) (pure . Just)) >>= \case + Just pivotId -> do + stats <- getGroupStats_ db user g scopeInfo_ + getGroupChatAround' db user g scopeInfo_ contentFilter pivotId count "" stats + Nothing -> do + stats <- liftIO $ getStats 0 (0, 0) + (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g scopeInfo_ contentFilter count "" stats + where + getStats minUnreadItemId (unreadCount, unreadMentions) = do + reportsCount <- getGroupReportsCount_ db user g False + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} +``` + +### Summary of all affected functions + +| Function | Change | +|----------|--------| +| `getDirectChatInitial_` | Try maxViewed first, fall back to minUnread, same `getAround'` call | +| `getGroupChatInitial_` | Same | +| **New:** `getContactMaxViewedItemId_` | MAX non-unread by (created_at DESC, id DESC) | +| **New:** `getGroupMaxViewedItemId_` | MAX non-unread with scope/filter dispatch | +| **New:** `queryViewedGroupItems` | Like `queryUnreadGroupItems` but `item_status != ?` | + +Nothing else changes. `getDirectChatAround'`, `getGroupChatAround'`, `getDirectChatAround_`, `getGroupChatAround_`, `getContactMinUnreadId_`, `getGroupMinUnreadId_`, `getContactStats_`, `getGroupStats_`, `getChatItemIDs`, `NavigationInfo` computation, mark-read operations, UI code — all unchanged. + +### Performance + +- `maxViewedItemId` query: scans backward from the largest sort key, skipping unread items. Fast when there are recent read/sent items (the common case). Worst case: all items are unread — returns `Nothing` and falls back to minUnread. + +- All other queries are existing code, same performance. + +- Queries run only during `CPInitial` (chat open). No writes. + +### Testing + +1. Open chat with unreads, no prior interaction → same as current (case 1/4) +2. Open chat, jump to bottom (marks bottom screen read), close, reopen → lands on new unreads after the gap (case 2) +3. Open chat, jump to bottom, reply, close, new messages arrive, reopen → lands on new messages (case 2) +4. Open chat, jump to bottom, close, no new messages, reopen → loads around last viewed item at bottom (case 3) +5. Open chat, read from top (marks first screen read), close, reopen → lands on next unread after first screen, same as current (case 1) +6. Group with scope (member support chat) → same behavior with scope filter applied +7. Group with content filter (reports) → same behavior with content filter applied diff --git a/plans/2026-04-01-agent-sign-for-address.md b/plans/2026-04-01-agent-sign-for-address.md new file mode 100644 index 0000000000..c648574399 --- /dev/null +++ b/plans/2026-04-01-agent-sign-for-address.md @@ -0,0 +1,61 @@ +# Plan: Agent API — getConnLinkPrivKey + +**Date: 2026-04-01** + +## Context + +The chat relay test (`APITestChatRelay`) requires the relay to sign a challenge with its address private key (`ShortLinkCreds.linkPrivSigKey`). This key is stored in the agent's database on `RcvQueue` and is not accessible from the chat layer. A new agent API function is needed to retrieve it. + +The chat layer performs the signing itself with `C.sign'`. + +## API + +```haskell +getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) +``` + +- `ConnId` — the agent connection ID +- Returns — `Just linkPrivSigKey` if the connection has short link credentials, `Nothing` otherwise + +## Implementation + +**File: `simplexmq/src/Simplex/Messaging/Agent.hs`** + +1. Add to module exports: + ```haskell + getConnLinkPrivKey, + ``` + +2. Add public function (near `getConnShortLink`, ~line 427): + ```haskell + getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) + getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c + {-# INLINE getConnLinkPrivKey #-} + ``` + +3. Add implementation (near `deleteConnShortLink'`, ~line 1089): + ```haskell + getConnLinkPrivKey' :: AgentClient -> ConnId -> AM (Maybe C.PrivateKeyEd25519) + getConnLinkPrivKey' c connId = do + SomeConn _ conn <- withStore c (`getConn` connId) + pure $ case conn of + ContactConnection _ rq -> linkPrivSigKey <$> shortLink rq + RcvConnection _ rq -> linkPrivSigKey <$> shortLink rq + _ -> Nothing + ``` + +## Design notes + +- Local operation (no network IO) — synchronous, fast +- No `withConnLock` — this is a pure read with no mutations; the lock would add latency for no benefit. Read-only agent operations like `getConn` don't require the conn lock. +- Returns `Maybe` — `Nothing` if connection has no short link credentials or is wrong type +- Handles both `ContactConnection` and `RcvConnection` (both have `RcvQueue` with `shortLink` field, Store.hs:159) +- Chat layer signs: `C.sign' privKey challenge` +- `linkPrivSigKey :: C.PrivateKeyEd25519` on `ShortLinkCreds` (Protocol.hs:1456) +- `shortLink :: Maybe ShortLinkCreds` on `StoredRcvQueue` (Store.hs:159) + +## Verification + +```bash +cd simplexmq && cabal build --ghc-options=-O0 +``` diff --git a/plans/2026-04-01-test-chat-relay-plan.md b/plans/2026-04-01-test-chat-relay-plan.md new file mode 100644 index 0000000000..905f7962c1 --- /dev/null +++ b/plans/2026-04-01-test-chat-relay-plan.md @@ -0,0 +1,813 @@ +# Plan: APITestChatRelay — Relay Liveness + Identity Verification + +**Date: 2026-04-01** + +## Context + +Channel owners configure relays by address but have no way to verify a relay is alive, authentic, or to discover its profile before creating a channel. A broken or impersonated relay means a broken channel. + +`APITestChatRelay` solves this by: +1. Fetching the relay's short link data (validates SMP server reachability + retrieves relay profile) +2. Running a challenge-response handshake (`XGrpRelayTest`) that proves the relay controls its address private key (`linkPrivSigKey`) +3. Returning the relay profile and test result to the UI + +The test can run before any `chat_relays` DB record exists — the UI uses the returned profile to populate the relay name field. + +No DB schema changes are needed — `name` column remains in `chat_relays`. The Haskell type `UserChatRelay` changes from `name :: Text` to `relayProfile :: RelayProfile`, wrapping the same DB column. + +--- + +## Data Flow + +``` +Owner SMP Server Relay + | | | + |--- getShortLinkConnReq ----------->| | + |<-- FixedLinkData{rootKey,cReq} ----| | + | + ConnLinkData{RelayAddressLinkData{relayProfile}} | + | | | + |--- joinConnection(XGrpRelayTest{challenge}) ---------------------->| + | | REQ with challenge | + | | relay signs challenge | + | | with linkPrivSigKey | + |<-- CONF(XGrpRelayTest{signature}) ----------------------------------| + | verify: C.verify' rootKey sig challenge | + | cleanup connections on both sides | +``` + +--- + +## Types + +### RelayProfile (Protocol.hs) + +```haskell +data RelayProfile = RelayProfile {name :: ContactName} + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''RelayProfile) +``` + +Simpler than `Profile` — relay identity needs only a name. Can be extended later with image, description, etc. + +### RelayAddressLinkData (Protocol.hs) + +```haskell +data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile} + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''RelayAddressLinkData) +``` + +Stored as `userData` in the relay's contact address short link data. Separate from `ContactShortLinkData` (which has irrelevant `message`/`business` fields) and `RelayShortLinkData` (per-group relay links). + +### XGrpRelayTest (Protocol.hs) + +```haskell +XGrpRelayTest :: ByteString -> Maybe (C.Signature 'C.Ed25519) -> ChatMsgEvent 'Json +``` + +Single constructor used in both directions: +- **Owner → Relay** (in joinConnection connInfo): `XGrpRelayTest challenge Nothing` +- **Relay → Owner** (in acceptContact connInfo): `XGrpRelayTest challenge (Just signature)` + +The relay profile is NOT included — the owner already has it from `RelayAddressLinkData` in the short link's `userData` (retrieved in step 1 via `decodeLinkUserData`). + +JSON encoding (follows `(.=?)` chain pattern, e.g. `XGrpMemDel`): +```haskell +XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString . C.signatureBytes <$> sig_)) + ["challenge" .= B64UrlByteString challenge] +``` + +JSON parsing: +```haskell +XGrpRelayTest_ -> do + B64UrlByteString challenge <- v .: "challenge" + sig_ <- traverse decodeSig =<< opt "signature" + pure $ XGrpRelayTest challenge sig_ +``` + +Where `decodeSig` converts `B64UrlByteString` to `Parser (C.Signature 'C.Ed25519)` using `<$?>` (from `Simplex.Messaging.Util`, already imported in Protocol.hs): +```haskell +decodeSig :: B64UrlByteString -> JQ.Parser (C.Signature 'C.Ed25519) +decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s +``` + +`(<$?>) :: MonadFail m => (a -> Either String b) -> m a -> m b` — converts `Either` errors into `MonadFail` failures. `JQ.Parser` has `MonadFail`. + +Note: `B64UrlByteString` is defined in `Types.hs:151` — add import to Protocol.hs if not already imported. + +### RelayTestError (Controller.hs) + +```haskell +data RelayTestStep + = RTSGetLink -- fetching short link data from SMP server + | RTSDecodeLink -- decoding RelayAddressLinkData from link userData + | RTSConnect -- preparing and joining connection + | RTSWaitResponse -- waiting for relay's signed response + | RTSVerify -- verifying relay's signature + deriving (Show) + +data RelayTestFailure = RelayTestFailure + { rtfStep :: RelayTestStep, + rtfDescription :: String + } + deriving (Show) +``` + +Pattern follows `ProtocolTestFailure {testStep, testError}` from simplexmq. + +### RelayTest (Controller.hs) + +```haskell +data RelayTest = RelayTest + { challenge :: ByteString, + rootKey :: C.PublicKeyEd25519, + result :: TMVar (Maybe RelayTestFailure) + } +``` + +- `challenge` — random bytes sent to relay +- `rootKey` — from `FixedLinkData`, used to verify relay's signature +- `result` — `Nothing` = success, `Just failure` = error + +### UserChatRelay type change (Operators.hs) + +`UserChatRelay'` changes `name :: Text` to `relayProfile :: RelayProfile`: + +```haskell +data UserChatRelay' s = UserChatRelay + { chatRelayId :: DBEntityId' s, + address :: ShortLinkContact, + relayProfile :: RelayProfile, -- was: name :: Text + domains :: [Text], + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + deleted :: Bool + } +``` + +`relayProfile` is non-optional — always present: +- Before testing: user provides name → `RelayProfile {name = userProvidedName}` +- After testing: relay's actual profile replaces the user-provided one + +No DB migration needed — `name TEXT` column stays in `chat_relays`. The `RelayProfile` wrapper is applied at the Haskell read/write boundary: + +**Constructors:** +```haskell +-- newChatRelay_ (Operators.hs:341): name parameter wraps into RelayProfile +newChatRelay_ preset enabled name domains !address = + UserChatRelay {chatRelayId = DBNewEntity, address, relayProfile = RelayProfile {name}, domains, ...} +``` + +**DB reads** — `toChatRelay` (Profiles.hs:636) and `toGroupRelay` (Groups.hs:1337): wrap `name` column value: +```haskell +-- toChatRelay: name from DB → RelayProfile + UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {name}, domains = ..., ...} +``` + +**DB writes** — `insertChatRelay`, `updateChatRelay`, `undeleteRelay` (Profiles.hs): unwrap `RelayProfile` to get `name` for column: +```haskell +-- insertChatRelay: destructure relayProfile +insertChatRelay db User {userId} ts relay@UserChatRelay {address, relayProfile = RelayProfile {name}, ...} = do +``` + +**Validation** — `chatRelayErrs` (Operators.hs:546): uses `name` from `relayProfile` for duplicate checking: +```haskell +duplicateErrs_ (AUCR _ UserChatRelay {relayProfile = RelayProfile {name}, address}) = ... +allNames = map (\(AUCR _ UserChatRelay {relayProfile = RelayProfile {name}}) -> name) cRelays +``` + +**View** — `viewChatRelay` (View.hs:1581): uses `name` from `relayProfile`: +```haskell +viewChatRelay UserChatRelay {relayProfile = RelayProfile {name}, address, ...} = name <> ... +``` + +**`createRelayForOwner`** (Groups.hs:1342): uses `relayProfile` directly instead of `profileFromName name`: +```haskell +createRelayForOwner db vr gVar user gInfo UserChatRelay {relayProfile = RelayProfile {name}} = do + let memberProfile = profileFromName name + ... +``` + +**JSON** — `deriveJSON` on `UserChatRelay'` picks up the field rename automatically. The JSON changes from `"name": "bob"` to `"relayProfile": {"name": "bob"}`. Mobile apps need to update their model types accordingly. + +### ChatController field + +```haskell +chatRelayTests :: TMap ConnId RelayTest, +``` + +### ChatCommand + +```haskell +| APITestChatRelay UserId ShortLinkContact +| TestChatRelay ShortLinkContact +``` + +Takes a `ShortLinkContact` (`ConnShortLink 'CMContact`) — relay addresses are always short links. This matches `UserChatRelay.address :: ShortLinkContact` and is directly accepted by `getShortLinkConnReq :: ... -> ConnShortLink m -> ...`. + +### ChatResponse + +```haskell +| CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, testFailure :: Maybe RelayTestFailure} +``` + +- On success: `relayProfile = Just p, testFailure = Nothing` +- On failure at link fetch/decode: `relayProfile = Nothing, testFailure = Just err` (profile not yet available) +- On failure at connect/verify: `relayProfile = Just p, testFailure = Just err` (profile from link data) + +--- + +## Implementation + +### Phase 1: Protocol — XGrpRelayTest + RelayAddressLinkData + RelayProfile + +**File: `src/Simplex/Chat/Protocol.hs`** + +1. Add `RelayProfile` type (near `RelayShortLinkData`, ~line 1444): + - `data RelayProfile = RelayProfile {name :: ContactName}` + - `deriveJSON` + +2. Add `RelayAddressLinkData` type (after `RelayShortLinkData`): + - `data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile}` + - `deriveJSON` + +3. Add `XGrpRelayTest` constructor (after `XGrpRelayAcpt`, ~line 438): + - `XGrpRelayTest :: ByteString -> Maybe (C.Signature 'C.Ed25519) -> ChatMsgEvent 'Json` + +4. Add event tag `XGrpRelayTest_` (after `XGrpRelayAcpt_`, ~line 966) + +5. Add tag string `"x.grp.relay.test"` (after `"x.grp.relay.acpt"`, ~line 1022) + +6. Add tag parsing (after `XGrpRelayAcpt_` parse, ~line 1079) + +7. Add event-to-tag mapping (after `XGrpRelayAcpt` mapping, ~line 1132): + - `XGrpRelayTest {} -> XGrpRelayTest_` + +8. Add JSON parsing (~line 1284): + ```haskell + XGrpRelayTest_ -> do + B64UrlByteString challenge <- v .: "challenge" + sig_ <- traverse decodeSig =<< opt "signature" + pure $ XGrpRelayTest challenge sig_ + ``` + Where: + ```haskell + decodeSig :: B64UrlByteString -> JQ.Parser (C.Signature 'C.Ed25519) + decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s + ``` + +9. Add JSON encoding (~line 1351): + ```haskell + XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString . C.signatureBytes <$> sig_)) + ["challenge" .= B64UrlByteString challenge] + ``` + +### Phase 2: UserChatRelay type change + +**Files: `src/Simplex/Chat/Operators.hs`, `src/Simplex/Chat/Store/Profiles.hs`, `src/Simplex/Chat/Store/Groups.hs`, `src/Simplex/Chat/View.hs`** + +Change `UserChatRelay'` field `name :: Text` → `relayProfile :: RelayProfile` and update all 10 use sites as described in the Types section above. No DB migration — `name` column stays, `RelayProfile` wraps/unwraps at read/write boundary. + +### Phase 3: Controller types — RelayTest, RelayTestFailure, commands, response + +**File: `src/Simplex/Chat/Controller.hs`** + +1. Add `RelayTestStep` and `RelayTestFailure` types (near `ProtocolTestFailure` usage) + +2. Add `RelayTest` type + +3. Add `chatRelayTests :: TMap ConnId RelayTest` field to `ChatController` (after `relayRequestWorkers`, ~line 252) + +4. Uncomment and update `APITestChatRelay` (lines 401-403): + ```haskell + | APITestChatRelay UserId ShortLinkContact + | TestChatRelay ShortLinkContact + ``` + +5. Add `CRChatRelayTestResult` to `ChatResponse` (after `CRServerTestResult`, ~line 667): + ```haskell + | CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, testFailure :: Maybe RelayTestFailure} + ``` + +**File: `src/Simplex/Chat.hs`** + +6. Initialize `chatRelayTests` in `newChatController` (after `relayRequestWorkers`, ~line 175): + ```haskell + chatRelayTests <- TM.emptyIO + ``` + Add `chatRelayTests` to the record construction (~line 218). + +### Phase 4: Agent API — getConnLinkPrivKey (simplexmq change) + +The relay needs to sign the challenge with `ShortLinkCreds.linkPrivSigKey`, which is stored in the agent's DB on `RcvQueue`. The chat layer has no direct access to the key. + +**New agent API function in `simplexmq/src/Simplex/Messaging/Agent.hs`:** + +```haskell +getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) +``` + +Implementation: +1. Look up `SomeConn` by `ConnId` via `withStore c getConn` +2. Pattern match on `ContactConnection _ rq` or `RcvConnection _ rq` +3. Return `linkPrivSigKey <$> shortLink rq` (returns `Nothing` if no short link creds) + +The chat layer then signs: `C.sign' privKey challenge`. + +This is a local operation (no network IO), so it's synchronous. + +**Separate plan file:** `plans/agent-sign-for-address.md` + +### Phase 5: Commands.hs — APITestChatRelay handler + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Add `import System.Timeout (timeout)`. + +Add handler after `APITestProtoServer` (~line 1491): + +```haskell +APITestChatRelay userId address -> withUserId userId $ \user -> do + -- Step 1: Fetch link data (validates SMP server + gets profile) + let failAt step desc = pure $ CRChatRelayTestResult user Nothing (Just $ RelayTestFailure step desc) + r <- tryAllErrors $ getShortLinkConnReq nm user address + case r of + Left e -> failAt RTSGetLink (show e) + Right (FixedLinkData {rootKey, linkConnReq = cReq}, cData) -> do + -- Step 2: Decode relay profile from link data + relayProfile_ <- liftIO $ decodeLinkUserData cData + case relayProfile_ of + Nothing -> failAt RTSDecodeLink "no relay address link data" + Just RelayAddressLinkData {relayProfile} -> do + let failWithProfile step desc = + pure $ CRChatRelayTestResult user (Just relayProfile) (Just $ RelayTestFailure step desc) + -- Step 3: Generate challenge + prepare connection + gVar <- asks random + challenge <- liftIO $ atomically $ C.randomBytes 32 gVar + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> failWithProfile RTSConnect "invalid connection request" + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + conn@Connection {connId = dbConnId} <- withFastStore $ \db -> + createRelayTestConnection db vr user connId ConnPrepared chatV subMode + -- Register test in TMap + testVar <- newEmptyTMVarIO + let acId = aConnId conn + relayTest = RelayTest {challenge, rootKey, result = testVar} + chatRelayTests_ <- asks chatRelayTests + atomically $ TM.insert acId relayTest chatRelayTests_ + -- Join with challenge, wrapped in tryAllErrors for cleanup safety + testResult <- tryAllErrors $ do + dm <- encodeConnInfo $ XGrpRelayTest challenge Nothing + void $ withAgent $ \a -> joinConnection a nm (aUserId user) acId True cReq dm PQSupportOff subMode + liftIO $ timeout 40_000_000 $ atomically $ takeTMVar testVar + -- Cleanup always (even on error) + atomically $ TM.delete acId chatRelayTests_ + withFastStore' $ \db -> deleteConnectionRecord db user dbConnId + deleteAgentConnectionAsync acId + case testResult of + Left e -> failWithProfile RTSConnect (show e) + Right Nothing -> failWithProfile RTSWaitResponse "timeout" + Right (Just Nothing) -> pure $ CRChatRelayTestResult user (Just relayProfile) Nothing + Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) +TestChatRelay address -> withUser $ \User {userId} -> + processChatCommand vr nm $ APITestChatRelay userId address +``` + +Also add CLI parsing for `TestChatRelay` in the command parser. + +Key points: +- `address :: ShortLinkContact` — passes directly to `getShortLinkConnReq` (no type mismatch) +- `conn@Connection {connId = dbConnId}` — explicit pattern match avoids `DuplicateRecordFields` ambiguity +- `tryAllErrors` wraps only the join+wait block; cleanup runs unconditionally after it +- `tryAllErrors` (from `Simplex.Messaging.Util`) catches ALL exceptions via `UE.catch`, not just `ChatError` +- `void $ withAgent $ \a -> joinConnection ...` — discards `(SndQueueSecured, Maybe ClientServiceId)` return + +### Phase 6: Subscriber.hs — Event handlers + +**File: `src/Simplex/Chat/Library/Subscriber.hs`** + +#### Owner side: processDirectMessage CONF handler (contact_ = Nothing) + +Modify the CONF handler at lines 407-417. Before the existing flow, check if this connection is a relay test: + +```haskell +Nothing -> case agentMsg of + CONF confId pqSupport _ connInfo -> do + -- Check if this is a relay test connection + chatRelayTests_ <- asks chatRelayTests + relayTest_ <- atomically $ TM.lookup agentConnId chatRelayTests_ + case relayTest_ of + Just RelayTest {challenge, rootKey, result = testVar} -> do + -- Parse response + r <- tryAllErrors $ do + ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + case chatMsgEvent of + XGrpRelayTest _challenge sig_ -> + case sig_ of + Just sig + | C.verify' rootKey sig challenge -> + atomically $ putTMVar testVar Nothing -- success + | otherwise -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify "invalid signature") + Nothing -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify "no signature in response") + _ -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse "unexpected message type") + case r of + Left e -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse (show e)) + Right () -> pure () + Nothing -> do + -- Existing flow (unchanged) + conn' <- processCONFpqSupport conn pqSupport + (conn'', gInfo_) <- saveConnInfo conn' connInfo + ... +``` + +Note: `agentConnId` is in scope from the `processAgentMessageConn` closure (Subscriber.hs:354). + +#### Relay side: processContactConnMessage REQ handler + +Add `XGrpRelayTest` case after `XGrpRelayInv` at line 1247: + +```haskell +XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge +``` + +Add `xGrpRelayTest` function near `xGrpRelayInv` (~line 1450): + +```haskell +xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () +xGrpRelayTest invId chatVRange challenge = do + -- Retrieve private key from address connection's short link creds, sign in chat layer + privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) + case privKey_ of + Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address") + Just privKey -> do + let sig = C.sign' privKey challenge + msg = XGrpRelayTest challenge (Just sig) + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` chatVRange + void $ agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV +``` + +Note: `conn` is the user contact address connection (from `processContactConnMessage` closure). Its `aConnId` is the agent `ConnId` that holds `ShortLinkCreds` with `linkPrivSigKey`. The agent returns `Maybe` — `Nothing` if the connection has no short link credentials (shouldn't happen for a properly configured relay, but handled gracefully — owner will timeout with `RTSWaitResponse`). + +### Phase 7: Store — createRelayTestConnection + +**File: `src/Simplex/Chat/Store/Direct.hs`** + +Add function to create a ConnContact connection without entity: + +```haskell +createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + conn_chat_version, to_subscribe, pq_support, pq_encryption, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, 0 :: Int, connStatus, ConnContact) + :. (chatV, BI (subMode == SMOnlyCreate), PQSupportOff, PQSupportOff) + :. (currentTs, currentTs) + ) + connId <- liftIO $ insertedRowId db + getConnectionById db vr user connId +``` + +Pattern: same as `createRelayConnection` (Store/Groups.hs:1388) but `ConnContact` type with no `group_member_id`. + +The resulting row has `contact_id = NULL`, `contact_conn_initiated = 0` (column default), `xcontact_id = NULL`, `via_contact_uri = NULL`. This distinguishes it from `createConnReqConnection` rows which always set `contact_conn_initiated = 1`, `xcontact_id`, and `via_contact_uri`. + +### Phase 8: APICreateMyAddress — Use RelayAddressLinkData + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Update `APICreateMyAddress` (~line 2162-2176) for relay users: + +```haskell +-- Current code (line 2168-2169): +-- TODO [relays] relay: add relay profile, identity, key to link data? +let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing + +-- New code for relay users: +let userData = if isTrue userChatRelay + then encodeShortLinkData $ RelayAddressLinkData + { relayProfile = RelayProfile {name = displayName (fromLocalProfile $ profile' user)} + } + else contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing +``` + +### Phase 9: Test connection cleanup + +Test connections are `ConnContact` with no entity (`contact_id = NULL`). They should be cleaned up if the test API handler crashes or times out without cleanup. + +Add `cleanupStaleRelayTestConns` step to `cleanupUser` in `cleanupManager` (after `cleanupInProgressGroups`, ~line 4500): + +```haskell +cleanupStaleRelayTestConns user `catchAllErrors` eToView +liftIO $ threadDelay' stepDelay +``` + +Implementation: +```haskell +cleanupStaleRelayTestConns user = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-300) ts -- 5 minutes + staleConns <- withStore' $ \db -> getStaleRelayTestConns db user cutoffTs + forM_ staleConns $ \acId -> do + deleteAgentConnectionAsync acId + withStore' $ \db -> deleteConnectionByAgentConnId db user acId +``` + +Where `getStaleRelayTestConns` queries: +```sql +SELECT agent_conn_id FROM connections +WHERE user_id = ? AND conn_type = 'contact' AND contact_id IS NULL + AND conn_status = 'prepared' AND contact_conn_initiated = 0 + AND created_at < ? +``` + +This uniquely identifies stale test connections. The `contact_conn_initiated = 0` discriminator is critical because `createConnReqConnection` (Store/Direct.hs:164) also creates `ConnContact` rows with `contact_id = NULL` and `conn_status = ConnPrepared`, but it always sets `contact_conn_initiated = True` (line 175). Test connections from `createRelayTestConnection` inherit the column default of 0. + +**No new DB column needed.** + +### Phase 10: Views (iOS + Android/Desktop) + +**iOS:** +- `apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift` +- `apps/ios/Shared/Views/NewChat/AddChannelView.swift` + +**Android/Desktop:** +- `apps/multiplatform/.../ChatRelayView.kt` +- `apps/multiplatform/.../AddChannelView.kt` + +Changes: +1. Add "Test" button next to relay address that calls `APITestChatRelay address` +2. On success: show relay profile name, optionally auto-fill name field +3. On failure: show error description from `RelayTestFailure` +4. Show relay status indicator: untested / tested-ok / tested-failed + +### Phase 11: View — CRChatRelayTestResult + +**File: `src/Simplex/Chat/View.hs`** + +Add `CRChatRelayTestResult` case after `CRServerTestResult` (~line 127): + +```haskell +CRChatRelayTestResult u relayProfile_ testFailure_ -> ttyUser u $ viewRelayTestResult relayProfile_ testFailure_ +``` + +Add `viewRelayTestResult` function near `viewServerTestResult` (~line 1600): + +```haskell +viewRelayTestResult :: Maybe RelayProfile -> Maybe RelayTestFailure -> [StyledString] +viewRelayTestResult relayProfile_ = \case + Just RelayTestFailure {rtfStep, rtfDescription} -> + ["relay test failed at " <> plain (show rtfStep) <> ", error: " <> plain rtfDescription] + Nothing -> case relayProfile_ of + Just RelayProfile {name} -> ["relay test passed, profile: " <> plain (T.unpack name)] + Nothing -> ["relay test passed"] +``` + +Output examples: +- Success: `relay test passed, profile: bob` +- Decode failure: `relay test failed at RTSDecodeLink, error: no relay address link data` +- Link failure: `relay test failed at RTSGetLink, error: ...` + +### Phase 12: CLI parsing — TestChatRelay + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Add CLI parser after `/relays` (~line 4771): + +```haskell +"/relay test " *> (TestChatRelay <$> strP), +``` + +### Phase 13: Tests + +**File: `tests/ChatTests/ChatRelays.hs`** + +Add to `chatRelayTests`: +```haskell +describe "configure chat relays" $ do + ... + it "test chat relay" testChatRelayTest +``` + +#### Test: `testChatRelayTest` + +Single test function covering three scenarios sequentially. Uses alice (owner), bob (relay), and cath (normal user). + +```haskell +testChatRelayTest :: HasCallStack => TestParams -> IO () +testChatRelayTest ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + -- Setup: bob (relay) creates address + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + -- Setup: cath (normal user) creates address + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + -- Scenario 1: Happy path — test relay address succeeds + -- Concurrent because alice's test command blocks while bob processes REQ + concurrentlyN_ + [ do + alice ##> ("/relay test " <> bobSLink) + alice <## "relay test passed, profile: bob", + -- Bob's side is automatic (subscriber handles XGrpRelayTest) + -- but we need to consume any potential output on bob's side + pure () + ] + + -- Scenario 2: Non-relay address — cath is not a relay user, + -- her address has ContactShortLinkData, not RelayAddressLinkData + alice ##> ("/relay test " <> cathSLink) + alice <## "relay test failed at RTSDecodeLink, error: no relay address link data" + + -- Scenario 3: Deleted address — bob deletes his address + bob ##> "/da" + bob <## "Your chat address is deleted - accepted contacts will remain connected." + alice ##> ("/relay test " <> bobSLink) + -- Exact error message depends on SMP server response, match prefix + alice <## startsWith "relay test failed at RTSGetLink, error: " +``` + +**Key design decisions:** + +1. **One test, three scenarios** — avoids repeating setup (creating users, addresses) across three separate tests while covering happy path + two failure modes. + +2. **`concurrentlyN_` for happy path** — alice's `TestChatRelay` command blocks on a TMVar waiting for the relay's response. Bob's subscriber processes the REQ automatically via `xGrpRelayTest`, but the test framework needs both sides to run concurrently. The relay side may produce no visible CLI output (the `xGrpRelayTest` handler doesn't emit events to the view), so the relay branch is `pure ()`. + +3. **No concurrency for failure scenarios** — both fail before establishing a connection (at link fetch or decode step), so alice returns immediately with an error. + +4. **`startsWith` for SMP error** — the exact SMP error message may vary (network error, connection refused, etc.), so we match only the prefix `"relay test failed at RTSGetLink, error: "`. + +5. **Bob's output during happy path** — the relay's subscriber handles `XGrpRelayTest` silently (no `toView` call on success). After accepting, the agent creates a new connection whose subsequent events (JOINED, etc.) hit `getConnectionEntity` → `SEConnectionNotFound` → logged via `eToView`. This log noise may or may not appear as a test output line. If it does, we'd need to consume it in the `concurrentlyN_` bob branch. This needs to be verified during implementation — if bob produces output, add `bob <## ...` to consume it. + +**Helper needed:** `startsWith` — matches output lines by prefix. Check if this already exists in test utils: + +```haskell +startsWith :: String -> String -> Bool +startsWith = isPrefixOf +``` + +Or use an existing pattern like `<##.` if available. + +#### Scenarios NOT tested (and why): + +- **Signature verification failure (`RTSVerify`)** — would require the relay to sign with a wrong key. No mechanism to inject that without modifying the relay's behavior (e.g., a test-only flag). Not worth the complexity. +- **Timeout (`RTSWaitResponse`)** — would require the relay to not respond (e.g., by stopping the relay process). The test would take 40 seconds and be fragile. Not practical for a unit test. +- **Connection error (`RTSConnect`)** — would require the SMP server to be reachable (link data returned) but the connection request to fail. Hard to construct reliably. + +Existing relay config tests (`testGetSetChatRelays`, etc.) need updating for the `relayProfile` type change — CLI output changes from `bob_relay: ` to the same (the `name` field is now accessed via `relayProfile`), but the CLI command syntax stays the same (`/relays name=bob_relay `). + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Protocol.hs` | `RelayProfile`, `RelayAddressLinkData`, `XGrpRelayTest` + tags + parsing + encoding | +| `src/Simplex/Chat/Operators.hs` | `UserChatRelay'`: `name` → `relayProfile :: RelayProfile`; update `newChatRelay_`, validation | +| `src/Simplex/Chat/Controller.hs` | `RelayTestStep`, `RelayTestFailure`, `RelayTest`, `chatRelayTests`, `APITestChatRelay`, `CRChatRelayTestResult` | +| `src/Simplex/Chat.hs` | Initialize `chatRelayTests` in `newChatController` | +| `src/Simplex/Chat/Library/Commands.hs` | `APITestChatRelay` handler, `APICreateMyAddress` relay link data, CLI parsing, `cleanupManager` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Owner CONF handler pre-check, relay REQ handler `XGrpRelayTest` | +| `src/Simplex/Chat/Store/Direct.hs` | `createRelayTestConnection` | +| `src/Simplex/Chat/Store/Groups.hs` | `toGroupRelay`, `createRelayForOwner`: wrap/unwrap `RelayProfile` | +| `src/Simplex/Chat/Store/Profiles.hs` | `toChatRelay`, `insertChatRelay`, `updateChatRelay`, `undeleteRelay`: wrap/unwrap `RelayProfile`; `getStaleRelayTestConns` | +| `src/Simplex/Chat/View.hs` | `viewChatRelay`: use `relayProfile`; `CRChatRelayTestResult` + `viewRelayTestResult` | +| `apps/ios/.../ChatRelayView.swift` | `UserChatRelay` model update, test button + result display | +| `apps/ios/.../AddChannelView.swift` | Test integration | +| `apps/multiplatform/.../ChatRelayView.kt` | `UserChatRelay` model update, test button + result display | +| `apps/multiplatform/.../AddChannelView.kt` | Test integration | +| `tests/ChatTests/ChatRelays.hs` | `testChatRelayTest` | + +**Separate simplexmq change:** +| `simplexmq/src/Simplex/Messaging/Agent.hs` | `getConnLinkPrivKey` API | + +--- + +## Key Functions Reused + +- `getShortLinkConnReq` (Internal.hs:1339) — fetch link data + validate SMP + get connReq +- `decodeLinkUserData` (Internal.hs:1361) — decode `RelayAddressLinkData` from `ConnLinkData` +- `encodeShortLinkData` (Internal.hs:1351) — encode `RelayAddressLinkData` for link userData +- `prepareConnectionToJoin` (agent) — prepare agent connection for joining +- `joinConnection` (agent) — join relay's contact address +- `encodeConnInfo` (Internal.hs:1929) — encode `XGrpRelayTest` as connInfo +- `parseChatMessage` (Internal.hs:1563) — parse connInfo in CONF handler +- `agentAcceptContactAsync` (Internal.hs:2421) — relay accepts test connection +- `deleteAgentConnectionAsync` (Internal.hs:2428) — cleanup connections +- `deleteConnectionRecord` (Store/Shared.hs:895) — cleanup DB connection record (takes `Int64` DB connection_id) +- `getConnLinkPrivKey` (agent, new) — retrieve `linkPrivSigKey` from connection's short link creds +- `C.verify'` (simplexmq Crypto:1270) — `PublicKey a -> Signature a -> ByteString -> Bool` +- `C.sign'` (simplexmq Crypto:1175) — `PrivateKey a -> ByteString -> Signature a` +- `C.randomBytes` (simplexmq Crypto:1401) — `Int -> TVar ChaChaDRG -> STM ByteString` +- `eToView` (Controller.hs:1537) — `ChatError -> CM ()` — report error to view + +--- + +## Verification + +### Build +```bash +cabal build --ghc-options=-O0 +``` + +### Test +```bash +cabal test simplex-chat-test --test-options='-m "channels"' +cabal test simplex-chat-test --test-options='-m "chat relays"' +``` + +### Manual verification +1. Start relay user, set as chat relay, create address +2. Start owner user +3. Owner tests relay address → verify CRChatRelayTestResult with profile, no failure +4. Owner tests invalid address → verify failure at RTSGetLink +5. Kill owner during test → verify cleanup by cleanupManager after 5 min + +--- + +## Adversarial Self-Review + +### Pass 1 + +**Issue: Signature type in JSON** — `C.Signature 'C.Ed25519` is a GADT constructor. Need to verify it has JSON/Encoding instances and can be transmitted in a JSON chat message. +**Analysis:** `Signature` has no native JSON instance. For JSON, encode as base64 ByteString using `B64UrlByteString . C.signatureBytes`. For parsing, decode `B64UrlByteString` then `C.decodeSignature :: ByteString -> Either String (Signature 'C.Ed25519)` (Crypto.hs:849). The `(.=?)` pattern handles `Maybe` — only included when `Just`. +**Fix:** Encoding uses `B64UrlByteString . C.signatureBytes <$> sig_`. Parsing uses `traverse decodeSig =<< opt "signature"` where `decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s` (returns `JQ.Parser`, not `Either String`). No relay profile in message — owner gets it from link data. + +**Issue: `DuplicateRecordFields` on `connId`** — `connId :: Int64` appears on `Connection`, `PendingContactConnection`, and `UserContactRequest`. With `DuplicateRecordFields` enabled, `connId conn` won't compile as a field selector. +**Analysis:** Must use pattern matching. The handler uses `conn@Connection {connId = dbConnId}`. +**Fix:** Already applied in Phase 5 handler code. + +**Issue: `getConnLinkPrivKey` conn access** — In `xGrpRelayTest`, we call `getConnLinkPrivKey a (aConnId conn)` where `conn` is the user contact address connection. Does the agent's `getConn` find it by the correct ConnId? +**Analysis:** `processContactConnMessage` receives `conn :: Connection` which is the chat-layer connection record. `aConnId conn` gives the agent's `ConnId`. The agent stores `ShortLinkCreds` on the `RcvQueue` of the `ContactConnection` for this `ConnId`. The agent function pattern-matches on `ContactConnection _ rq` and returns `linkPrivSigKey <$> shortLink rq`. This is correct. +**Fix:** No fix needed. + +**Issue: `getConnLinkPrivKey` returns Nothing** — If the relay's address connection has no short link credentials, the relay-side handler logs an error via `eToView` and does not accept the test connection. +**Analysis:** This shouldn't happen for a properly configured relay (creating the address creates short link creds via `createConnection` in the agent). Handled gracefully — the owner will timeout with `RTSWaitResponse`. +**Fix:** No fix needed. + +**Issue: Test connection routing on relay side** — After the relay accepts the test via `agentAcceptContactAsync`, the agent creates a new connection. Future events on this connection (JOINED, etc.) arrive at `processAgentMessageConn`. Since there's no DB connection record, `getConnectionEntity` will fail with `SEConnectionNotFound`, producing error in `eToView`. This is log noise. +**Analysis:** Acceptable for MVP. The agent will eventually GC the connection. The error is harmless and happens for the relay only. The owner's connection is cleaned up by the handler. +**Fix:** Document as known behavior. + +**Issue: `tryAllErrors` behavior** — Does `tryAllErrors` catch all exceptions or just `ChatError`? +**Analysis:** `tryAllErrors` (Util.hs:249) uses `UE.catch` which catches `SomeException` — ALL exceptions, not just `ChatError`. It converts via `fromSomeException` into the error type. This is important: if `joinConnection` throws an IO exception, it's still caught and the cleanup runs. +**Fix:** No fix needed — the behavior is correct. + +**Issue: Multiple CONFs** — Could the owner receive multiple CONF events for the same connection? If yes, the second `putTMVar` would block. +**Analysis:** The SMP protocol sends exactly one CONF per connection. Multiple CONFs would be a protocol violation. +**Fix:** No fix needed. + +**Issue: Cleanup on timeout** — If the timeout fires (40s), the handler deletes the DB connection and agent connection. But the relay's response might arrive AFTER cleanup. +**Analysis:** After timeout, the TMap entry is deleted. A late CONF arriving at the subscriber finds no TMap entry, falls through to the existing flow, fails at `getConnectionEntity` (connection deleted). Harmless — `catchAllErrors eToView` absorbs it. +**Fix:** No fix needed. The cleanup sequence (delete TMap → delete DB → delete agent) is safe in all interleavings. + +### Pass 2 + +**Issue: `decodeLinkUserData cData`** — For relay addresses, `cData` is `ContactLinkData vr UserContactData{..}`. Does `decodeLinkUserData` decode the right field? +**Analysis:** `decodeLinkUserData` (Internal.hs:1361) is polymorphic — uses `JQ.decode` on the `userData` bytes from `UserContactData`. The caller constrains the type via the binding `Just RelayAddressLinkData {relayProfile}`. The `FromJSON` instance is provided by `deriveJSON`. +**Fix:** No fix needed. + +**Issue: `encodeShortLinkData`** — Will it work for `RelayAddressLinkData`? +**Analysis:** `encodeShortLinkData` (Internal.hs:1351) is polymorphic — `J.ToJSON a => a -> UserLinkData`. Uses `J.encode` and wraps in `UserLinkData`. Works for any type with `ToJSON`. +**Fix:** No fix needed. + +**Issue: Cleanup identification query safety** — `getStaleRelayTestConns` uses: `ConnContact + contact_id IS NULL + ConnPrepared + contact_conn_initiated = 0 + old created_at`. Could this match non-test connections? +**Analysis:** All code paths that create `ConnContact` with `contact_id = NULL`: +- `createConnReqConnection` (Direct.hs:158): sets `ConnPrepared` (line 164) BUT also sets `contact_conn_initiated = True` (line 175, `BI True`), `xcontact_id`, and `via_contact_uri`. The `contact_conn_initiated = 0` condition excludes these. +- `createRelayTestConnection` (new): sets `ConnPrepared`, inherits `contact_conn_initiated = 0` default. Matches the query. +- No other code path creates `ConnContact` with `contact_id = NULL` and `contact_conn_initiated = 0`. +**Fix:** The query is safe with the `contact_conn_initiated = 0` discriminator. + +**Issue: Partial failure cleanup** — If `prepareConnectionToJoin` succeeds but the `withFastStore` for `createRelayTestConnection` fails, the agent connection leaks. +**Analysis:** The `prepareConnectionToJoin` call happens before the `tryAllErrors` block. If `createRelayTestConnection` throws, we never reach cleanup. The agent connection from `prepareConnectionToJoin` would leak until restart. However, `createRelayTestConnection` is a simple INSERT — it's unlikely to fail. And if it does, `cleanupManager` won't catch it because no DB row was created. The agent-level connection will be cleaned up on agent restart. +**Fix:** Acceptable for MVP. Could wrap in a broader try-catch, but the failure mode is extremely unlikely and the consequence (one leaked agent connection) is minor. + +**Issue: `void $ withAgent $ \a -> joinConnection ...`** — The return type of `joinConnection` is `AE (SndQueueSecured, Maybe ClientServiceId)`. Using `void` discards both values. +**Analysis:** For the test connection, we don't need `SndQueueSecured` or `ClientServiceId`. The `addRelay` function (Commands.hs:3776) uses the return value to update connection status, but the test connection is deleted immediately anyway. +**Fix:** No fix needed. + +Both passes clean. No further issues found. diff --git a/plans/2026-04-02-desktop-voice-recording.md b/plans/2026-04-02-desktop-voice-recording.md new file mode 100644 index 0000000000..e29af72f34 --- /dev/null +++ b/plans/2026-04-02-desktop-voice-recording.md @@ -0,0 +1,39 @@ +# Desktop Voice Recording + +## Overview + +Implement voice recording on desktop using vlcj (already a dependency). The `RecorderNative` class is currently a stub. All UI is already in common code. + +## Files to modify + +1. `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt` — implement `RecorderNative` +2. `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt` — remove desktop "in development" guard (line 317-318) +3. `apps/multiplatform/desktop/build.gradle.kts` — add `NSMicrophoneUsageDescription` to macOS Info.plist + +## RecorderNative implementation + +Uses `MediaPlayerFactory` + `MediaPlayer` to capture from default microphone and transcode to AAC/m4a via VLC's sout chain. + +Platform-specific capture MRLs: +- macOS: `qtsound://` +- Linux: `pulse://` +- Windows: `dshow://` with `:dshow-vdev=none :dshow-adev=` + +Transcode options: `vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000` — matches Android (mono, 16kHz, 32kbps AAC). + +Factory requires `--sout-avcodec-strict=-2` to enable FFmpeg's native AAC encoder. + +Progress tracked via elapsed time (VLC capture has no position API). Duration read via `AudioPlayer.duration()` after stop. + +Max duration: enforced by stopping recording after `MAX_VOICE_MILLIS_FOR_SENDING` (300,000 ms) in the progress coroutine. + +## macOS permission + +Add `NSMicrophoneUsageDescription` to Info.plist via Gradle `infoPlist` block. + +## What does NOT change + +- `RecorderInterface` (common) +- `ComposeView.kt`, `ComposeVoiceView` — already handle voice preview/sending +- Audio format — `.m4a` (matches Android) +- All voice recording UI — already in common code diff --git a/plans/2026-04-06-onboarding-cards-compose.md b/plans/2026-04-06-onboarding-cards-compose.md new file mode 100644 index 0000000000..648f9e1d81 --- /dev/null +++ b/plans/2026-04-06-onboarding-cards-compose.md @@ -0,0 +1,492 @@ +# Onboarding Cards — Compose (Android/Desktop) Implementation Plan + +References the layout specification in `plans/2026-04-06-onboarding-cards-ios.md`. + +## Scope + +Same as iOS: Screens 1 and 2 with paging transition. Modal sheets for deeper views. No banner, no standalone onboarding variants. + +## New file + +`common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt` + +## Assets + +8 card stub SVGs needed in `assets/default/MR/images/` (same names as the real PNGs, with `.svg` extension): +- `card_let_someone_connect_to_you_alpha.svg` / `_light.svg` +- `card_connect_via_link_alpha.svg` / `_light.svg` +- `card_invite_someone_privately_alpha.svg` / `_light.svg` +- `card_create_your_public_address_alpha.svg` / `_light.svg` + +Real PNGs already generated in art repo `multiplatform/resources/MR/images/`. + +## Onboarding condition (shared by Android and Desktop) + +Placed in `ConnectOnboardingView.kt` as top-level functions, accessible from both `ChatListView.kt` and `App.kt`: + +```kotlin +@Composable +fun shouldShowOnboarding(): Boolean { + val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val chats = chatModel.chats.value + return !addressCreationCardShown.value && chats.isNotEmpty() && noConversationChatsYet(chats) +} + +fun noConversationChatsYet(chats: List): Boolean = + chats.all { chat -> + when (val c = chat.chatInfo) { + is ChatInfo.Local -> true + is ChatInfo.Direct -> c.contact.chatDeleted || c.contact.isContactCard + is ChatInfo.Group -> false + is ChatInfo.ContactRequest -> true + is ChatInfo.ContactConnection -> true + is ChatInfo.InvalidJSON -> true + } + } +``` + +`shouldShowOnboarding` is `@Composable` (reads reactive state) and public — called from both `ChatListView.kt` and `App.kt`. `noConversationChatsYet` is a pure function, also public (used by auto-dismiss LaunchedEffect). + +### Auto-dismiss + +```kotlin +LaunchedEffect(chatModel.chats.value.size) { + if (!noConversationChatsYet(chatModel.chats.value)) { + appPrefs.addressCreationCardShown.set(true) + } +} +``` + +Placed in `ChatListWithLoadingScreen`. + +## Android integration + +### In `ChatListView.kt` — `ChatListWithLoadingScreen` (line 291) + +Change from: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + if (!chatModel.desktopNoUserNoRemote) { ChatList(...) } + if (chatModel.chats.value.isEmpty() && ...) { Text("Loading/empty") } +} +``` + +To: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + val chats = chatModel.chats.value + when { + chats.isEmpty() && !chatModel.switchingUsersAndHosts.value + && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == null -> { + Text(stringResource(MR.strings.loading_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } + shouldShowOnboarding() -> { + if (appPlatform.isAndroid) { + ConnectOnboardingView() + } + // Desktop: empty — overlay in DesktopScreen handles it + } + !chatModel.desktopNoUserNoRemote -> { + ChatList(searchText = searchText, listState) + } + } + // Auto-dismiss + LaunchedEffect(chats.size) { + if (chats.isNotEmpty() && !noConversationChatsYet(chats)) { + appPrefs.addressCreationCardShown.set(true) + } + } +} +``` + +Toolbar is a sibling in the parent `Box` (lines 150-174), stays visible. + +## Desktop integration + +### Architecture + +The overlay is the PRIMARY UI surface during onboarding. ALL interaction happens inside it — card taps, toolbar button modals, everything. `ModalManager.start` renders INTO the overlay instead of into the start panel. + +### Overlay structure in `DesktopScreen` (App.kt) + +Two visual layers in the overlay, both full-width: + +1. **Background layer:** covers center+end area only (padded left by start panel width). Opaque `MaterialTheme.colors.background`. Hides center panel content ("No selected chat") while leaving start panel fully visible underneath. + +2. **Content layer:** full window width, no background. Cards render here, centered in the full window. Clicks outside cards fall through to the start panel below. + +Both layers have top/bottom padding for toolbar height (`AppBarHeight * fontSizeSqrtMultiplier`). + +```kotlin +if (shouldShowOnboarding()) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val toolbarPadding = AppBarHeight * fontSizeSqrtMultiplier + val topPad = if (!oneHandUI.value) toolbarPadding else 0.dp + val bottomPad = if (oneHandUI.value) toolbarPadding else 0.dp + + // Background — center+end only + Box( + Modifier + .fillMaxSize() + .padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier, top = topPad, bottom = bottomPad) + .background(MaterialTheme.colors.background) + ) + + // Content — full width, cards centered + Box( + Modifier + .fillMaxSize() + .padding(top = topPad, bottom = bottomPad), + contentAlignment = Alignment.Center + ) { + ConnectOnboardingView() + } +} +``` + +Z-order: above panels and vertical divider, below `ModalManager.fullscreen`. + +### Start panel modal redirection + +During onboarding, `ModalManager.start.showInView()` renders INSIDE the overlay instead of in the start panel Box. + +In `DesktopScreen`: +```kotlin +// Start panel modals — normal location +Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { + if (!shouldShowOnboarding()) { + ModalManager.start.showInView() + } + SwitchingUsersView() +} +``` + +Inside `ConnectOnboardingView`, on desktop: +- Watch `ModalManager.start.hasModalsOpen` +- When a start modal opens (from toolbar + button, avatar, or card tap): + 1. Cards shift RIGHT and fade to ~30% opacity (animated) + 2. `ModalManager.start.showInView()` renders on the LEFT side of the overlay with left-to-right slide animation + 3. This is the FIRST modal opening — it slides left-to-right + 4. Subsequent modals within the start modal stack open right-to-left as usual (standard `ModalManager` behavior inside the rendered area) +- When all start modals close: reverse animation — modal area slides left, cards restore position and opacity +- Clicking a faded card triggers `ModalManager.start.closeModals()` to dismiss and restore cards. This requires swapping card `onClick` handlers when `startModalsOpen` is true — each card's onClick becomes `{ ModalManager.start.closeModals() }` instead of its normal action. + +```kotlin +// Inside ConnectOnboardingView, desktop only: +val startModalsOpen = ModalManager.start.hasModalsOpen +val cardOffset by animateFloatAsState(if (startModalsOpen) 0.3f else 0f) +val cardAlpha by animateFloatAsState(if (startModalsOpen) 0.3f else 1f) +val modalSlide by animateFloatAsState(if (startModalsOpen) 0f else -1f) + +Box(Modifier.fillMaxSize()) { + // Modal area — slides from left + if (appPlatform.isDesktop) { + Box( + Modifier + .fillMaxHeight() + .widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) + .graphicsLayer { translationX = modalSlide * size.width } + ) { + ModalManager.start.showInView() + } + } + + // Cards — shift right and fade when modal open + Box( + Modifier + .fillMaxSize() + .graphicsLayer { + if (appPlatform.isDesktop) { + translationX = cardOffset * size.width + alpha = cardAlpha + } + } + ) { + HorizontalPager(...) { /* pages */ } + } +} +``` + +Card taps use `ModalManager.start.showModalCloseable` on ALL platforms — same code. On Android, the modal renders in the normal start panel modal area. On desktop during onboarding, the modal renders inside the overlay via the redirected `showInView()`. + +### Card tap actions — same on all platforms + +```kotlin +val openConnectViaLink = { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, ..., close = close) + } +} +``` + +No platform branching needed. `ModalManager.start` handles the modal lifecycle. Only the rendering location changes. + +### Suppress "No selected chat" in `CenterPartOfScreen` (line 373) + +```kotlin +null -> { + if (!shouldShowOnboarding() && !rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + Box(...) { Text(stringResource(...)) } + } else if (!shouldShowOnboarding()) { + ModalManager.center.showInView() + } +} +``` + +When onboarding active: center panel shows nothing (overlay covers it visually). + +### Desktop HorizontalPager: tap only + +`userScrollEnabled = !appPlatform.isDesktop` — disables mouse swipe on desktop. + +## Revision 2 — Bug fixes from initial implementation + +### Fix 1: `fillMaxSize()` overrides `widthIn(max:)` + +In both page composables, the Column has `Modifier.fillMaxSize().widthIn(max = 500.dp)`. `fillMaxSize()` sets width to maximum, overriding the `widthIn` constraint. + +Fix: `Modifier.fillMaxHeight().widthIn(max = 500.dp)` — only fill height, let widthIn cap the width. + +### Fix 2: Modifier order — background before padding + +In the overlay Box: `.fillMaxSize().background(color).padding(...)` paints background over toolbar area. + +Fix: `.fillMaxSize().padding(...).background(color)` — padding first, background only fills content area. + +Superseded by the two-layer approach above — background layer is separate from content layer. + +### Fix 3: "You have no chats" text dropped + +The `when` block in `ChatListWithLoadingScreen` replaced two independent `if` blocks with mutually exclusive branches, dropping the "You have no chats" case. + +Fix: revert to the original `if` block structure, adding onboarding as the first check: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + if (shouldShowOnboarding()) { + if (appPlatform.isAndroid) { + ConnectOnboardingView() + } + } else { + if (!chatModel.desktopNoUserNoRemote) { + ChatList(searchText = searchText, listState) + } + if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + Text(stringResource( + if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats + ), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } + } + // Auto-dismiss + LaunchedEffect(chatModel.chats.value.size) { ... } +} +``` + +This preserves the original loading/empty behavior exactly. The onboarding branch is checked first — when active, it replaces everything. When inactive, original code runs unchanged. + +## ConnectOnboardingView composable + +### Structure + +```kotlin +@Composable +fun ConnectOnboardingView() { + val pagerState = rememberPagerState(initialPage = 0) { 2 } + val scope = rememberCoroutineScope() + + HorizontalPager(state = pagerState, userScrollEnabled = true) { page -> + when (page) { + 0 -> TalkToSomeonePage( + onLetSomeoneConnect = { scope.launch { pagerState.animateScrollToPage(1) } }, + onConnectViaLink = { ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = close) + }} + ) + 1 -> ConnectWithSomeonePage( + onBack = { scope.launch { pagerState.animateScrollToPage(0) } }, + onInviteSomeone = { ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) + }}, + onCreateAddress = { ModalManager.start.showModalCloseable { close -> + UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close) + }} + ) + } + } +} +``` + +### Page layout + +Each page uses `BoxWithConstraints` to compute card dimensions: + +```kotlin +@Composable +private fun TalkToSomeonePage(onLetSomeoneConnect: () -> Unit, onConnectViaLink: () -> Unit) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val isLandscape = maxWidth > maxHeight + val padding = 16.dp + val spacing = 16.dp + val cardWidth = if (isLandscape) (maxWidth - padding * 2 - spacing) / 2 else maxWidth - padding * 2 + val maxCardHeight = cardWidth * 0.75f + + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + pageHeader("Talk to someone", showBack = false, isLandscape = isLandscape) + Spacer(Modifier.weight(1f).defaultMinSize(minHeight = 16.dp)) + cardPair(isLandscape, padding, spacing, maxCardHeight) { + // card1 and card2 + } + Spacer(Modifier.weight(1f).defaultMinSize(minHeight = 16.dp)) + } + } +} +``` + +### pageHeader composable + +Shared by both pages. No duplication: + +```kotlin +@Composable +private fun pageHeader(title: String, showBack: Boolean, isLandscape: Boolean, onBack: (() -> Unit)? = null) { + val titleView = @Composable { + Text( + stringResource(title), + style = MaterialTheme.typography.h1, // largeTitle equivalent + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + if (isLandscape) { + Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + if (showBack && onBack != null) { + backButton(onBack, Modifier.align(Alignment.CenterStart)) + } + titleView() + } + } else { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + if (showBack && onBack != null) { + backButton(onBack, Modifier.align(Alignment.Start)) + } else { + Spacer(Modifier.height(AppBarHeight)) + } + titleView() + } + } +} +``` + +Back button spacer uses `AppBarHeight` (56.dp) to match the platform's back button area, not iOS's 44pt. + +### cardPair composable + +Shared layout helper, no card duplication: + +```kotlin +@Composable +private fun cardPair( + isLandscape: Boolean, + padding: Dp, + spacing: Dp, + maxCardHeight: Dp, + card1: @Composable () -> Unit, + card2: @Composable () -> Unit +) { + if (isLandscape) { + Row(Modifier.padding(horizontal = padding), horizontalArrangement = Arrangement.spacedBy(spacing)) { + Box(Modifier.weight(1f).heightIn(max = maxCardHeight)) { card1() } + Box(Modifier.weight(1f).heightIn(max = maxCardHeight)) { card2() } + } + } else { + Column(Modifier.padding(horizontal = padding), verticalArrangement = Arrangement.spacedBy(spacing)) { + Box(Modifier.fillMaxWidth().heightIn(max = maxCardHeight)) { card1() } + Box(Modifier.fillMaxWidth().heightIn(max = maxCardHeight)) { card2() } + } + } +} +``` + +### OnboardingCardView composable + +```kotlin +@Composable +fun OnboardingCardView( + imageName: ImageResource, + imageNameLight: ImageResource, + icon: ImageResource, + title: String, + subtitle: String? = null, + labelHeightRatio: Float, + onClick: () -> Unit +) +``` + +Key Compose details (from layout-compose.md checklist): +- **Image:** `contentScale = ContentScale.Fit`, `Modifier.fillMaxSize()` — scaled AND centered ✓ +- **Gradient:** `Brush.linearGradient(colorStops, start, end)` with pixel Offsets computed from image area measured size via `Modifier.onSizeChanged` or `BoxWithConstraints` +- **Gradient math:** identical to iOS — same function ported to Kotlin, same angle/scale/aspect-ratio correction +- **Corner radius:** `RoundedCornerShape(24.dp)` with `Modifier.clip()` +- **Dark/light:** `if (isInDarkTheme()) imageNameLight else imageName` for image, gradient stops selected by theme +- **Conditional assets:** `if (BuildConfigCommon.SIMPLEX_ASSETS) { Image(...) }` +- **Clickable:** `Modifier.clip(RoundedCornerShape(24.dp)).clickable(onClick = onClick)` — clip first so ripple is bounded + +#### Label stripe background + +Use the same pattern as the toolbar (from DefaultTopAppBar.kt line 43-65): +```kotlin +MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) +``` + +This exactly matches the toolbar appearance, including the user's bar transparency preference. + +#### Gradient in Compose + +```kotlin +// Compute inside BoxWithConstraints or onSizeChanged callback +val imageAreaSize = Size(width, imageHeight) +val (startUnit, endUnit) = gradientPoints( + aspectRatio = imageAreaSize.height / imageAreaSize.width, + scale = if (isInDarkTheme()) 1.5f else 1.2f +) +val brush = Brush.linearGradient( + colorStops = if (isInDarkTheme()) darkStops else lightStops, + start = Offset(startUnit.x * imageAreaSize.width, startUnit.y * imageAreaSize.height), + end = Offset(endUnit.x * imageAreaSize.width, endUnit.y * imageAreaSize.height) +) +``` + +### Card icons (Moko resource names) + +Screen 1: +- "Let someone connect to you" — `MR.images.ic_add_link` +- "Connect via link or QR code" — `MR.images.ic_qr_code` + +Screen 2: +- "Invite someone privately" — `MR.images.ic_add_link` +- "Create your public address" — `MR.images.ic_qr_code` + +### Strings + +8 new entries in `strings.xml` (`MR/base/strings.xml`): +```xml +Talk to someone +Let someone connect to you +Connect via link or QR code +Create your link +Invite someone privately +A link for one person to connect +Create your public address +For anyone to reach you +``` + +## Files changed + +- `ChatListView.kt` — add `shouldShowOnboarding`, `noConversationChatsYet`, modify `ChatListWithLoadingScreen`, add auto-dismiss `LaunchedEffect` +- `App.kt` — add desktop overlay in `DesktopScreen`, suppress "No selected chat" in `CenterPartOfScreen` +- `MR/base/strings.xml` — 8 new strings +- **New:** `OnboardingCards.kt` — `ConnectOnboardingView`, `OnboardingCardView`, `TalkToSomeonePage`, `ConnectWithSomeonePage`, `pageHeader`, `cardPair`, `shouldShowOnboarding`, `noConversationChatsYet`, gradient math +- **New:** 8 stub SVGs in `assets/default/MR/images/` diff --git a/plans/2026-04-06-onboarding-cards-ios.md b/plans/2026-04-06-onboarding-cards-ios.md new file mode 100644 index 0000000000..d9c310a93e --- /dev/null +++ b/plans/2026-04-06-onboarding-cards-ios.md @@ -0,0 +1,501 @@ +# Onboarding Cards — Layout Specification & iOS Implementation Plan + +## Layout Specification (cross-platform) + +This section is the authoritative reference for implementing on any platform. + +### Overall structure + +Two screens, each with a title and two tappable cards. Screens are connected by a horizontal paging transition (swipe or tap). Screen 1 has no back button; Screen 2 has a back button. Deeper views (1-time link, connect via link, SimpleX address) open as modal sheets from card taps, NOT as navigation pushes. + +The chat list toolbar (top or bottom depending on platform/settings) remains visible on both screens — the onboarding content occupies only the chat list content area. + +### Page header + +Each page has a header area containing: +- **Back button area:** fixed height 44pt. Screen 1: empty space. Screen 2: "< Back" button left-aligned. +- **Title:** centered, largeTitle font, bold, single line, shrinks to 75% minimum scale factor. +- Screen 1 title: "Talk to someone" +- Screen 2 title: "Create your link" + +**Portrait:** back button area and title are two separate rows (VStack). +**Landscape:** back button and title share one row (ZStack — back button leading, title centered). No separate back button row — saves vertical space. + +Padding: 16pt horizontal on the header container. Back button has no padding of its own. + +### Card layout + +**Portrait:** two cards stacked vertically (VStack, spacing 16pt). +**Landscape:** two cards side-by-side (HStack, spacing 16pt). + +Card horizontal padding: 16pt each side. + +Cards are vertically centered in the remaining space below the header. Equal space above and below the card group (Spacer with minLength 16pt on both sides). + +### Card max height + +Max total card height = card width × 0.75. + +In portrait: card width = screen width − 32pt (16pt padding each side). +In landscape: card width = (screen width − 32pt − 16pt spacing) / 2. + +Card height can be less than max on small screens. Height never exceeds max. + +### Card component + +Each card is a rounded rectangle (corner radius 24pt) containing: + +1. **Image area** (top) — gradient background + alpha-channel illustration overlay +2. **Label stripe** (bottom) — toolbar material background, fixed proportional height + +#### Image area + +- Gradient fills only the image area, NOT the label stripe +- Illustration: `.resizable().scaledToFit()`, fills available space, clipped to image area + +#### Gradient + +Stops (light mode): +- `#d2e8ff` (rgb 0.824, 0.910, 1.0) at 0% +- `#cce9ff` (rgb 0.800, 0.914, 1.0) at 50% +- `#dfffff` (rgb 0.875, 1.0, 1.0) at 90% +- `#fffcea` (rgb 1.0, 0.988, 0.918) at 100% + +Stops (dark mode): +- `#040a24` (rgb 0.016, 0.039, 0.141) at 40% +- `#3854ab` (rgb 0.220, 0.329, 0.671) at 72% +- `#a8edf3` (rgb 0.659, 0.929, 0.953) at 90% +- `#fff6e0` (rgb 1.0, 0.965, 0.878) at 100% + +Angle: 80° counter-clockwise from horizontal (almost vertical, slight rightward lean at top). + +Gradient scale (center-anchored): 1.2× in light mode, 1.5× in dark mode. This pushes start/end points further from center, reducing colored corner area. + +**Gradient endpoint calculation** (accounts for variable card aspect ratio): + +The gradient must maintain a constant 80° visual angle regardless of card proportions. Given the IMAGE AREA aspect ratio `r = imageHeight / width`: + +``` +θ = 80° (in radians: 80 × π / 180) +dx = cos(θ) +dy = −sin(θ) / r + +Project four corners (±0.5, ±0.5) onto direction (dx, dy): + projections = [−0.5·dx + (−0.5)·dy, 0.5·dx + (−0.5)·dy, −0.5·dx + 0.5·dy, 0.5·dx + 0.5·dy] + tMin = min(projections), tMax = max(projections) + dLenSq = dx² + dy² + +Base endpoints: + startX = 0.5 + tMin·dx/dLenSq, startY = 0.5 + tMin·dy/dLenSq + endX = 0.5 + tMax·dx/dLenSq, endY = 0.5 + tMax·dy/dLenSq + +Apply scale S (1.2 or 1.5) center-anchored: + finalStart = (0.5 + (startX−0.5)·S, 0.5 + (startY−0.5)·S) + finalEnd = (0.5 + (endX−0.5)·S, 0.5 + (endY−0.5)·S) +``` + +Important: aspect ratio uses IMAGE AREA height (card height minus label stripe), not total card height. + +#### Label stripe + +Height relative to card width: +- Single-line labels (Screen 1): 0.132 × card width +- Two-line labels (Screen 2): 0.195 × card width + +Background: platform toolbar material (matches the app toolbar appearance). On iOS: `Material` from `ToolbarMaterial` user setting. On Android: equivalent translucent material. + +Content layout: centered horizontally. +- Icon: 24pt, theme primary/accent color +- Title: body font (17pt), medium weight, theme foreground color, single line, shrinks to 75% +- Subtitle (Screen 2 only): footnote (13pt), theme foreground at 70% opacity + +Label stripe sits below the image area — gradient does NOT extend under it. + +### Card images + +8 alpha-channel PNGs (4 illustrations × light/dark variants). + +Screen 1: +- `card-let-someone-connect-to-you-alpha` / `-light` +- `card-connect-via-link-alpha` / `-light` + +Screen 2: +- `card-invite-someone-privately-alpha` / `-light` +- `card-create-your-public-address-alpha` / `-light` + +Light/dark selection: use base name on light backgrounds, `-light` suffix on dark backgrounds. + +Gated behind build flag (`#if SIMPLEX_ASSETS` on iOS, `BuildConfigCommon.SIMPLEX_ASSETS` on Android). Without assets: gradient-only cards with label stripe, still functional. + +### Card icons (SF Symbols / Material equivalents) + +Screen 1: +- "Let someone connect to you" — `link.badge.plus` +- "Connect via link or QR code" — `qrcode.viewfinder` + +Screen 2: +- "Invite someone privately" — `link.badge.plus` +- "Create your public address" — `qrcode` + +### Card actions + +Screen 1: +- Left card ("Let someone connect to you") → paging transition to Screen 2 +- Right card ("Connect via link or QR code") → modal sheet with ConnectView + +Screen 2: +- Left card ("Invite someone privately") → modal sheet with InviteView (1-time link) +- Right card ("Create your public address") → modal sheet with UserAddressView (auto-create) + +### Onboarding visibility + +Controlled by existing user default `addressCreationCardShown` (key: `"AddressCreationCardShown"`). + +Show onboarding when: +- `addressCreationCardShown == false` +- Chat list is not empty (chats have loaded) +- All chats are "ignorable" (note folders, deleted contacts, contact cards, pending connections/requests, invalid JSON) +- Any group = real conversation → onboarding hidden + +Auto-dismiss: when first real conversation appears, set `addressCreationCardShown = true` permanently. Observed via chat list count changes. + +### Strings (8) + +- "Talk to someone" +- "Let someone connect to you" +- "Connect via link or QR code" +- "Create your link" +- "Invite someone privately" +- "A link for one person to connect" +- "Create your public address" +- "For anyone to reach you" + +--- + +## Scope + +Screens 1 and 2 only — two card selection screens with slide navigation between them. No standalone onboarding variants of existing views. No banner. Those are separate future work. + +## New file + +`Shared/Views/NewChat/OnboardingCards.swift` — all new code in one file. + +## What it contains + +### `OnboardingCardView` — reusable card component + +```swift +struct OnboardingCardView: View { + @Environment(\.colorScheme) var colorScheme + let imageName: String // base asset name (without -light suffix) + let icon: String // SF Symbol name + let title: LocalizedStringKey + let subtitle: LocalizedStringKey? // nil for screen 1 cards + let action: () -> Void +} +``` + +Image selection follows the project convention: +- `colorScheme == .light` → `imageName` (base name, dark-colored image for light backgrounds) +- `colorScheme == .dark` → `"\(imageName)-light"` (light-colored image for dark backgrounds) + +Note: this only works when the base name does NOT already contain `-light`. The card image base names are like `card-let-someone-connect-to-you-alpha` — the `-alpha` suffix distinguishes them, and appending `-light` gives `card-let-someone-connect-to-you-alpha-light`. Correct. + +Structure (inside → out): +1. `Button(action:)` wrapping the entire card for tap handling, with `.buttonStyle(.plain)` to prevent default blue tint +2. Clipped to `RoundedRectangle(cornerRadius: 18)` +3. Inside, `ZStack(alignment: .bottom)`: + - `LinearGradient` filling the card shape + - `VStack(spacing: 0)`: + - `#if SIMPLEX_ASSETS` block: `Image` with `.resizable().scaledToFit().frame(maxWidth: .infinity, maxHeight: .infinity)` — takes all space above label. Image uses `.clipped()` to prevent overflow into label area. + - `#else` block: `Spacer()` — gradient-only card, label still functional. + - Label area with fixed height: `HStack(spacing: 8)` with `Image(systemName: icon)` (20pt) + `VStack(alignment: .leading, spacing: 2)` containing title + optional subtitle. Padded `(.horizontal, 16)` and `(.vertical, 12)`. + +Gradient stops (using `Color(red:green:blue:)` with values 0-1, no hex extension exists in the project): +- Light: + - `Color(red: 0.824, green: 0.910, blue: 1.0)` at 0.0 (#d2e8ff) + - `Color(red: 0.800, green: 0.914, blue: 1.0)` at 0.5 (#cce9ff) + - `Color(red: 0.875, green: 1.0, blue: 1.0)` at 0.9 (#dfffff) + - `Color(red: 1.0, green: 0.988, blue: 0.918)` at 1.0 (#fffcea) +- Dark: + - `Color(red: 0.016, green: 0.039, blue: 0.141)` at 0.4 (#040a24) + - `Color(red: 0.220, green: 0.329, blue: 0.671)` at 0.72 (#3854ab) + - `Color(red: 0.659, green: 0.929, blue: 0.953)` at 0.9 (#a8edf3) + - `Color(red: 1.0, green: 0.965, blue: 0.878)` at 1.0 (#fff6e0) +- Angle: 80° from vertical = 10° from horizontal. `LinearGradient(stops:..., startPoint: .init(x: 0.0, y: 0.6), endPoint: .init(x: 1.0, y: 0.4))`. Must verify visually — the exact start/end points for 80° depend on the view's aspect ratio. May need adjustment. + +Define the gradient stops as static properties on `OnboardingCardView` to avoid recomputing them on every recomposition. + +Label text styles: +- Title: `.body` weight `.semibold`, color `Color.white` in dark mode, `Color.primary` in light mode (from design: dark text on light gradient, light text on dark gradient). +- Subtitle: `.footnote`, color `.secondary` (adapts to theme). +- Icon: same color as title. + +### `TalkToSomeoneView` — Screen 1 + +```swift +struct TalkToSomeoneView: View { + @EnvironmentObject var theme: AppTheme + @State private var showConnectWithSomeone = false + @State private var showConnectViaLink = false +``` + +Body — NOT scrollable, fills available space: + +```swift +var body: some View { + VStack(spacing: 16) { + Text("Talk to someone") + .font(.largeTitle) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + + OnboardingCardView( + imageName: "card-let-someone-connect-to-you-alpha", + icon: "link", + title: "Let someone connect to you", + subtitle: nil, + action: { showConnectWithSomeone = true } + ) + .frame(maxHeight: .infinity) + .padding(.horizontal, 16) + + OnboardingCardView( + imageName: "card-connect-via-link-alpha", + icon: "qrcode", + title: "Connect via link or QR code", + subtitle: nil, + action: { showConnectViaLink = true } + ) + .frame(maxHeight: .infinity) + .padding(.horizontal, 16) + } + .padding(.vertical, 16) + .background( + NavigationLink(isActive: $showConnectWithSomeone) { + ConnectWithSomeoneView() + } label: { EmptyView() } + ) + .background( + NavigationLink(isActive: $showConnectViaLink) { + NewChatView(selection: .connect, showQRCodeScanner: true) + .navigationBarTitleDisplayMode(.inline) + } label: { EmptyView() } + ) +} +``` + +Key layout decisions: +- `.frame(maxHeight: .infinity)` on each card makes them share remaining vertical space equally after the title takes its natural height. +- `.padding(.vertical, 16)` on the VStack adds 16pt above the title and 16pt below the second card (VStack `spacing` only applies between children, not before first or after last). +- Hidden `NavigationLink(isActive:)` in `.background()` — drives navigation without affecting layout. This is the deprecated iOS 15 API but it works on iOS 16+ inside `NavigationStack` and is used throughout the existing codebase (e.g., `NewChatMenuButton.swift` lines 100-110). + +**`oneHandUI` inversion handling:** `TalkToSomeoneView` replaces `chatList` content. `chatListView` applies `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)` to the root page. The onboarding view gets inverted. It must counter-invert with `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)`. This is applied in `ChatListView.chatList`, NOT inside `TalkToSomeoneView` — the caller is responsible. When NavigationLink pushes Screen 2 or further views, those are new navigation pages outside the root page's scale effect, so they render normally. + +### `ConnectWithSomeoneView` — Screen 2 + +```swift +struct ConnectWithSomeoneView: View { + @EnvironmentObject var theme: AppTheme + @State private var showInviteSomeone = false + @State private var showCreateAddress = false +``` + +Same VStack layout as Screen 1, with these differences: +- Title: "Create your link" +- Card 1: imageName `"card-invite-someone-privately-alpha"`, icon `"link"`, title "Invite someone privately", subtitle "A link for one person to connect" → sets `showInviteSomeone = true` +- Card 2: imageName `"card-create-your-public-address-alpha"`, icon `"qrcode"`, title "Create your public address", subtitle "For anyone to reach you" → sets `showCreateAddress = true` + +Navigation destinations (existing views, unmodified — onboarding variants are future work): +- `showInviteSomeone` → `NewChatView(selection: .invite)` — tabbed view, 1-time link tab pre-selected. Has tabs (not ideal) but functional. +- `showCreateAddress` → `UserAddressView(shareViaProfile: false, autoCreate: true)` — auto-creates address on appear. + +Both wrapped with `.navigationBarTitleDisplayMode(.inline)`. + +Navigation bar back button shows automatically (pushed via NavigationLink within the stack). + +## Integration into ChatListView + +### In `chatList` property (line 351 of ChatListView.swift) + +Current code: +```swift +private var chatList: some View { + let cs = filteredChats() + return ZStack { + ScrollViewReader { scrollProxy in + List { ... } + } + } +} +``` + +Changed to: +```swift +@ViewBuilder +private var chatList: some View { + if shouldShowOnboarding { + TalkToSomeoneView() + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + } else { + let cs = filteredChats() + ZStack { + ScrollViewReader { scrollProxy in + List { ... } + } + } + } +} +``` + +Requires `@ViewBuilder` because `if/else` returns different view types. + +**`oneHandUI` inversion:** The `.scaleEffect(y: -1)` is applied by `chatListView` to the root page of the navigation stack. `TalkToSomeoneView` counter-inverts at the call site. When `NavigationLink` pushes Screen 2 or further, those are new navigation pages NOT affected by the root page's `.scaleEffect`. Only the root content needs the flip. + +### `shouldShowOnboarding` and `noConversationChatsYet` + +```swift +private var shouldShowOnboarding: Bool { + !addressCreationCardShown && noConversationChatsYet +} + +private var noConversationChatsYet: Bool { + chatModel.chats.allSatisfy { chat in + switch chat.chatInfo { + case .local: return true + case let .direct(contact): return contact.chatDeleted || contact.isContactCard + case let .group(groupInfo, _): return groupInfo.chatDeleted + case let .contactRequest(req): return req.chatDeleted + case let .contactConnection(conn): return conn.chatDeleted + case .invalidJSON: return true + } + } +} +``` + +Both are computed properties on `ChatListView`. `noConversationChatsYet` reads `chatModel.chats` which is `@Published` on `ChatModel` (`@EnvironmentObject`). SwiftUI re-evaluates the body when it changes, so `shouldShowOnboarding` is reactive. + +Note: `chatModel.chats` may be empty during initial load (before `APIGetChats` completes). `allSatisfy` on an empty array returns `true`. Combined with `!addressCreationCardShown`, this means the onboarding flashes briefly on app launch for users who have conversations but `chats` hasn't loaded yet. Mitigation: also check `chatModel.chats.isEmpty` and show a loading indicator instead: + +```swift +private var shouldShowOnboarding: Bool { + !addressCreationCardShown && !chatModel.chats.isEmpty && noConversationChatsYet +} +``` + +When `chats` is empty (loading), neither onboarding nor chat list shows — the existing loading state (if any) handles it. + +### Auto-dismiss + +`addressCreationCardShown` must be set to `true` when the first real conversation appears, so the onboarding never returns. + +```swift +.onChange(of: chatModel.chats.count) { _ in + if !noConversationChatsYet && !addressCreationCardShown { + addressCreationCardShown = true + } +} +``` + +Placed on `chatList` view. Observes `.count` as a proxy for chat list changes. When count changes and `noConversationChatsYet` is false, the user default is set permanently. This covers: receiving a contact request, establishing a connection, creating a group, etc. + +Edge case: chat count can change without affecting `noConversationChatsYet` (e.g., adding a second note folder). The check `!noConversationChatsYet` prevents unnecessary writes — only sets the default when there's actually a real conversation. + +## User default + +Existing `@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false` at ChatListView line 165. Constant defined in `SettingsView.swift` line 55 as `let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"`. Also referenced in `AddressCreationCard.swift` line 17 and in `SettingsView.swift` defaults reset (line 114, 144). + +No new user default needed. + +## String localization + +8 new strings for `Localizable.strings` (en). Use `NSLocalizedString` or `LocalizedStringKey` inline — project uses both patterns. + +- "Talk to someone" +- "Let someone connect to you" +- "Connect via link or QR code" +- "Create your link" +- "Invite someone privately" +- "A link for one person to connect" +- "Create your public address" +- "For anyone to reach you" + +## Assets + +8 card images in art repo (4 base + 4 light variants). Run `resize.sh` then `copy-assets.sh` to populate `SimpleXAssets.xcassets`. Gated with `#if SIMPLEX_ASSETS`. Without assets: gradient-only cards with labels, still tappable and functional. + +Image base names for the `imageName` parameter: +- Screen 1: `"card-let-someone-connect-to-you-alpha"`, `"card-connect-via-link-alpha"` +- Screen 2: `"card-invite-someone-privately-alpha"`, `"card-create-your-public-address-alpha"` + +The `-light` suffix is appended automatically by `OnboardingCardView` when `colorScheme == .dark`. + +## Files changed + +- `Shared/Views/ChatList/ChatListView.swift` — add `shouldShowOnboarding`, `noConversationChatsYet`, add `@ViewBuilder` to `chatList`, branch to `TalkToSomeoneView`, add `.onChange` for auto-dismiss +- **New:** `Shared/Views/NewChat/OnboardingCards.swift` — `OnboardingCardView`, `TalkToSomeoneView`, `ConnectWithSomeoneView` + +No modifications to NewChatView, UserAddressView, or ConnectView in this phase. + +## Revision 1 — corrections from design review + +### Navigation scope (critical) +Both screens must keep the bottom/top toolbar visible. The onboarding NavigationView is SCOPED to just the card area — it does NOT replace the full chatListView. In `chatList`, wrap `TalkToSomeoneView()` in its own `NavigationView { }.navigationViewStyle(.stack)`. The toolbar from `chatListView.withToolbar()` stays outside and visible on both screens. + +Screen 1 → Screen 2: real NavigationLink push within the scoped NavigationView. +Screen 2 → deeper views: also NavigationLink pushes within same scoped NavigationView. + +### Screen 1 — reserve nav bar space +Screen 2 has a back button (navigation bar). Screen 1 must reserve the same height to prevent content shift on slide. Set `.navigationTitle("")` with `.navigationBarTitleDisplayMode(.inline)` on Screen 1's root — shows an empty inline nav bar matching Screen 2's bar height. + +### Gradient direction fix +Current gradient is nearly horizontal — wrong. Correct angle is 80° CCW from horizontal (almost vertical, slight rightward lean). + +Formula for full-coverage gradient at angle θ: +``` +startPoint = (0.5 - 0.5·cos(θ), 0.5 + 0.5·sin(θ)) +endPoint = (0.5 + 0.5·cos(θ), 0.5 - 0.5·sin(θ)) +``` + +For θ = 80°: `startPoint: .init(x: 0.413, y: 0.992), endPoint: .init(x: 0.587, y: 0.008)` + +### Corner radius +Change from 18 to 24. + +### Label stripe background +The label area has a distinct semi-transparent background strip at the bottom of the card. Add to `labelRow`: +- Light mode: `Color.white.opacity(0.5)` +- Dark mode: `Color.black.opacity(0.3)` +Exact opacity values need visual tuning. + +### Card max height ratio +Cards have a max total height/width ratio of 0.75. On tall screens, cards are capped at this ratio with extra space distributed equally above and below. On short screens, cards shrink — ratio goes below 0.75, label stripe stays fixed height, only image area shrinks. + +Implementation: use `GeometryReader` to get available width, compute `maxCardHeight = cardWidth * 0.75`, apply `.frame(maxHeight: maxCardHeight)` on each card. The VStack centers vertically in the GeometryReader — equal space above and below on tall screens. + +### Title alignment +Change from `.leading` to `.center` — design shows centered titles on both screens. + +### Subtitle color in dark mode +Change `.foregroundColor(.secondary)` to `.foregroundColor(colorScheme == .dark ? .white.opacity(0.7) : .secondary)` — standard `.secondary` is too gray on the dark gradient. + +### Label stripe height proportions +The label stripe has fixed proportional heights relative to card width: +- Screen 1 (single-line labels): 0.132 × card width +- Screen 2 (two-line labels): 0.195 × card width + +These are achieved via fixed padding on the label row. The image area is the remainder of the card height. When cards shrink on short screens, only the image area shrinks — the label stripe stays at its proportional height. + +### Spacing between title and cards, between cards, and below cards +The gaps above first card and below second card should be EQUAL and LARGER than the gap between the two cards. The inter-card gap is the VStack spacing (~16pt). The outer gaps are larger — achieved by the GeometryReader centering the VStack vertically, which distributes extra space equally above and below. + +### ThemedBackground on TalkToSomeoneView +`TalkToSomeoneView` needs `.modifier(ThemedBackground())` — it replaces `chatList` content and needs its own background. Currently missing. + +### `oneHandUI` inversion on Screen 2 +The scoped `NavigationView` sits inside `chatList` which is visually inverted by `chatListView`'s `.scaleEffect(y: -1)`. This inversion applies to the NavigationView's rendered frame — ALL pages inside it (both Screen 1 and Screen 2) are inverted. `TalkToSomeoneView` counter-inverts at the call site. `ConnectWithSomeoneView` (pushed within the NavigationView) also needs counter-inversion. Pass `oneHandUI` as a binding or read from `@AppStorage(GROUP_DEFAULT_ONE_HAND_UI)` directly inside `ConnectWithSomeoneView`, and apply `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)` on its root VStack. Same for any deeper pushed views — but those are existing views not modified in this phase, so their inversion behavior needs testing. + +### Plan cleanup note +The original sections above contain outdated code snippets (wrong gradient, wrong corner radius, wrong switch cases, wrong alignment). The Revision 1 sections are authoritative. When implementing, follow Revision 1 values; treat original sections as structural context only. + diff --git a/plans/2026-04-10-relay-leaving-group.md b/plans/2026-04-10-relay-leaving-group.md new file mode 100644 index 0000000000..42aebf425b --- /dev/null +++ b/plans/2026-04-10-relay-leaving-group.md @@ -0,0 +1,234 @@ +# Plan: Relay Leaving Group (Moderation Capability) + +## Context + +SimpleX Chat channels use chat relays to forward messages from owners to subscribers. When a channel hosts prohibited content, the relay operator needs the ability to make their relay leave the group. Currently `APILeaveGroup` doesn't work correctly for relay members: the `getRecipients` helper always uses `getGroupRelayMembers` for channel groups, which returns only other relays (members with `GRRelay` role) — the owner is excluded. The relay needs to notify the owner (so it can update channel link data) and all subscribers directly (relay has connections to all of them). + +## Flow + +1. **Relay** calls `APILeaveGroup` → sends `XGrpLeave` directly to all members (owners + subscribers) → deletes all connections +2. **Owner** receives `XGrpLeave` → updates relay member status to `GSMemLeft` → updates `GroupRelay.relayStatus` to `RSInactive` → updates channel link relay list via `updatePublicGroupData` → `setGroupLinkDataAsync` → `setAgentConnShortLinkAsync` (excludes left relay from link data) → relay bar shows status +3. **Subscribers** receive `XGrpLeave` directly from relay → update relay member status to `GSMemLeft` → delete connection to relay → relay bar shows status + +## Changes + +### 1. Add `RSInactive` to `RelayStatus` + +**`src/Simplex/Chat/Types/Shared.hs`** (~L81-112) + +`GroupMemberStatus` already carries left/removed semantics (`GSMemLeft`, `GSMemRemoved`), so `RelayStatus` should not duplicate that. Add `RSInactive` as a generic terminal status meaning "no longer operational", complementing `RSActive`. Add `"inactive"` encoding in `relayStatusText` and `TextEncoding` instance. + +```haskell +data RelayStatus + = RSNew + | RSInvited + | RSAccepted + | RSActive + | RSInactive + deriving (Eq, Show) +``` + +### 2. Fix `APILeaveGroup` recipients for relay + +**`src/Simplex/Chat/Library/Commands.hs`** (~L2838-2844) + +Use nested condition inside `useRelays'` guard. When relay leaves, it sends `XGrpLeave` to all current/pending members directly (relay has connections to all of them). + +```haskell +getRecipients user gInfo@GroupInfo {membership} + | useRelays' gInfo = + if isRelay membership + then do + -- Relay leaving: notify all members directly, clean up all connections + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + pure (ms, filter memberCurrentOrPending ms) + else do + relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + pure (relays, relays) + | otherwise = do + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + pure (ms, filter memberCurrentOrPending ms) +``` + +- `members` (first tuple) = all members → used by `deleteMembersConnections'` for connection cleanup +- `recipients` (second tuple) = all current/pending members → XGrpLeave sent directly + +Existing functions: `isRelay` (Types.hs:1063), `getGroupMembers` (Store/Groups.hs), `memberCurrentOrPending` (Types.hs:1308). + +### 3. Update `xGrpLeave` to set relay status and channel link on owner + +**`src/Simplex/Chat/Library/Subscriber.hs`** (~L3113-3124) + +After `updateMemberRecordDeleted` and before `updatePublicGroupData`, set `RSInactive` on the owner's `GroupRelay` record. On subscribers this is a no-op because they have no `GroupRelay` record (`getGroupRelayByGMId` returns `Left`, `forM_` skips). + +```haskell +xGrpLeave gInfo m msg@RcvMessage {msgSigned} brokerTs = do + deleteMemberConnection m + gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft + -- Set relay status to inactive (owner-only; subscriber has no GroupRelay record) + when (isRelay m) $ + withStore' $ \db -> do + relay_ <- runExceptT $ getGroupRelayByGMId db (groupMemberId' m) + forM_ relay_ $ \relay -> void $ updateRelayStatus db relay RSInactive + gInfo'' <- updatePublicGroupData user gInfo' + -- ... rest unchanged +``` + +The channel link update chain on owner: `updatePublicGroupData` (Internal.hs:1317) → `setGroupLinkDataAsync` (Internal.hs:1309) → `getConnectedGroupRelays` (filters `member_status = GSMemConnected AND relay_status IN (RSAccepted, RSActive)`) → `groupLinkData` (builds `UserContactLinkData` with remaining relay links only) → `setAgentConnShortLinkAsync` (updates SMP short link). The left relay is excluded by the `member_status` filter, so its link is removed from the channel link data. + +Existing functions: `isRelay` (Types.hs:1063), `getGroupRelayByGMId` (Store/Groups.hs:1296), `updateRelayStatus` (Store/Groups.hs:1418), `groupMemberId'` (Types.hs). + +### 4. Client type updates + +**`apps/ios/SimpleXChat/ChatTypes.swift`** (~L2558-2563, L2628-2637) + +Add `case rsInactive = "inactive"` to `RelayStatus` enum and `case .rsInactive: "inactive"` to `text` property. + +**`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`** (~L2266-2276) + +Add `@SerialName("inactive") RsInactive` to `RelayStatus` enum and `RsInactive -> generalGetString(MR.strings.relay_status_inactive)` to `text` property. + +**`apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`** (~L2879) + +Add `inactive`. + +**`packages/simplex-chat-client/types/typescript/src/types.ts`** (~L3608-3613) + +Add `Inactive = "inactive"` to `RelayStatus` enum. + +### 5. UI: relay bar updates + +The relay bar (above compose area) currently shows for owners when `activeCount < relays.count`, and for subscribers in steady state. After relay leaves, the bar shows but needs better indication of what happened and whether delivery is broken. + +#### 5a. Fix `relayStatusIndicator` color for `RSInactive` + +Currently `relayStatusIndicator` shows yellow for any non-active status — yellow implies "connecting/in progress", which is wrong for an inactive relay. + +**iOS** (`apps/ios/Shared/Views/NewChat/AddChannelView.swift` L431-432): +```swift +// CURRENT: +let color: Color = connFailed ? .red : (status == .rsActive ? .green : .yellow) +// NEW: +let color: Color = connFailed ? .red : (status == .rsActive ? .green : (status == .rsInactive ? .red : .yellow)) +``` + +**Kotlin** (`apps/multiplatform/.../views/newchat/AddChannelView.kt` L551): +```kotlin +// CURRENT: +val color = if (connFailed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow +// NEW: +val color = if (connFailed) Color.Red else when (status) { + RelayStatus.RsActive -> Color.Green + RelayStatus.RsInactive -> Color.Red + else -> WarningYellow +} +``` + +#### 5b. Owner relay bar: "no active relays" message + +When `activeCount == 0`, show a warning in the relay bar that delivery is broken and adding new relays is coming. + +**iOS** (`apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift` ~L730-738): + +In `ownerChannelRelayBar`, when expanded and `activeCount == 0`, add footer text: +```swift +"Messages can't be delivered to subscribers. Adding new relay will be available in a future update." +``` + +**Kotlin** (`apps/multiplatform/.../views/chat/ComposeView.kt` ~L1647-1657): same logic. + +New string: `relay_bar_owner_no_delivery` = "Messages can't be delivered to subscribers. Adding new relay will be available in a future update." + +#### 5c. Subscriber relay bar: show disconnection in steady state + +Currently in steady state (`showProgress = false`), the subscriber relay bar header shows only "N relays" with no error indication. When relay connections are deleted (relay left), the subscriber sees no issue in collapsed view. + +**iOS** (`apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift` ~L780-792): + +When `!showProgress`, check error state: +```swift +if !showProgress { + if errorCount == total { + Text("All relays disconnected – messages can't be delivered") + } else if errorCount > 0 { + Text(String.localizedStringWithFormat("%d/%d relays connected, %d errors", connectedCount, total, errorCount)) + } else { + Text(String.localizedStringWithFormat("%d relays", total)) + } +} +``` + +**Kotlin** (`apps/multiplatform/.../views/chat/ComposeView.kt` ~L1695-1707): same logic. + +New strings: +- `relay_bar_all_disconnected` = "All relays disconnected – messages can't be delivered" +- `relay_bar_connected_with_errors_steady` = "%1$d/%2$d relays connected, %3$d errors" + +### 6. Test + +**`tests/ChatTests/Groups.hs`** + +Add `testChannelRelayLeave` test: + +1. Create channel with 2 relays (`relay1`, `relay2`) and 2 subscribers (`dan`, `eve`) via `prepareChannel2Relays` + `memberJoinChannel` +2. Verify channel works: owner sends message → subscribers receive via relay forwarding +3. `relay1` leaves: `relay1 ##> "/leave #team"` +4. Verify relay1 output: `"#team: you left the group"` +5. Verify owner output: `"#team: left the group (signed)"` +6. Verify subscribers receive `XGrpLeave` directly — check relay1 member status is `"left"` on subscribers via `checkMemberStatus` +7. Wait for async link data update +8. Verify channel still works with remaining relay: owner sends message → relay2 forwards → subscribers receive +9. `relay2` leaves: `relay2 ##> "/leave #team"` +10. Verify relay2 output and owner/subscriber leave events +11. **Verify no delivery**: owner sends message, `threadDelay`, check subscribers' last item is still the previous message (not the new one) — pattern from `testChannelSubscriberLeave` L9237 + +Register test in test list at ~L261 (after `testChannelSubscriberLeave`). + +## Files Modified + +| File | Change | +|------|--------| +| `src/Simplex/Chat/Types/Shared.hs` | Add `RSInactive` to `RelayStatus` | +| `src/Simplex/Chat/Library/Commands.hs` | Fix `getRecipients` for relay in `APILeaveGroup` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Update `xGrpLeave` to set relay status | +| `apps/ios/SimpleXChat/ChatTypes.swift` | Add `rsInactive` case | +| `apps/ios/Shared/Views/NewChat/AddChannelView.swift` | Fix `relayStatusIndicator` color for inactive | +| `apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift` | Owner/subscriber relay bar messages | +| `apps/multiplatform/.../ChatModel.kt` | Add `RsInactive` case | +| `apps/multiplatform/.../AddChannelView.kt` | Fix `RelayStatusIndicator` color for inactive | +| `apps/multiplatform/.../ComposeView.kt` | Owner/subscriber relay bar messages | +| `apps/multiplatform/.../strings.xml` | Add `relay_status_inactive` + relay bar strings | +| `packages/.../types.ts` | Add `Inactive` to enum | +| `tests/ChatTests/Groups.hs` | Add `testChannelRelayLeave` test | + +## Verification + +```bash +cabal build --ghc-options=-O0 +cabal test simplex-chat-test --test-options='-m "channels"' +``` + +Manual: verify relay bar appearance on iOS simulator and Android emulator after relay leaves. + +## Adversarial Review + +**Pass 1:** +- Relay's `sendGroupMessage'` signs `XGrpLeave` (`requiresSignature XGrpLeave_ = True`; relay has `groupKeys` with `memberPrivKey`). Owner and subscribers verify signature. OK. +- `deleteMembersConnections' user members True` with `waitDelivery=True` ensures `XGrpLeave` reaches SMP queues before relay deletes connections. OK. +- Owner's channel link update: `updatePublicGroupData` → `setGroupLinkDataAsync` → `getConnectedGroupRelays` (excludes left relay by `member_status = GSMemLeft`) → `groupLinkData` (builds link with remaining relays) → `setAgentConnShortLinkAsync` (updates SMP short link). Left relay's link removed. OK. +- `muteEventInChannel` for relay member (`GRRelay < GRModerator`): muted for subscribers (no chat item), but DB is updated. Owner sees event (`GROwner >= GRModerator`). OK. +- Relay's `deleteGroupLinkIfExists` is no-op (relay doesn't own group link). OK. +- `getGroupRelayByGMId` on subscriber returns `Left` (no `GroupRelay` record) → `forM_` skips. No error. OK. +- `memberEventDeliveryScope` returns `DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}` for relay member. On subscriber, creates a delivery task but subscriber has no forwarding role — delivery worker finds no eligible connections. Harmless no-op. OK. +- Owner relay bar: `activeCount` drops (RSInactive ≠ RSActive) → bar shows → `relayStatusIndicator` shows red dot with "inactive". When `activeCount == 0`: "Messages can't be delivered... Adding new relay will be available in a future update." OK. +- Subscriber relay bar: `connStatus = .deleted` → `deletedCount` increases → `errorCount` increases. When `errorCount == total` in steady state: "All relays disconnected – messages can't be delivered". OK. + +**Pass 2:** +- Race condition: owner removes relay + relay leaves simultaneously. Both paths delete connection and update member status. No data corruption — idempotent. `GroupRelay.relayStatus` ends as `RSInactive` from `xGrpLeave` or unchanged from `xGrpMemDel` (which doesn't update relay status). OK. +- `getGroupMembers` for relay may return thousands of subscribers. `deleteMembersConnections'` uses `deleteAgentConnectionsAsync'` which handles batching. `sendGroupMessage'` also handles sending to many members. OK. +- Relay's own `membership` record has no `activeConn` in members list (can't connect to self). `mapMaybe` in `deleteMembersConnections'` filters it out. OK. +- Both relays leave: after last relay leaves, owner sends message. Delivery system has no connected relays to forward through — message saved locally but not delivered. Subscribers' last chat item remains unchanged. Test verifies this. OK. +- `getGroupRelays` (used by `apiGetGroupRelays`) returns ALL GroupRelay records including RSInactive — owner UI correctly includes left relays in bar. OK. +- Subscriber `groupMembers` filter (`memberRole == .relay`) includes left members (memberRole unchanged) — subscriber UI correctly shows left relays. OK. + +**Result: 2 consecutive clean passes.** diff --git a/plans/2026-04-11-channel-invitations-directory.md b/plans/2026-04-11-channel-invitations-directory.md new file mode 100644 index 0000000000..6ae2c046db --- /dev/null +++ b/plans/2026-04-11-channel-invitations-directory.md @@ -0,0 +1,255 @@ +# Public Group Invitations & Directory Listing + +## Goal + +Enable public group (channel) subscribers to invite new subscribers by sharing a channel card in any chat where they can send messages. Channel owners can prove ownership via a signed card. This unblocks directory service support for public groups alongside regular groups. + +Sharing channels should be as simple as forwarding — share button on channel opens chat picker, sends a channel card as a regular message. Old clients show the text; new clients show a rich card with profile and join button. + +## Context + +### Current state +- Public groups have `PublicGroupProfile {groupType = GTChannel, groupLink, publicGroupId}` and `useRelays = True` +- Users join public groups via link → `APIPrepareGroup` → `APIConnectPreparedGroup` +- `MCChat` message content exists with `MsgChatLink` variants for contacts, invitations, and groups (`MCLGroup`) +- Group invitations (`XGrpInv`) carry `connRequest :: ConnReqInvitation` — public groups don't use this mechanism +- Directory bot registers groups via group invitation (owner invites bot as admin) — public groups need a different flow + +### Owner keys in public group links +- `FixedLinkData.rootKey :: PublicKeyEd25519` — genesis root key +- `UserContactData.owners :: [OwnerAuth]` — chain of authorized owner keys, each signed by root or previous owner +- Public group creator stores `GroupKeys {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey}` +- `memberPrivKey`'s public key = `ownerKey` in the `OwnerAuth` entry (created via `newOwnerAuth`) +- `publicGroupId = sha256(rootPubKey)` — immutable group identity + +### DR connection shared secret +- Each direct connection has `rcAD` (Associated Data) from X3DH key exchange +- `getConnectionRatchetAdHash` returns `sha256(rcAD)` — binding for replay protection + +## Design + +### Channel cards as MCChat messages + +Channel invitations are sent as regular `XMsgNew` with `MCChat` content. No new protocol messages. + +```haskell +data MsgContent + = ... + | MCChat {text :: Text, chatLink :: MsgChatLink, ownerSig :: Maybe LinkOwnerSig} + | ... +``` + +`ownerSig` is optional. Old clients ignore it (missing field) and show `text` as a regular message. + +```haskell +data LinkOwnerSig = LinkOwnerSig + { ownerId :: Maybe OwnerId, -- Nothing = root key, Just = owner key from OwnerAuth chain + binding :: B64UrlByteString, + ownerSig :: B64UrlByteString + } +``` + +Sending is supported for channel cards only (for now). Verification is generic for all `MsgChatLink` types: +- `ownerId = Just id`: verified against matching `OwnerAuth.ownerKey` in the link's owner chain (channels) +- `ownerId = Nothing`: verified against `rootKey` from `FixedLinkData` (contacts, invitations) + +The sender proves control over the link regardless of type. + +### What is signed + +`smpEncode chatBinding <> bindingData <> smpEncode chatLink` signed with `memberPrivKey`. + +Binding depends on where the card is sent: +- **Direct chat**: `CBDirect` with `ratchetAdHash` +- **Public group**: `CBGroup` with `smpEncode (publicGroupId, memberId)` +- **Group without public identity**: signature treated as failed at verification time + +Binding is to chat, not to message (`sharedMsgId` is not included). This allows the sender to forward their own signed card within the same chat (e.g., re-sharing a channel link as a reminder) without invalidating the signature. Message-level binding would prevent this since forwarded messages get new `sharedMsgId`s. + +### Sending flow + +1. User presses "Share" on channel → API call `APIPrepareLinkOwnerSig GroupId` returns `Maybe LinkOwnerSig` +2. Opens chat picker (same as forwarding) — chats with disabled simplex links greyed out +3. Sends `XMsgNew` with `MCChat {text = displayName, chatLink = MCLGroup {connLink, groupProfile}, ownerSig}` +4. Creates regular `CISndMsgContent` chat item — no new item types, no new response types + +### Receiving flow + +Regular `XMsgNew` processing. Creates `CIRcvMsgContent (MCChat ...)`. No hidden groups, no async verification, no special events. + +UI renders channel card with profile, member count, join button. If `ownerSig` present, shows "signed by owner" indicator (unverified until join). + +### Verification at join time + +When user taps "Join" on a channel card: + +1. UI extracts `connLink` and `ownerSig` from `MCChat` message content +2. UI calls `APIConnectPlan` with the link and signature. `APIConnectPlan` extended: + ```haskell + APIConnectPlan {userId :: UserId, connectionLink :: Maybe AConnectionLink, linkOwnerSig :: Maybe LinkOwnerSig} + ``` + Parser: `/_connect plan [sig=]` +3. Inside `connectPlan`, if `linkOwnerSig` is present: + - Gets `FixedLinkData {rootKey}` and `UserContactData {owners}` from resolved link + - Finds verification key: `ownerId = Nothing` → `rootKey`, `ownerId = Just id` → matching `OwnerAuth.ownerKey` + - Verifies binding data against expected value from context + - Verifies signature +4. Each "OK" plan variant extended with verification result: + ```haskell + data LinkSigVerification = LSVVerified | LSVFailed {reason :: Text} + + ILPOk {contactSLinkData_, linkSigVerification :: Maybe LinkSigVerification} + CAPOk {contactSLinkData_, linkSigVerification :: Maybe LinkSigVerification} + GLPOk {groupSLinkInfo_, groupSLinkData_, linkSigVerification :: Maybe LinkSigVerification} + -- Nothing = not signed, Just LSVVerified = verified, Just LSVFailed = failed with reason + ``` + Reasons: "unknown owner ID", "binding data mismatch", "signature verification failed", "no group identity for verification" +5. UI shows verification result in join/connect alert for the OK plan variants +6. User confirms → `APIPrepareGroup` → `APIConnectPreparedGroup` — existing join flow, no changes + +Pasted links (no message context) pass `linkOwnerSig = Nothing` — plan shows "not signed." + +### Forwarding + +When `MCChat` is forwarded, `ownerSig` is dropped — UNLESS forwarded by sender in the same chat (re-sharing own card as reminder). Signature is bound to chat context, so forwarding in the same chat preserves validity. + +Implementation: in forwarding code, drop `ownerSig` unless `fromChatRef == toChatRef` and sender is the same user. + +### Simplex link permission + +`MCChat` IS a simplex link — if `SGFSimplexLinks` is prohibited for the sender's role, `MCChat` should be prohibited regardless of content. + +Currently `prohibitedSimplexLinks` (Internal.hs:363) only checks formatted text. Fix: also check `MsgContent` type — if it's `MCChat` and simplex links are not allowed, prohibit it. This covers both send and receive via existing `prohibitedGroupContent` calls. + +For backward compatibility, the current text-level check is sufficient since the link is included in `text`. But the `MCChat` type check is the correct long-term fix. + +### CLI view + +`MCChat` with `MCLGroup` renders as channel card with display name. If `ownerSig` present, shows "(signed)" indicator. + +## Directory bot changes + +### Registration flow + +Bot receives regular `CIRcvMsgContent (MCChat ...)` messages in direct chat from channel owners. Bot checks `ownerSig` is present. Verifies at join time via `connectPlan`. No special events needed. + +- Owner sends channel card to bot in DM (signed) +- Bot resolves link, verifies owner signature +- Bot joins channel as subscriber +- Simplified approval flow: `GRSProposed` → `GRSPendingApproval` → `GRSActive` + +### Profile monitoring + +Bot as subscriber receives `XGrpInfo` when owner updates profile. On profile change: re-resolve link, compare. Periodic re-verification. + +### Search and listing + +Search includes both groups and public groups. No separate listing category — `groupProfile.publicGroup` is the source of truth. `DETGroup` works for both in JSON listing. + +## Implementation plan (diff from master) + +### Step 1: LinkOwnerSig type + +- `LinkOwnerSig` type in Types.hs (or Protocol.hs alongside `MCChat`) +- `ownerSig :: Maybe LinkOwnerSig` field on `MCChat` +- JSON derivation with backward compat (optional field) + +### Step 2: CBDirect + +- Add `CBDirect` to `ChatBinding` in Protocol.hs (already done on master via refactoring PR) + +### Step 3: Share chat message content API + +New command that constructs the complete `MCChat` content for sharing: +```haskell +-- Controller.hs +APIShareChatMsgContent {shareChatRef :: ChatRef, toChatRef :: ChatRef} +-- returns CRChatMsgContent {user :: User, msgContent :: MsgContent} +``` + +Implementation in Commands.hs: +1. Load shared chat info from `shareChatRef` — initially only `CTGroup` with public groups supported +2. Get `PublicGroupProfile {groupLink}` and `groupProfile` from group +3. Determine if user is owner (has `GroupKeys {memberPrivKey}`) +4. If owner, compute binding based on `toChatRef`: + - `ChatRef CTDirect contactId` → `getConnectionRatchetAdHash` on contact's connection → `CBDirect` + - `ChatRef CTGroup groupId` → `smpEncode (publicGroupId, memberId)` if group has identity → `CBGroup` + - Group without identity → `Nothing` (can't sign) +5. If owner and binding available, sign `smpEncode chatBinding <> bindingData <> smpEncode chatLink` with `memberPrivKey` +6. Return `MCChat {text = displayName, chatLink = MCLGroup {connLink = groupLink, groupProfile}, ownerSig}` + +Parser: `/_share_chat ` + +UI flow: press Share on channel → chat picker → select destination → call `APIShareChatMsgContent` → get `MsgContent` → send via existing `APISendMessages` + +All business logic (ownership check, signing decision, link extraction, profile inclusion) stays in core. UI only passes two chat refs and sends the returned content. + +### Step 4: connectPlan verification + +Extend `APIConnectPlan` (Controller.hs:472): +```haskell +APIConnectPlan {userId :: UserId, connectionLink :: Maybe AConnectionLink, linkOwnerSig :: Maybe LinkOwnerSig} +``` + +Parser (Commands.hs:4945): extend to accept optional JSON `LinkOwnerSig` parameter. + +In `connectPlan` (Commands.hs), pass `linkOwnerSig` to `groupShortLinkPlan` / `groupJoinRequestPlan`. + +In `groupShortLinkPlan` (Commands.hs ~line 3944): after resolving the link via `getShortLinkConnReq`, if `linkOwnerSig` is present: +1. Extract `FixedLinkData {rootKey}` and `UserContactData {owners}` +2. If `ownerId = Nothing`: verify against `rootKey` +3. If `ownerId = Just id`: find `OwnerAuth` where `ownerId == id`, verify against `ownerKey` +4. Check binding data matches expected +5. Verify signature + +Extend `GroupLinkPlan` (Controller.hs:1025): +```haskell +GLPOk {groupSLinkInfo_, groupSLinkData_, ownerVerified :: Maybe Bool} +``` +`Nothing` = not signed, `Just True` = verified, `Just False` = failed. + +`CRConnectionPlan` response carries this through to UI — shown in plan alert. + +### Step 5: Forwarding — drop ownerSig + +In message forwarding code (Commands.hs, `APIForwardChatItems`), when forwarding `MCChat` content, set `ownerSig = Nothing`. + +Location: Commands.hs where forwarded message content is constructed — find where `MCChat` is handled in forwarding and strip the signature. + +### Step 6: Permission check + +Fix `prohibitedSimplexLinks` (Internal.hs:363) to also check `MsgContent` type — if `MCChat`, treat as simplex link. Covers both send and receive paths via existing `prohibitedGroupContent` calls. + +For backward compatibility, the link is also in `text` field, so existing text-level check catches it. The type check is the correct fix. + +### Step 7: CLI view + +In `viewChatItem` (View.hs), `MCChat` content already renders via `ttyMsgContent`. Extend to show channel card format and "(signed)" indicator when `ownerSig` is present. + +### Step 8: groupLinkData owners preservation + +Fix `groupLinkData` (Internal.hs:1330) to reconstruct `OwnerAuth` from `GroupKeys` instead of hardcoding `owners = []`. This ensures the resolved link data has the owner keys needed for verification. + +Implementation: when `GroupKeys` has `GRKPrivate rootPrivKey` and `memberPrivKey`, reconstruct `OwnerAuth` with `ownerId = unMemberId memberId`, `ownerKey = publicKey memberPrivKey`, `authOwnerSig = sign rootPrivKey (ownerId <> encodePubKey ownerKey)`. + +### Step 9: Tests + +- Share channel card in direct chat (owner signed) +- Share channel card in group (unsigned — no binding for groups without identity) +- Share channel card in channel +- Join via channel card — verify `connectPlan` shows verification result +- Non-public group share rejected +- Forwarded card has no signature +- Old client compatibility (text field shown) + +### Step 10: Directory bot + +- Handle `MCChat` with `MCLGroup` in `crDirectoryEvent_` +- Channel registration flow +- Profile monitoring + +## What stays from refactoring PR (already on master) + +- `CBDirect` in `ChatBinding` +- `HasShortLink` typeclass with `connShortLink'` +- `setShortLinkType` / `setShortLinkType_` diff --git a/plans/2026-04-16-ios-share-channel-link.md b/plans/2026-04-16-ios-share-channel-link.md new file mode 100644 index 0000000000..414016c9c4 --- /dev/null +++ b/plans/2026-04-16-ios-share-channel-link.md @@ -0,0 +1,407 @@ +# iOS — Share chat card (MCChat) + +Share a public group/channel link as a card in any chat. Backend (`APIShareChatMsgContent`, `SharePublicGroup`) exists. This plan covers: send-side UI (picker, compose, send), receive-side UI (card rendering, tap-to-connect with owner verification), and the plumbing between. + +--- + +## 0. UX flow + +1. Channel info screen (`GroupChatInfoView`): two entry points. + - **Quick-access action button** row (next to search/mute): "share" button — visible when `groupInfo.useRelays` and channel has a public link. Non-owners see it too. + - **Section button**: "Share via chat" inside the existing channel-link section (after existing system "Share link" button). +2. Tap either → **destination picker sheet** (reused from `ChatItemForwardingView` by parameterization, NOT a new view). +3. Tap a destination → sheet dismisses, navigates to the destination chat, compose shows **plaque** above input: "Sharing #channelName" (reused `ContextItemView` by parameterization, NOT a new view). +4. User may type optional text. Tap Send. +5. Backend builds MCChat: `text = \n` (link appended for old clients), signed with owner key if applicable. Sent as one message. +6. **Receive side**: card renders as a group-invitation-style tile (reused from `CIGroupInvitationView` by extracting shared component, NOT copy-pasted). Shows profile image + channel name + icon per link type + "from channel owner" if `ownerSig` present. Tap → `planAndConnect` flow with `linkOwnerSig` → alert shows owner verification result alongside standard plan info. + +--- + +## B. Implementation, file by file + +### 1. `SimpleXChat/ChatTypes.swift` — `LinkOwnerSig`, `OwnerVerification`, `MsgContent.chat` update + +**LinkOwnerSig** (new struct, near `MsgChatLink` ~line 4790): +```swift +public struct LinkOwnerSig: Codable, Equatable, Hashable { + public let ownerId: String? + public let chatBinding: String + public let ownerSig: String +} +``` + +**OwnerVerification** (new enum, near `GroupLinkPlan` in AppAPITypes.swift ~line 1379): +```swift +enum OwnerVerification: Decodable, Hashable { + case verified + case failed(reason: String) +} +``` + +**MsgContent.chat** — add `ownerSig` field: +- Case: `case chat(text: String, chatLink: MsgChatLink, ownerSig: LinkOwnerSig?)` +- CodingKeys: add `case ownerSig` +- Decoder (4719-4722): add `let ownerSig = try container.decodeIfPresent(LinkOwnerSig.self, forKey: .ownerSig)` +- Encoder (4764-4767): add `try container.encodeIfPresent(ownerSig, forKey: .ownerSig)` +- text getter (4605): `case let .chat(text, _, _)` +- `==` (4679): `case let (.chat(lt, ll, ls), .chat(rt, rl, rs)): return lt == rt && ll == rl && ls == rs` +- ComposeView.swift:1480: `case let .chat(_, chatLink, ownerSig): return .chat(text: msgText, chatLink: chatLink, ownerSig: ownerSig)` + +### 2. `Shared/Model/AppAPITypes.swift` — `SendRef`, plan types, command, response + +**SendRef** (new enum, near `ref()` helper ~line 580): +```swift +enum SendRef { + case direct(contactId: Int64) + case group(groupId: Int64, scope: GroupChatScope?, asGroup: Bool) +} + +func sendRef(_ r: SendRef) -> String { + switch r { + case let .direct(contactId): "@\(contactId)" + case let .group(groupId, scope, asGroup): + "#\(groupId)\(scopeRef(scope))\(asGroup ? "(as_group=on)" : "")" + } +} +``` + +**Plan types — add `ownerVerification`** to `.ok` cases: +- `InvitationLinkPlan.ok` (1352): `case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?)` +- `ContactAddressPlan.ok` (1359): `case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?)` +- `GroupLinkPlan.ok` (1374): `case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?)` + +**ChatCommand** — new case after `apiForwardChatItems` (line 64): +```swift +case apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toSendRef: SendRef) +``` +cmdString: `"/_share chat content \(ref(shareChatType, shareChatId, scope: nil)) \(sendRef(toSendRef))"` + +**APIConnectPlan** — extend with `linkOwnerSig`: +- Current (line ~150ish): `case apiConnectPlan(userId: Int64, connLink: String?)` +- Change to: `case apiConnectPlan(userId: Int64, connLink: String?, linkOwnerSig: LinkOwnerSig?)` +- cmdString: append `linkOwnerSig.map { " sig=" + encodeJSON($0) } ?? ""` (matches Haskell parser `optional (" sig=" *> jsonP)`) + +**ChatResponse1** — new case after `newChatItems` (823): +```swift +case chatMsgContent(user: UserRef, msgContent: MsgContent) +``` +Plus `responseType` + `details` entries. + +### 3. `Shared/Model/SimpleXAPI.swift` — wrappers + +**apiShareChatMsgContent** (near apiForwardChatItems, line 506): +```swift +func apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toSendRef: SendRef) async throws -> MsgContent { + let r: APIResult = await chatApiSendCmd( + .apiShareChatMsgContent(shareChatType: shareChatType, shareChatId: shareChatId, toSendRef: toSendRef) + ) + if case let .result(.chatMsgContent(_, mc)) = r { return mc } + throw r.unexpected +} +``` + +**apiConnectPlan** (line 1023-1032): add `linkOwnerSig: LinkOwnerSig? = nil` parameter, pass to `.apiConnectPlan(userId:connLink:linkOwnerSig:)`. + +### 4. `SimpleXChat/ChatUtils.swift` — NO new filter function + +Reuse `filterChatsToForwardTo` + `canForwardToChat` as-is. Chat cards ARE simplex links, so `prohibitedByPref(hasSimplexLink: true, ...)` applies and correctly gates by the destination's simplex-links preference + user's role. No separate filter. + +### 5. `ChatItemForwardingView.swift` — parameterize for dual use + +Add parameters to support both forwarding and sharing modes. **No new view file.** + +Add to the struct: +```swift +var title: String = "Forward" +var chats: [Chat] // caller provides filtered list (replaces internal filterChatsToForwardTo) +var isProhibited: ((Chat) -> Bool)? = nil // default: existing prohibitedByPref check; sharing overrides +var onSelect: (Chat) -> Void // replaces the inline tap handler +``` + +Remove `chatItems`, `fromChatInfo`, and `composeState` — the `onSelect` closure captures whatever the caller needs. The caller builds `ComposeState` and does navigation externally. + +Existing forwarding call site (`ChatView.swift:278`) adapts: +```swift +ChatItemForwardingView( + title: "Forward", + chats: filterChatsToForwardTo(chats: chatModel.chats), + isProhibited: { chat in forwardedChatItems.map { ci in chat.prohibitedByPref(...) }.contains(true) }, + onSelect: { chat in + dismiss forwarding sheet + set composeState to forwarding context + if different chat: loadOpenChat(chat.id) + } +) +``` + +Sharing call site (from GroupChatInfoView): +```swift +ChatItemForwardingView( + title: "Share channel", + chats: filterChatsToForwardTo(chats: chatModel.chats), // same filter + isProhibited: { chat in chat.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelect: { chat in + dismiss info sheet + set composeState to .sharingChatCard(sourceGroupInfo) + if different chat: loadOpenChat(chat.id) + } +) +``` + +### 6. `ContextItemView.swift` — parameterize for chat-card context + +Add an optional `customText: String?` property. When set, render that text instead of the ChatItem preview. Everything else (icon, cancel button, background, layout) stays the same. + +```swift +var customText: String? = nil // e.g., "Sharing #news" +``` + +When `customText != nil`: +- Display the string in place of the `msgContentView` / multi-message count +- Use `Color(uiColor: .tertiarySystemBackground)` for background (no ChatItem to derive color from) + +ComposeView's `contextItemView()` dispatch for the new case: +```swift +case let .sharingChatCard(sourceGroupInfo): + ContextItemView( + chat: chat, + contextItems: [], + contextIcon: "arrowshape.turn.up.forward", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, + customText: "Sharing #\(sourceGroupInfo.groupProfile.displayName)" + ) + Divider() +``` + +### 7. `ComposeView.swift` — new context case + send dispatch + +**New `ComposeContextItem` case** (line 20-26): +```swift +case sharingChatCard(sourceGroupInfo: GroupInfo) +``` +Name is `sharingChatCard` (not channel-specific — MCChat is general). + +**Convenience init** (after `forwardingItems` init at 90-96): +```swift +init(sharingChatCard sourceGroupInfo: GroupInfo) { + self.message = "" + self.parsedMessage = [] + self.preview = .noPreview + self.contextItem = .sharingChatCard(sourceGroupInfo: sourceGroupInfo) + self.voiceMessageRecordingState = .noRecording +} +``` + +**Accessor** (after `forwarding` at 146-150): +```swift +var sharingChatCard: Bool { + switch contextItem { + case .sharingChatCard: return true + default: return false + } +} +``` + +**sendEnabled** (176): add `|| sharingChatCard`. + +**Draft-restore guard** (`ChatView.swift:758`): extend `!composeState.forwarding` to `!composeState.forwarding && !composeState.sharingChatCard`. + +**Send dispatch** in `sendMessageAsync` (before forwarding branch at 1354): +```swift +if case let .sharingChatCard(sourceGroupInfo) = composeState.contextItem { + sent = await shareChatCard(sourceGroupInfo, ttl) +} else if case let .forwardingItems(...) = ... { +``` + +**Helper** inside the same scope: +```swift +func shareChatCard(_ sourceGroupInfo: GroupInfo, _ ttl: Int?) async -> ChatItem? { + let toSendRef: SendRef + switch chat.chatInfo { + case let .direct(contact): + toSendRef = .direct(contactId: contact.contactId) + case let .group(gInfo, scope): + toSendRef = .group(groupId: gInfo.groupId, scope: scope, asGroup: gInfo.useRelays) + default: + return nil + } + do { + var mc = try await apiShareChatMsgContent( + shareChatType: .group, shareChatId: sourceGroupInfo.groupId, toSendRef: toSendRef + ) + // Append user-typed text: backend returns MCChat with text=link; prepend user message if present + if !composeState.message.isEmpty, case let .chat(text, chatLink, ownerSig) = mc { + mc = .chat(text: composeState.message + "\n" + text, chatLink: chatLink, ownerSig: ownerSig) + } + return await send(mc, quoted: nil, live: false, ttl: ttl, mentions: [:]) + } catch { + logger.error("shareChatCard failed: \(error.localizedDescription)") + return nil + } +} +``` + +**Post-send draft-restore** (1411-1417): mirror `wasForwarding` with `wasSharing`. + +### 8. `GroupChatInfoView.swift` — two entry points + composeState plumbing + +**Add to struct**: `@Binding var composeState: ComposeState` and `@State private var showSharePicker = false`. + +**Quick-access button** — in `infoActionButtons()` (line 354-370), add after `channelLinkActionButton` / `addMembersActionButton` branch: +```swift +if groupInfo.useRelays && groupInfo.groupProfile.publicGroup?.groupLink != nil { + InfoViewButton(image: "arrowshape.turn.up.forward", title: "share", width: buttonWidth) { + showSharePicker = true + } + .disabled(!groupInfo.ready) +} +``` +Adjust the `buttonWidth` divisor accordingly (4 → 5 if all four buttons can show). + +**Section button** — in `if groupInfo.useRelays` Section (line 104-125), after existing "Share link" button (115): +```swift +Button { + showSharePicker = true +} label: { + Label("Share via chat", systemImage: "arrowshape.turn.up.forward") +} +``` + +**Sheet** — on the body: +```swift +.sheet(isPresented: $showSharePicker) { + let shareChats = filterChatsToForwardTo(chats: ChatModel.shared.chats) + if #available(iOS 16.0, *) { + ChatItemForwardingView( + title: "Share channel", + chats: shareChats, + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelect: { chat in selectShareDestination(chat) } + ).presentationDetents([.fraction(0.8)]) + } else { + ChatItemForwardingView( + title: "Share channel", + chats: shareChats, + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelect: { chat in selectShareDestination(chat) } + ) + } +} +``` + +**selectShareDestination helper** in the same struct: +```swift +private func selectShareDestination(_ chat: Chat) { + showSharePicker = false + composeState = ComposeState(sharingChatCard: groupInfo) + if chat.id != ChatModel.shared.chatId { + ItemsModel.shared.loadOpenChat(chat.id) + } + dismiss() // dismiss info sheet too +} +``` + +**ChatView.swift:505-517**: pass `composeState: $composeState` to `GroupChatInfoView`. + +### 9. View rendering — `MsgContent.chat` text handling + +**On send**: text = `\n`. Link is the `strEncode groupLink` that the backend includes. If user typed nothing, text = just the link. + +**On display**: when rendering an `MCChat` message, strip the last line from `text` if it equals `chatLink`'s encoded link. This way: +- Old clients (no MCChat support) see text as-is: "hello\nhttps://simplex.chat/g#..." — usable. +- New clients (MCChat support) see "hello" + the rendered card — no redundant link. + +Implement in the card view's text rendering (§10 below). The stripping logic: +```swift +func chatCardText(_ text: String, _ chatLink: MsgChatLink) -> String { + let link = chatLinkStr(chatLink) + if text.hasSuffix("\n" + link) { + return String(text.dropLast(link.count + 1)) + } + return text +} +``` +Where `chatLinkStr` extracts the encoded link from the `MsgChatLink` variant. + +### 10. Card rendering — shared component from `CIGroupInvitationView` + +Extract a reusable **`CICardView`** from `CIGroupInvitationView`. This is a shared component that both views use (not a copy-paste). + +**`CICardView`** (new file `Shared/Views/Chat/ChatItem/CICardView.swift`): +Provides the outer frame: background with chat-tail padding, ZStack with bottomTrailing meta, VStack with: +- Header slot (profile image + name) +- Divider +- Body slot (action text, subtitle) + +Parameterized by: +```swift +struct CICardView: View { + @ObservedObject var chat: Chat + var chatItem: ChatItem + var header: Header + var body_: Body // avoid collision with View.body + var onTap: (() -> Void)? +} +``` + +**`CIGroupInvitationView`** refactored to use `CICardView`: +- Passes `groupInfoView(action)` as header +- Passes invitation text + "Tap to join" as body +- Passes `joinGroup(groupId)` as onTap +- All existing behaviour preserved (status checks, progress indicator, incognito) + +**`CIChatLinkView`** (new file, uses `CICardView`): +- Header: `ProfileImage` from `groupProfile.image` / `profile.image` + display name. Icon = same as chat list (no need to invent): + - `.group` channel (`publicGroup?.groupType == .channel`): `antenna.radiowaves.left.and.right.circle.fill` (from `GroupInfo.chatIconName` when `useRelays`) + - `.group` non-channel: `person.2.circle.fill` (from `GroupInfo.chatIconName` default) + - `.group` business: `briefcase.circle.fill` (from `GroupInfo.chatIconName` business case) + - `.contact` non-bot: `person.crop.circle.fill` (from `Contact.chatIconName`) + - `.contact` business address: `briefcase.circle.fill` + - `.invitation`: `person.crop.circle.fill` +- Body: stripped text (via `chatCardText`) + subtitle line: + - If `ownerSig != nil`: "signed" (secondary color) — same as CLI, it's a claim, verification happens on tap + - Action line: "Tap to open" (primary color) +- onTap (both sent and received): calls `planAndConnect(connLink, linkOwnerSig: ownerSig, ...)` — full connect flow with owner verification + +**Wire-in** at `ChatItemView.swift:73-90`, before the `isShortEmoji` check: +```swift +if case let .chat(_, chatLink, _) = ci.content.msgContent { + CIChatLinkView(chat: chat, chatItem: ci, chatLink: chatLink) +} else if let mc = ... { +``` + +### 11. Connect flow — owner verification in alerts + +**`planAndConnect`** (`NewChatView.swift:1218`): add optional `linkOwnerSig: LinkOwnerSig? = nil` parameter. Pass to `apiConnectPlan(connLink:linkOwnerSig:)`. + +**Alert text** — in the `.ok` branches of `planAndConnect` where the alert is built (lines 1255-1410), extend the alert body with owner verification info: +- `case .verified`: append "Channel owner signature verified." to alert message. +- `case .failed(let reason)`: append "Owner signature verification failed: \(reason)." to alert message. Consider making this a warning-styled alert. +- `nil` (no sig): no additional text. + +This surfaces in the standard connect-confirmation alert before the user taps "Connect" / "Join". + +### 12. Haskell — strip ownerSig on forward + +When a received MCChat message is forwarded (the existing forward-items path in `Library/Commands.hs`), the `dropSig` function already strips `ownerSig` for cross-chat forwarding (binding mismatch). The existing code at `Commands.hs:1000-1006` handles this. **No additional Haskell work for v1.** + +Card forwarding (subscriber shares the card further) naturally produces an unsigned card — the subscriber doesn't have the owner's key. This is correct. + +--- + +## C. Decisions — all resolved + +1. **Icon for "share" button**: `arrowshape.turn.up.forward` — easy to change later. +2. **Text stripping**: strip only the last line if it exactly matches the encoded link. User doesn't control the last line (backend appends it). If user also types the link, the typed copy remains — no special handling needed. +3. **"signed" label on card**: shown unconditionally when `ownerSig != nil`. It's a claim (same as CLI "signed"); verification happens on tap via `planAndConnect`. +4. **Card tap for sent items**: yes, same `planAndConnect` flow for both sent and received. +5. **Icons**: reuse existing `chatIconName` icons from chat list — `antenna.radiowaves.left.and.right.circle.fill` (channel), `person.2.circle.fill` (group), `briefcase.circle.fill` (business), `person.crop.circle.fill` (contact/invitation). Contact address sharing accounts for business address. + +--- + +## D. Items to lock during coding (not user decisions) + +- Exact `chatApiSendCmd` decode shape vs `chatSendCmd` / `processSendMessageCmd` for `apiShareChatMsgContent` response. +- `CICardView` exact slot API: whether to use `@ViewBuilder` closures or generic type params — decide during extraction from `CIGroupInvitationView` based on what minimises the diff. +- `planAndConnect` alert builder structure — may need a helper to format the owner-verification line, to be added inline during the `.ok` branch modifications. +- `chatLinkStr` extraction — how to get the encoded link string from `MsgChatLink` for text-stripping. Likely just `strEncode` equivalent on the `connLink` / `invLink` / `groupLink` field. diff --git a/plans/2026-04-17-kotlin-share-channel-link.md b/plans/2026-04-17-kotlin-share-channel-link.md new file mode 100644 index 0000000000..0133ca377c --- /dev/null +++ b/plans/2026-04-17-kotlin-share-channel-link.md @@ -0,0 +1,561 @@ +# Kotlin/Desktop — Share chat card (MCChat) — Implementation Plan + +Port of iOS commit `f49d98511` to Kotlin multiplatform codebase. Every section maps an iOS change to its Kotlin equivalent with file:line anchors. + +--- + +## 1. Types — `ChatModel.kt` + +### 1.1 Add `LinkOwnerSig` (new type, near line 4551 after `MsgChatLink`) + +```kotlin +@Serializable +data class LinkOwnerSig( + val ownerId: String? = null, + val chatBinding: String, + val ownerSig: String +) +``` + +iOS equivalent: `ChatTypes.swift` `LinkOwnerSig` struct. + +### 1.2 Add `ownerSig` to `MCChat` (line ~4310) + +Current: `class MCChat(override val text: String, val chatLink: MsgChatLink): MsgContent()` +Change to: `class MCChat(override val text: String, val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig? = null): MsgContent()` + +### 1.3 Add `chatLinkStr` property to `MsgContent` (near `text` property) + +```kotlin +val chatLinkStr: String? + get() = (this as? MCChat)?.chatLink?.connLinkStr +``` + +### 1.4 Update `MsgContentSerializer` (lines 4366-4496) + +In the `"chat"` case of the deserializer, add `ownerSig` field: +```kotlin +"chat" -> { + val text = json["text"]?.jsonPrimitive?.content ?: "" + val chatLink = Json.decodeFromJsonElement(json["chatLink"]!!) + val ownerSig = json["ownerSig"]?.let { Json.decodeFromJsonElement(it) } + MCChat(text, chatLink, ownerSig) +} +``` + +In the serializer, add `ownerSig` to the `MCChat` case: +```kotlin +is MCChat -> buildJsonObject { + put("type", "chat") + put("text", mc.text) + put("chatLink", Json.encodeToJsonElement(mc.chatLink)) + mc.ownerSig?.let { put("ownerSig", Json.encodeToJsonElement(it)) } +} +``` + +### 1.5 Add computed properties to `MsgChatLink` (line ~4547) + +The existing `MsgChatLink` sealed class uses `@SerialName` annotations for JSON. The Haskell side uses `taggedObjectJSON` format (`{"type": "group", ...}`). Need to verify the existing `@SerialName` produces the right format — it should, since kotlinx.serialization with `classDiscriminator = "type"` matches. + +Add after the sealed class definition: +```kotlin +sealed class MsgChatLink { + // ... existing cases ... + + val isPublicGroup: Boolean + get() = (this as? Group)?.groupProfile?.publicGroup != null + + val connLinkStr: String + get() = when (this) { + is Group -> connLink + is Contact -> connLink + is Invitation -> invLink + } + + val image: String? + get() = when (this) { + is Group -> groupProfile.image + is Contact -> profile.image + is Invitation -> profile.image + } + + val displayName: String + get() = when (this) { + is Group -> groupProfile.displayName + is Contact -> profile.displayName + is Invitation -> profile.displayName + } + + val fullName: String + get() = when (this) { + is Group -> groupProfile.fullName + is Contact -> profile.fullName + is Invitation -> profile.fullName + } + + val shortDescription: String? + get() { + val s = when (this) { + is Group -> groupProfile.shortDescr + is Contact -> profile.shortDescr + is Invitation -> profile.shortDescr + } + return s?.trim()?.ifEmpty { null } + } + + val iconRes: ImageResource // for ProfileImage icon parameter + get() = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> MR.images.ic_bigtop_updates_padded + else -> MR.images.ic_supervised_user_circle_filled + } + is Contact -> if (business) MR.images.ic_work_filled_padded else MR.images.ic_account_circle_filled + is Invitation -> MR.images.ic_account_circle_filled + } + + val smallIconRes: ImageResource // for inline icon in context/quote views + get() = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> MR.images.ic_bigtop_updates + else -> MR.images.ic_group + } + is Contact -> if (business) MR.images.ic_work else MR.images.ic_person + is Invitation -> MR.images.ic_person + } + + fun infoLine(signed: Boolean): String { + var s = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> generalGetString(MR.strings.channel_link) + else -> generalGetString(MR.strings.group_link) + } + is Contact -> if (business) generalGetString(MR.strings.business_address) else generalGetString(MR.strings.contact_address) + is Invitation -> generalGetString(MR.strings.one_time_link) + } + if (signed) { + s += " " + if (isPublicGroup) generalGetString(MR.strings.from_owner) else generalGetString(MR.strings.signed_parentheses) + } + return s + } +} +``` + +Icons resolved — see "Resolved decisions" section 1. + +### 1.6 Add `OwnerVerification` type (near ConnectionPlan, line ~6844 of SimpleXAPI.kt) + +```kotlin +@Serializable +sealed class OwnerVerification { + @Serializable @SerialName("verified") object Verified : OwnerVerification() + @Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification() +} +``` + +### 1.7 Update plan types with `ownerVerification` (SimpleXAPI.kt lines 6852-6877) + +- `InvitationLinkPlan.Ok`: add `val ownerVerification: OwnerVerification? = null` +- `ContactAddressPlan.Ok`: add `val ownerVerification: OwnerVerification? = null` +- `GroupLinkPlan.Ok`: add `val ownerVerification: OwnerVerification? = null` + +--- + +## 2. API commands — `SimpleXAPI.kt` + +### 2.1 Add `ApiShareChatMsgContent` command class (near line 3626) + +```kotlin +class ApiShareChatMsgContent( + val shareChatType: ChatType, val shareChatId: Long, + val toChatType: ChatType, val toChatId: Long, + val toScope: GroupChatScope?, val sendAsGroup: Boolean +): CC() +``` + +Add `cmdString`: +```kotlin +is ApiShareChatMsgContent -> { + val asGroup = if (sendAsGroup) "(as_group=on)" else "" + "/_share chat content ${chatRef(shareChatType, shareChatId)} ${chatRef(toChatType, toChatId, toScope)}$asGroup" +} +``` + +### 2.2 Add `CR.ChatMsgContent` response (near line 6320) + +```kotlin +@Serializable @SerialName("chatMsgContent") +class ChatMsgContent(val user: UserRef, val msgContent: MsgContent): CR() +``` + +### 2.3 Add `apiShareChatMsgContent` wrapper function (near line 1133) + +```kotlin +suspend fun apiShareChatMsgContent( + rh: Long?, shareChatType: ChatType, shareChatId: Long, + toChatType: ChatType, toChatId: Long, + toScope: GroupChatScope?, sendAsGroup: Boolean +): MsgContent? { + val r = sendCmd(rh, CC.ApiShareChatMsgContent(shareChatType, shareChatId, toChatType, toChatId, toScope, sendAsGroup)) + if (r is CR.ChatMsgContent) return r.msgContent + apiErrorAlert("apiShareChatMsgContent", r) + return null +} +``` + +### 2.4 Update `apiConnectPlan` (line 1488) + +Add `linkOwnerSig: LinkOwnerSig? = null` parameter. Update the `CC.APIConnectPlan` class to include it. Update cmdString to append `sig=` when present. + +--- + +## 3. Compose state — `ComposeView.kt` + `Enums.kt` + +### 3.1 Add `SharedContent.ChatLink` to `Enums.kt` (line 13-18) + +```kotlin +data class ChatLink(val groupInfo: GroupInfo): SharedContent() +``` + +This triggers the share flow: sets `chatModel.sharedContent.value = SharedContent.ChatLink(groupInfo)` → navigates to chat list → user picks destination. + +### 3.2 Add `ChatLinkPreview` to `ComposePreview` (`ComposeView.kt` line 57-63) + +```kotlin +@Serializable class ChatLinkPreview(val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig?): ComposePreview() +``` + +### 3.3 Update `ComposeState` (`ComposeView.kt` line 103-240) + +- `sendEnabled`: add `is ComposePreview.ChatLinkPreview -> true` case +- `linkPreviewAllowed`: add `is ComposePreview.ChatLinkPreview -> false` +- `attachmentPreview`: add `is ComposePreview.ChatLinkPreview -> false` + +### 3.4 Add compose preview rendering + +In the compose area where previews are rendered, add a case for `ChatLinkPreview` that shows `ComposeChatLinkView` (new composable). + +### 3.5 Add send handling + +In the send function, add case for `ChatLinkPreview`: +```kotlin +is ComposePreview.ChatLinkPreview -> { + val linkStr = preview.chatLink.connLinkStr + val text = if (msgText.isEmpty()) linkStr else "$msgText\n$linkStr" + send(MsgContent.MCChat(text, preview.chatLink, preview.ownerSig), ...) +} +``` + +### 3.6 Handle `SharedContent.ChatLink` in `ComposeView.kt` (line 1431-1446, `LaunchedEffect(chatModel.sharedContent.value)`) + +When the destination chat opens with `SharedContent.ChatLink`, the `LaunchedEffect` fires. At this point: +- `chatModel.chatId.value` = destination chat ID +- `shared.groupInfo` = source group (what we're sharing) +- The current chat's `ChatInfo` provides destination type/id/scope for the API call + +```kotlin +is SharedContent.ChatLink -> { + // chat variable is available in ComposeView scope — it's the destination chat + val cInfo = chat.chatInfo + val sendAsGroup = cInfo.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false + withBGApi { + val mc = chatModel.controller.apiShareChatMsgContent( + chat.remoteHostId, ChatType.Group, shared.groupInfo.groupId, + cInfo.chatType, cInfo.apiId, + cInfo.groupChatScope(), sendAsGroup + ) + if (mc is MsgContent.MCChat) { + composeState.value = composeState.value.copy( + preview = ComposePreview.ChatLinkPreview(mc.chatLink, mc.ownerSig) + ) + } else if (mc != null) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_sharing_channel), + mc.toString() + ) + } + } +} +``` + +Note: `chat` is available as a parameter in the ComposeView composable scope. `withBGApi` is needed because `apiShareChatMsgContent` is a suspend function and `LaunchedEffect` already runs in a coroutine but the API call should use the standard error handling pattern. + +### 3.7 Handle `SharedContent.ChatLink` in `ShareListView.kt` (line 33-54) + +Add filtering case in the `when (sharedContent)` block: +```kotlin +is SharedContent.ChatLink -> { + hasSimplexLink = true // chat cards ARE simplex links, prohibited by SimplexLinks group pref +} +``` + +This means in `ShareListNavLinkView` (line 44): `simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)` — groups where simplex links are disabled will show as prohibited (disabled row + alert on tap). Direct chats and local notes are unaffected (line 30-31 don't check simplex links for direct). + +### 3.8 Handle `SharedContent.ChatLink` in `ShareListNavLinkView.kt` (line 28-67) + +The existing `when (chat.chatInfo)` dispatch handles click actions per chat type. For `SharedContent.ChatLink`, the click action (line 37 `directChatAction`, line 54 `groupChatAction`) opens the destination chat. `ComposeView`'s `LaunchedEffect` (§3.6) then picks up the `SharedContent.ChatLink` and sets up the compose preview. + +No changes needed to `ShareListNavLinkView` click handlers — they already open the correct chat. The `SharedContent.ChatLink` is consumed by `ComposeView`. + +### 3.9 Handle `SharedContent.ChatLink` in `ShareListToolbar` (line 142-147) + +Add title for the share list toolbar: +```kotlin +is SharedContent.ChatLink -> stringResource(MR.strings.share_channel) +``` + +### 3.10 Handle back navigation from share list with `SharedContent.ChatLink` (line 126-133) + +When user taps back on the share list with `SharedContent.ChatLink`, should navigate back to the source chat (like Forward navigates back to `fromChatInfo.id`): +```kotlin +if (sharedContent is SharedContent.ChatLink) { + chatModel.chatId.value = sharedContent.groupInfo.id +} +``` + +--- + +## 4. New composables + +### 4.1 `ComposeChatLinkView.kt` (new file) + +Near `ComposeView.kt`. Shows ProfileImage + displayName + optional shortDescription. Cancel button. Mirrors iOS `ComposeChatLinkView`. + +### 4.2 `CIChatLinkHeader.kt` (new file) + +Near `FramedItemView.kt`. Shows profile header (image + name + fullName), shortDescription, info line, "Tap to open" + meta. Mirrors iOS `CIChatLinkHeader`. + +--- + +## 5. Message rendering — `FramedItemView.kt` + +### 5.1 Add `MCChat` case in content dispatch (line ~296-341) + +After the `MCLink` case: +```kotlin +is MsgContent.MCChat -> { + val hasText = mc.text != mc.chatLink.connLinkStr + CIChatLinkHeader(chatItem = ci, chatLink = mc.chatLink, ownerSig = mc.ownerSig, hasText = hasText) + // tap gesture → planAndConnect(mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig) + if (hasText) { + CIMarkdownText(..., stripLink = mc.chatLink.connLinkStr) + } +} +``` + +### 5.2 Add `MCChat` case in quote dispatch (line ~142-183) + +```kotlin +is MsgContent.MCChat -> { + val prefix = buildAnnotatedString { + append(mc.chatLink.displayName) + append(if (mc.text != mc.chatLink.connLinkStr) " - " else "") + } + CIQuotedMsgView(qi, stripLink = mc.chatLink.connLinkStr, prefix = prefix) + // + small icon +} +``` + +### 5.3 Add `stripLink` parameter to text rendering + +`CIMarkdownText` / `MarkdownText` (TextItemView.kt) needs `stripLink: String? = null` parameter. Inside, strip the text and formattedText before rendering. + +Add `stripTextLink` and `stripFormattedTextLink` functions near `MarkdownText`: + +```kotlin +fun stripTextLink(text: String, link: String): String = + if (text == link) "" + else if (text.endsWith("\n$link")) text.dropLast(link.length + 1) + else text + +fun stripFormattedTextLink(ft: List?, link: String): List? { + if (ft == null || ft.isEmpty() || ft.last().text != link) return ft + val result = ft.toMutableList() + result.removeLast() + val i = result.lastIndex + if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + result[i] = result[i].copy(text = result[i].text.dropLast(1)) + if (result[i].text.isEmpty()) result.removeLast() + } + return result.ifEmpty { null } +} +``` + +--- + +## 6. Chat list preview — `ChatPreviewView.kt` + +### 6.1 Add content preview for `MCChat` (line ~293-337) + +```kotlin +is MsgContent.MCChat -> { + SmallContentPreview(borderColor = if (mc.chatLink.image != null) ...) { + ProfileImage(mc.chatLink.image, mc.chatLink.iconName, size) + // onClick → planAndConnect + } +} +``` + +### 6.2 Update text preview (line ~217-290) + +For `MCChat`, show `displayName + description` instead of raw text: +```kotlin +is MsgContent.MCChat -> { + val descr = mc.chatLink.shortDescription?.let { "\n$it" } ?: "" + itemText = mc.chatLink.displayName + descr + formattedText = null +} +``` + +--- + +## 7. Context/forwarding view — `ContextItemView.kt` + +### 7.1 Add `MCChat` attachment icon (line 75-84, `fun attachment()`) + +```kotlin +is MsgContent.MCChat -> mc.chatLink.smallIconRes +``` + +This returns the small icon (e.g., `MR.images.ic_bigtop_updates` for channels). The icon is rendered inline via the existing `inlineContent` mechanism in `MessageText` (line 42-58). + +### 7.2 Add `MCChat` case in `ContextMsgPreview` or `MessageText` (line 87-89) + +For MCChat, `MessageText` needs: +1. The attachment icon (from §7.1) — rendered inline by existing mechanism +2. `prefix` with `chatLink.displayName + " - "` (or just displayName if no text) — `MarkdownText` already has `prefix: AnnotatedString?` +3. `stripLink = chatLink.connLinkStr` — strips the link from text + +Modify `ContextMsgPreview` (line 87-89) or add a special case: +```kotlin +fun ContextMsgPreview(contextItem: ChatItem, lines: Int) { + val mc = contextItem.content.msgContent + if (mc is MsgContent.MCChat) { + val hasText = contextItem.text != mc.chatLink.connLinkStr + val prefix = buildAnnotatedString { append(mc.chatLink.displayName + if (hasText) " - " else "") } + MessageText(contextItem, mc.chatLink.smallIconRes, lines, prefix = prefix, stripLink = mc.chatLink.connLinkStr) + } else { + MessageText(contextItem, remember(contextItem.id) { attachment(contextItem) }, lines) + } +} +``` + +This requires `MessageText` to accept optional `prefix` and `stripLink` parameters and pass them to `MarkdownText`. + +--- + +## 8. Group link / info views + +### 8.1 `GroupLinkView.kt` (line ~27) + +Add parameter: `groupInfo: GroupInfo? = null`. +Add "Share via chat" button when `groupInfo?.groupProfile?.publicGroup != null`. +Button action: `chatModel.sharedContent.value = SharedContent.ChatLink(groupInfo)` + close modals + navigate to chat list. + +### 8.2 `GroupChatInfoView.kt` + +Add "Share via chat" button in the channel link section (next to existing "Share link" button). +Button action: same as 8.1 — sets `SharedContent.ChatLink` and navigates. + +No `composeState` parameter needed (unlike iOS) — the `SharedContent` pattern handles state transfer without bindings. + +### 8.3 Channel creation (equivalent of `AddChannelView`) + +Find the Kotlin channel creation flow and pass `groupInfo` to `GroupLinkView` so "Share via chat" is available during creation. + +### 8.4 Share flow summary (no separate `shareChatLink` function needed) + +Unlike iOS which has a separate `shareChatLink` free function (due to sheet-based navigation), Kotlin's flow is: + +1. User taps "Share via chat" → `chatModel.sharedContent.value = SharedContent.ChatLink(groupInfo)` + `chatModel.chatId.value = null` (navigates to chat list showing `ShareListView`) +2. `ShareListView` shows filtered chats with `hasSimplexLink = true` prohibition +3. User picks destination → `directChatAction`/`groupChatAction` opens the chat +4. `ComposeView`'s `LaunchedEffect` fires (§3.6) → calls `apiShareChatMsgContent` → sets `ComposePreview.ChatLinkPreview` +5. User types optional text, taps Send +6. Send dispatch (§3.5) constructs `MCChat(text + link, chatLink, ownerSig)` and sends + +The API call happens in `ComposeView`'s `LaunchedEffect`, not in a separate function. Error handling: if the API fails, show alert and clear `sharedContent`. + +For the **channel creation flow** (no chat open yet): when `SharedContent.ChatLink` is consumed in `ComposeView` and the API call succeeds, the preview is set directly. No draft fallback needed — the chat IS already open at that point (the user picked it from the share list). + +--- + +## 9. Connect flow + +### 9.1 Update `planAndConnect` equivalent + +Add `linkOwnerSig: LinkOwnerSig? = null` parameter. Pass to `apiConnectPlan`. Thread `ownerVerification` from plan result to connect alerts. + +### 9.2 Add `ownerVerificationMessage` function + +```kotlin +fun ownerVerificationMessage(ov: OwnerVerification?): String? = when (ov) { + is OwnerVerification.Verified -> generalGetString(MR.strings.link_signature_verified) + is OwnerVerification.Failed -> "⚠️ " + String.format(generalGetString(MR.strings.signature_verification_failed), ov.reason) + null -> null +} +``` + +### 9.3 Update connect alerts + +Add `information: String? = null` parameter to `AlertManager.showOpenChatAlert` (`AlertManager.kt` line 271). Render as a separate `Text` below subtitle with `MaterialTheme.colors.onSurface` color (not secondary — more prominent). + +Update `showPrepareContactAlert` (ConnectPlan.kt line 572) and `showPrepareGroupAlert` (line 612) to accept and pass `ownerVerification`. Thread from `planAndConnectTask` `.Ok` cases. + +--- + +## 10. String resources + +Add to `strings.xml` (all platforms). No collisions found with existing keys: +- `chat_link_channel` = "Channel link" +- `chat_link_group` = "Group link" +- `chat_link_business_address` = "Business address" +- `chat_link_contact_address` = "Contact address" +- `chat_link_one_time` = "One-time link" +- `chat_link_from_owner` = "(from owner)" +- `chat_link_signed` = "(signed)" +- `owner_verification_passed` = "Link signature verified." +- `owner_verification_failed` = "⚠️ Signature verification failed: %s." +- `error_sharing_channel` = "Error sharing channel" +- `share_via_chat` = "Share via chat" +- `share_channel` = "Share channel" +- `tap_to_open` = "Tap to open" + +--- + +## Resolved decisions (from investigation) + +### 1. Icon resource names (verified from `ChatModel.kt` and MR/images/) +- **Channel**: `MR.images.ic_bigtop_updates_padded` (used in `GroupInfo.chatIconName` when `useRelays`) +- **Group**: `MR.images.ic_supervised_user_circle_filled` (used in `GroupInfo.chatIconName` default) +- **Business**: `MR.images.ic_work_filled_padded` (used in `GroupInfo.chatIconName` business case) +- **Contact**: `MR.images.ic_account_circle_filled` (used in `Contact.chatIconName`) +- **Small (inline text) icons**: `MR.images.ic_bigtop_updates` (channel), `MR.images.ic_group` (group), `MR.images.ic_work` (business), `MR.images.ic_person` (contact/invitation) + +### 2. MsgChatLink JSON serialization +Existing `@Serializable` sealed class with `@SerialName` annotations already produces `{"type": "group", ...}` format. No custom serializer needed (confirmed by user). Keep existing pattern. + +### 3. Forwarding/sharing picker pattern +Kotlin uses `SharedContent` + navigate to chat list, NOT a sheet picker: +- Forward: sets `chatModel.sharedContent.value = SharedContent.Forward(items, fromInfo)` + `chatModel.chatId.value = null` (returns to chat list) +- Share: add `SharedContent.ChatLink(groupInfo: GroupInfo)` case → sets `sharedContent` → user picks chat from `ShareListView` → opens chat with `ChatLinkPreview` in compose + +`ShareListView.kt` (line 44) dispatches on `SharedContent` type for filtering. Add `SharedContent.ChatLink` case there with `hasSimplexLink = true` filtering. + +### 4. Navigation after share +- `ModalManager.closeAllModalsEverywhere()` dismisses all modals +- Setting `chatModel.chatId.value = chatId` navigates to a chat +- For the share flow: `shareChatLink` calls API → on success → `ModalManager.closeAllModalsEverywhere()` → sets `composeState` preview → sets `chatModel.chatId.value = destChat.id` + +### 5. Draft mechanism (verified at `ChatModel.kt:203-204`) +Same as iOS: `chatModel.draft: MutableState` and `chatModel.draftChatId: MutableState`. Used in `ComposeView.kt:435-444` for save/restore. Same fallback pattern as iOS for the channel creation flow. + +### 6. `planAndConnect` (verified at `ConnectPlan.kt:24-48`) +Single function `suspend fun planAndConnect(rhId, shortOrFullLink, close, cleanup, filterKnownContact, filterKnownGroup)` in `ConnectPlan.kt`. Add `linkOwnerSig: LinkOwnerSig? = null` parameter. Thread to `apiConnectPlan`. Thread `ownerVerification` to alert functions. + +Alert functions: `showPrepareContactAlert` (line 572) and `showPrepareGroupAlert` (line 612) use `AlertManager.privacySensitive.showOpenChatAlert(...)` which has `subtitle: String?`. Add `information: String? = null` parameter. + +### 7. Forwarding view parameterization +No `ChatItemForwardingView` to parameterize — Kotlin uses `ShareListView` which dispatches on `SharedContent` type. Add a new `SharedContent.ChatLink` case. `ShareListView` filters chats and shows the list. When user picks a chat, `ComposeView` reads `sharedContent` and sets compose state accordingly (line 1439: `is SharedContent.Forward -> composeState.value = ...`). Add handling for `SharedContent.ChatLink`. + +### 8. String resource naming +Need to check existing strings to avoid collisions. Use `chat_link_channel`, `chat_link_group`, etc. prefix pattern to avoid collision with existing `group_link` string. diff --git a/plans/2026-04-19-directory-public-groups.md b/plans/2026-04-19-directory-public-groups.md new file mode 100644 index 0000000000..1b23234d14 --- /dev/null +++ b/plans/2026-04-19-directory-public-groups.md @@ -0,0 +1,324 @@ +# Directory Service — Public Group Registration via Chat Cards + +## Goal + +Enable directory registration of public groups (channels and future group types) via MCChat cards shared in DM with the bot. Replaces the admin-invitation flow with a signature-verified card flow. + +## Background + +### Current group registration flow +1. Owner invites bot as admin member +2. Bot joins, creates group link, asks owner to add link to welcome message +3. Owner updates profile with link → bot sends for admin approval +4. Admin approves → group listed + +This requires the bot to be admin. Public groups don't need this — they already have a public link, and ownership is proven via `ownerSig` on the MCChat card. + +### Public group identity +- `PublicGroupProfile {groupType :: GroupType, groupLink :: ShortLinkContact, publicGroupId :: B64UrlByteString}` +- `publicGroupId = sha256(rootKey)` — immutable identity +- `GroupType`: currently `GTChannel`, adding `GTGroup` for forward compatibility +- `GroupKeys {publicGroupId, groupRootKey, memberPrivKey}` — owner's signing keys +- `ownerId` in `LinkOwnerSig` = `B64UrlByteString (unMemberId memberId)` — the owner's MemberId bytes + +### ownerId-to-member mapping +- `LinkOwnerSig.ownerId = Just (B64UrlByteString unMemberId)` — same raw bytes as `MemberId` +- `createLinkOwnerMember` (called during `APIConnectPreparedGroup`, Commands.hs:2129) creates a member record with `memberRole = GROwner`, `memberStatus = GSMemUnknown`, `memberContactId = Nothing` +- `GroupMemberId` is available immediately after `APIConnectPreparedGroup` +- `getGroupMemberIdViaMemberId db user gInfo (MemberId ownerId)` looks up `GroupMemberId` from `MemberId` + +### Owner member activation +When a relay announces the pre-created `GSMemUnknown` member, `CEvtUnknownMemberAnnounced` fires (Subscriber.hs:2872, via `xGrpMemNew`). The member's profile and role are updated from the announcement's `MemberInfo` (via `updateUnknownMemberAnnounced`, Groups.hs:3010) — the role reflects the member's actual current role, not the pre-created `GROwner`. This event is not currently handled in directory Events.hs. + +### connectPlan and known groups +`apiConnectPlan` with `linkOwnerSig` returns: +- `GLPOk {groupSLinkData_, ownerVerification}` — new group +- `GLPKnown {groupInfo}` — bot already a member +- `GLPOwnLink` / `GLPConnectingProhibit` / `GLPConnectingConfirmReconnect` / `GLPNoRelays` + +**Gap**: For `GLPKnown`, `groupShortLinkPlan` short-circuits via `knownLinkPlans` — never resolves link data, never verifies signature. + +**Fix**: Add an optional parameter to `APIConnectPlan` (before `sig=`, since JSON must be last) that forces link data re-resolution even for known groups. With this parameter, `GLPKnown` includes `ownerVerification` and freshly loaded `groupSLinkData`. The loaded profile may differ from stored — the bot treats the server's current data as authoritative and updates its stored profile accordingly. + +**Future**: Add a signed version counter to link data to detect rollback attacks (malicious server serving old signed profiles). The bot would store the highest version seen and reject/flag version reductions. For now, the server is treated as authoritative. + +### Owner-contact association via APIConnectPreparedGroup +`createLinkOwnerMember` (called during `APIConnectPreparedGroup`) currently creates owner members with `memberContactId = Nothing`. Add an optional `(contactId, ownerId)` paired parameter to `APIConnectPreparedGroup`: when the link was received in a DM, pass the sender's `contactId` and the `ownerId` from `LinkOwnerSig`. The core sets `memberContactId` on the specific owner member whose `memberId` matches `ownerId`. + +This makes ALL existing directory event routing work: `DEContactRoleChanged`, `DEContactRemovedFromGroup`, `DEContactLeftGroup` all resolve via `memberContactId` — no new event types needed for owner tracking. + +Also benefits regular UI: when a user taps an owner's link in a DM, the contact association is created, improving the experience (e.g., showing the contact in the group member list). + +## Registration flow for public groups + +1. Owner taps "Share via chat" on their public group → sends MCChat card to bot in DM +2. Bot receives `CEvtNewChatItems` with `MCChat` content in direct chat → `DEChatLinkReceived` +3. Bot validates card (see validation matrix) +4. Bot calls `apiConnectPlan` with `connLink`, `linkOwnerSig`, and force-resolve flag +5. On `GLPOk` + `Verified`: bot replies "Joining {channel/group} {name}..." and joins via `APIPrepareGroup` then `APIConnectPreparedGroup` (passing owner's `contactId` and `ownerId`). On error: replies "Error joining {channel/group} {name}, please re-send the link!" (same pattern as existing group flow, Service.hs:368-370). +6. After `APIConnectPreparedGroup`, bot stores `dbOwnerMemberId` (via `getGroupMemberIdViaMemberId` — `createLinkOwnerMember` created the record during connect). Registration status: `GRSProposed`. +7. When `CEvtUnknownMemberAnnounced` fires for the owner member → `DEOwnerMemberAnnounced` → bot transitions to `GRSPendingApproval`, replies "Joined {channel/group} {name}. Registration is pending approval — it may take up to 48 hours.", sends to admins for approval +8. Admin approves → `GRSActive` + +## Scenario matrix: card received in DM + +### Event + +One event: `DEChatLinkReceived { contact :: Contact, chatItemId :: ChatItemId, chatLink :: MsgChatLink, ownerSig :: Maybe LinkOwnerSig }`. + +Handler validates and replies based on content. + +### Card validation (handler level) + +| Condition | Action | +|---|---| +| `chatLink` is not MCLGroup, or MCLGroup but no `publicGroup` in profile | Reply: "Only channels can be added to directory via link." | +| MCLGroup + publicGroup but `ownerSig` is `Nothing` | Reply: "To add a {channel/group} to directory you must be the owner." | +| MCLGroup + publicGroup + `ownerSig` is `Just` | Proceed to connectPlan | + +### connectPlan results + +| Plan result | ownerVerification | Action | +|---|---|---| +| `GLPOk` + sLinkData | `Verified` | Reply "Joining {channel/group} {name}...", join (with contactId + ownerId), register as `GRSProposed` | +| `GLPOk` + sLinkData | `Failed reason` | Reply: "Link signature verification failed: {reason}.\nYou must be the {channel/group} owner to register it." | +| `GLPOk` + sLinkData | `Nothing` | Reply: "Error: could not verify {channel/group} ownership. Please report it to directory admins." | +| `GLPOk` no sLinkData | — | Reply: "Error: no {channel/group} information available via the link." | +| `GLPKnown` | `Verified` | Bot already member — handle as re-registration (see below) | +| `GLPKnown` | `Failed reason` | Reply: "Link signature verification failed: {reason}.\nYou must be the {channel/group} owner to register it." | +| `GLPKnown` | `Nothing` | Reply: "Error: could not verify ownership." | +| `GLPConnectingProhibit` | — | Reply: "Already connecting to this {channel/group}." | +| `GLPConnectingConfirmReconnect` | — | Reply: "Already connecting to this {channel/group}." | +| `GLPOwnLink` | — | Log error. Reply: "Unexpected error. Please report it to directory admins." | +| `GLPNoRelays` | — | Reply: "{Channel/Group} has no active relays. Please try again later." | + +### Owner member activation after joining + +Bot is in `GRSProposed`. The pre-created owner member has `GSMemUnknown` status. When the relay announces this member, `CEvtUnknownMemberAnnounced` fires → mapped to `DEOwnerMemberAnnounced` in directory events. + +| Condition | Action | +|---|---| +| `CEvtUnknownMemberAnnounced` for member matching `dbOwnerMemberId`, announced role is `GROwner` | Transition to `GRSPendingApproval`, notify submitting contact, send for admin approval | +| `CEvtUnknownMemberAnnounced` for member matching `dbOwnerMemberId`, announced role < `GROwner` | Reply: "The signing key does not belong to a current owner. Registration cancelled." Set `GRSRemoved`. | +| Owner member never announced | Registration stays in `GRSProposed`. No timeout — manual cleanup via admin. | + +### Re-registration (GLPKnown — bot already member, signature verified at plan) + +With the `connectPlan` fix, `GLPKnown` now includes `ownerVerification` and fresh `groupSLinkData`. Only proceed if `Verified`. + +Bot extracts `ownerId`, looks up member via `getGroupMemberIdViaMemberId`, confirms `memberRole >= GROwner` AND `memberStatus` is active (not `GSMemUnknown`). The pre-created member has `GROwner` role from creation, so role alone is insufficient — the member must have been announced by a relay to confirm actual presence in the group. + +Look up existing `GroupReg` by `groupId`: + +| Existing registration | Ownership verified | Action | +|---|---|---| +| No GroupReg found | Yes | Create new registration as `GRSPendingApproval` | +| GroupReg exists, same owner contact | Yes | Handle based on current status (see status matrix) | +| GroupReg exists, different contact | Sender is verified owner AND previous registrant no longer owner (check `dbOwnerMemberId` member's current role) | Transfer: update `dbContactId` and `dbOwnerMemberId`, proceed as same-owner case | +| GroupReg exists, different contact | Sender is verified owner BUT previous registrant still owner | Reply: "This {channel/group} is registered by another owner." | +| GroupReg exists, different contact | Sender NOT verified owner | Reply: "You must be the {channel/group} owner to register it." Additionally: check if previous registrant (via `dbOwnerMemberId`) is still owner. If not → suspend (`GRSSuspendedBadRoles`). | + +### Re-registration by same owner — status matrix + +| Current status | Action | +|---|---| +| `GRSProposed` | Only if owner member is active (not `GSMemUnknown`): transition to `GRSPendingApproval`, send for approval. If still `GSMemUnknown`: reply "Waiting for owner to connect to the {channel/group}." | +| `GRSPendingConfirmation` | Transition to `GRSPendingApproval`, send for approval (only if previously registered via admin-invitation flow) | +| `GRSPendingUpdate` | Transition to `GRSPendingApproval`, send for approval (only if previously registered via admin-invitation flow) | +| `GRSPendingApproval n` | Check if profile changed (fresh profile from connectPlan vs bot's current DB). If yes: increment approval ID, re-send. If no: reply "Already pending approval." | +| `GRSActive` | Check if profile changed. If yes: transition to `GRSPendingApproval`, re-send. If no: reply "Already listed in the directory." | +| `GRSSuspended` | Reply: "{Channel/Group} is suspended by admin. Contact support." | +| `GRSSuspendedBadRoles` | Ownership re-verified at plan. Transition to `GRSPendingApproval`, send for approval. | +| `GRSRemoved` | Re-register as `GRSPendingApproval` | + +### Profile change detection + +For re-registration: compare the freshly loaded profile (from connectPlan's re-resolved `groupSLinkData`) against the group's current profile in the bot's database. + +For XGrpInfo updates: re-resolve the link via `apiConnectPlan` with `resolve=on`, compare freshly loaded link profile against bot's stored profile. + +Uses the same `sameProfile` comparison as existing group flow (Service.hs:491-494), extended with `publicGroup` field: `displayName`, `fullName`, `shortDescr`, `image`, `description`, `memberAdmission`, `publicGroup` — any difference triggers re-approval. The `publicGroup` field includes `groupLink` (ShortLinkContact), so link regeneration by the owner also triggers re-approval. + +## Profile updates via XGrpInfo (bot is subscriber) + +Bot receives `DEGroupUpdated` when any member updates the group profile. Works for subscribers. + +For public groups: skip "link in welcome message" check. First check if the profile actually changed using the same `sameProfile` comparison as for regular groups (`displayName`, `fullName`, `shortDescr`, `image`, `description`, `memberAdmission`). Only if changed, call `apiConnectPlan` with `resolve=on` to re-resolve the link data. Compare the resolved link profile against the bot's stored profile. + +Note: `xGrpInfo` (Subscriber.hs:3172) prevents `publicGroup` removal and `publicGroupId` changes for channels — these cases can never occur. The `groupLink` (ShortLinkContact) CAN change if the owner regenerates the link; the bot's DB is updated via XGrpInfo and subsequent re-resolution uses the current link. + +| Current status | Profile changed (link data vs stored) | Action | +|---|---|---| +| `GRSProposed` | Any | No action (waiting for owner activation) | +| `GRSPendingApproval n` | Yes | Increment approval ID, re-send for approval | +| `GRSPendingApproval n` | No | No action | +| `GRSActive` | Yes | Transition to `GRSPendingApproval`, notify owner, re-send | +| `GRSActive` | No | No action | +| `GRSSuspended` | Any | No action | +| `GRSSuspendedBadRoles` | Any | No action | +| `GRSRemoved` | Any | No action | + +## Owner tracking + +### Owner-contact association + +When the bot connects via `APIConnectPreparedGroup` with the submitting contact's `contactId` and `ownerId`, the core sets `memberContactId` on the specific pre-created owner member whose `memberId` matches `ownerId`. This makes all existing event routing work: `DEContactRoleChanged`, `DEContactRemovedFromGroup`, `DEContactLeftGroup` resolve via `memberContactId`. + +### Owner changes + +| Event | Detection | Action | +|---|---|---| +| Owner loses owner role | `DEContactRoleChanged` (works via `memberContactId` set at connect time) | Transition to `GRSSuspendedBadRoles`, notify | +| Owner leaves group | `DEContactLeftGroup` | Transition to `GRSRemoved`, notify, leave group | +| Owner removed from group | `DEContactRemovedFromGroup` | Transition to `GRSRemoved`, notify, leave group | +| Non-owner sends card, current registrant no longer owner | Re-registration flow detects stale ownership | Suspend (`GRSSuspendedBadRoles`). Non-owner's card also checked: if their `ownerId` resolves to a non-owner member, and the current registrant is also not owner → suspend. | +| New owner sends card, current registrant no longer owner | Re-registration flow, verified | Transfer registration | + +## Commands for public group registrations + +Bot is subscriber (not admin): +- `/filter` — Reply: "This command is not available for public groups." +- `/role` — Reply: "This command is not available for public groups." +- `/link` — Show `PublicGroupProfile.groupLink` with appropriate message. +- `/delete` — Remove registration, bot leaves group (`APILeaveGroup`). +- `/list` — Works as before, includes public group registrations. + +## De-registration + +| Event | Action | +|---|---| +| Owner sends `/delete ID:NAME` | Delete registration, reply confirmation, leave group | +| Bot removed (`DEServiceRemovedFromGroup`) | Set `GRSRemoved`, notify | +| Group deleted (`DEGroupDeleted`) | Set `GRSRemoved`, notify | +| Owner leaves (`DEContactLeftGroup`) | Set `GRSRemoved`, notify, leave group | +| Owner removed (`DEContactRemovedFromGroup`) | Set `GRSRemoved`, notify, leave group | +| Admin sends `/suspend ID:NAME` | Set `GRSSuspended`, notify, do NOT leave group | + +Bot leaves group only for public group registrations (regular groups preserve existing behavior). + +## Code changes + +### 1. GroupType — add GTGroup + +`Types.hs`: +```haskell +data GroupType = GTChannel | GTGroup | GTUnknown Text +``` + +### 2. connectPlan — force-resolve parameter + +Add optional parameter to `APIConnectPlan` (before `sig=`): `resolve=on`. When present, `groupShortLinkPlan` skips the `knownLinkPlans` shortcut and always resolves link data. `GLPKnown` extended with `ownerVerification` and `groupSLinkData_`: +```haskell +GLPKnown {groupInfo :: GroupInfo, ownerVerification :: Maybe OwnerVerification, groupSLinkData_ :: Maybe GroupShortLinkData} +``` + +Parser: `/_connect plan [resolve=on] [sig=]` + +### 3. APIConnectPreparedGroup — optional (contactId, ownerId) + +Add optional paired `(contactId, ownerId)` parameter to `APIConnectPreparedGroup`. When present, `createLinkOwnerMember` (called during connect, Commands.hs:2129) sets `memberContactId` on the specific owner member whose `memberId` matches the provided `ownerId`. + +Current parser (Commands.hs:5045): `/_connect group # [incognito=on] []` +New parser: `/_connect group # [contact= owner=] [incognito=on] []` + +`contact` and `owner` are paired — both required together. `ownerId` identifies which pre-created owner member gets the `memberContactId` set (multiple owners possible via OwnerAuth chain). + +Current type (Controller.hs:479): `APIConnectPreparedGroup GroupId IncognitoEnabled (Maybe MsgContent)` +New type: `APIConnectPreparedGroup GroupId (Maybe (ContactId, B64UrlByteString)) IncognitoEnabled (Maybe MsgContent)` + +This also benefits the UI: when tapping an owner's link in a DM, the contactId is threaded through the connect alert to `APIConnectPreparedGroup`, creating the association. + +### 4. Events.hs — new events + +`DEChatLinkReceived` — fires for ALL MCChat messages in DM (any `MsgChatLink` variant, signed or unsigned): +```haskell +| DEChatLinkReceived + { contact :: Contact, + chatItemId :: ChatItemId, + chatLink :: MsgChatLink, + ownerSig :: Maybe LinkOwnerSig + } +``` + +`DEOwnerMemberAnnounced` (from `CEvtUnknownMemberAnnounced`): +```haskell +| DEOwnerMemberAnnounced GroupInfo GroupMember GroupMember + -- ^ groupInfo, unknownMember, announcedMember +``` + +In `crDirectoryEvent_`, extend `CEvtNewChatItems` for direct chat: +```haskell +(MCChat {chatLink, ownerSig}, Nothing) -> DEChatLinkReceived ct ciId chatLink ownerSig +``` + +Add `CEvtUnknownMemberAnnounced` handler: +```haskell +CEvtUnknownMemberAnnounced {groupInfo, unknownMember, announcedMember} -> + Just $ DEOwnerMemberAnnounced groupInfo unknownMember announcedMember +``` + +### 5. Service.hs — public group link handler + +`deChatLinkReceived`: validates card, calls `apiConnectPlan` (with `resolve=on`), handles per scenario matrix. The link string comes from `MCLGroup.connLink` (`ShortLinkContact`) formatted as URI — passed via command string, parsed inside the handler. For `GLPOk` + `Verified`: joins (with contactId + ownerId), stores `dbOwnerMemberId`, registers as `GRSProposed`. On join error: replies to owner (same pattern as Service.hs:368-370). For `GLPKnown` + `Verified`: re-registration flow. + +### 6. Service.hs — owner member announced handler + +`deOwnerMemberAnnounced`: checks if the announced member's `GroupMemberId` matches `dbOwnerMemberId` of any `GRSProposed` registration. If yes and role is `GROwner`: transition to `GRSPendingApproval`, notify, send for approval. If role < `GROwner`: cancel. + +### 7. Service.hs — deGroupUpdated changes + +For public groups (`groupProfile.publicGroup` present), skip "link in welcome message" check. On profile change, call `apiConnectPlan` with `resolve=on` to get authoritative link data. Compare resolved profile against stored. If different, trigger re-approval. + +### 8. Service.hs — command restrictions and de-registration + +Check `groupProfile.publicGroup` for `/filter`, `/role`. On `/delete` for public groups, call `APILeaveGroup`. Same for owner departure/removal events. + +### 9. Help message update + +``` +To register a channel, share its link with this bot using the "Share via chat" button. +To register a group, invite this bot as admin. +``` + +### 10. Approval message for admins + +Include: group name, description, image, member count, "Registered via link sharing (signed by owner)", publicGroupId. + +### 11. Tests + +**Registration:** +- Share signed card → bot joins, owner announced, pending approval +- Share unsigned card → "must be owner" reply +- Share non-MCLGroup / non-public-group card → "only channels" reply +- Share card with invalid signature → rejection with reason +- Share card, owner never announced → stays GRSProposed +- Share card, owner announced but role < GROwner → cancelled + +**Re-registration (GLPKnown, verified):** +- Same owner re-shares, active → "already listed" +- Same owner re-shares, pending → "already pending" +- Same owner re-shares with changed profile → re-approval +- Different contact, verified owner, previous no longer owner → transfer +- Different contact, verified owner, previous still owner → "registered by another owner" +- Different contact, not owner → rejection + stale ownership check +- Same owner re-shares while GRSProposed, owner still GSMemUnknown → "waiting for owner" + +**Profile updates:** +- XGrpInfo on active public group → re-approval +- XGrpInfo on pending public group → increment approval ID +- XGrpInfo on public group skips link-in-welcome check + +**Owner tracking (via contactId association):** +- Owner role changed → suspension +- Owner leaves → removal, bot leaves +- Owner removed → removal, bot leaves + +**De-registration:** +- `/delete` by owner → removal, bot leaves +- Bot removed → removal +- Admin `/suspend` → suspension, bot stays + +**Commands:** +- `/filter` on public group → disabled +- `/role` on public group → disabled +- `/link` on public group → shows public link diff --git a/plans/2026-04-29-member-profile-sending-channels.md b/plans/2026-04-29-member-profile-sending-channels.md new file mode 100644 index 0000000000..2ee36b676e --- /dev/null +++ b/plans/2026-04-29-member-profile-sending-channels.md @@ -0,0 +1,237 @@ +# Plan: Member Profile Sending in Channels + +## Context + +In channels (relayed groups), subscribers don't know profiles of other subscribers. When subscriber A sends a reaction/message that gets forwarded to subscriber B, B creates an "unknown member" record with a synthesized name. This degrades UX — subscribers see "unknown member" instead of real profiles. + +We can't eagerly send all subscriber profiles to all subscribers (doesn't scale to 100K+ channels). We need on-demand, deduplicated profile delivery: the relay tracks which subscribers have received which sender's profile, and prepends profile info when forwarding a message from a sender the recipient doesn't know. + +## Approach: Vector-tracked profile delivery + +### Core idea + +Each member record on the relay stores a `sent_profile_vector BLOB` — a byte vector where position `i` represents the recipient at `index_in_group = i`. Value 0 = profile not sent, non-zero = sent. + +When the relay forwards a batch (possibly from multiple senders): +1. Collect distinct senders in the batch. Load each sender's `sent_profile_vector`. +2. For each cursor-batch of recipients, partition into two groups: + - **Knows all**: recipient's index is marked as sent in every sender's vector → gets bare batch + - **Needs profiles**: recipient's index is unmarked in at least one sender's vector → gets batch with all sender profiles prepended as `XGrpMemNew` elements +3. Update all senders' vectors to mark recipients who were delivered to. + +When a sender updates their profile (relay receives `XInfo`): clear that sender's `sent_profile_vector`, so the updated profile is re-sent on next forwarded message. + +In steady state, most long-standing subscribers have received all active senders' profiles from previous deliveries. The "knows all" group dominates; the "needs profiles" group consists mainly of newcomers and is small. The partition converges quickly to near-zero redundancy. + +### Why this approach + +**Considered alternatives:** +- **Include profile in every FwdSender**: Wastes bandwidth sending profile on every message. +- **Subscriber requests profile from relay**: Adds latency (round-trip) and new request-response protocol complexity. +- **Separate delivery worker** (using commented-out `DWSMemberProfileUpdate` stubs): Harder to guarantee ordering (profile must arrive before message). +- **Bloom filters / epoch-based**: Same storage complexity as vectors, more complex to implement, probabilistic (false positives). + +**Advantages of prepend-to-batch approach:** +- Profile + forwarded message arrive in a single SMP message (no extra 16KB block overhead) +- SMP guarantees in-order processing within a batch +- No protocol changes — `XGrpMemNew` is already handled by subscribers +- No subscriber-side code changes for receiving + +### Design decisions to discuss + +**1. Bit-level vs byte-level vector** + +Byte-per-position is consistent with `member_relations_vector` but uses 8x more space. For 100K members: byte=100KB/sender, bit=12.5KB/sender. With 1000 active senders: byte=100MB, bit=12.5MB. Byte is simpler; bit is more space-efficient. **Recommend: byte-level for consistency, optimize to bit-level later if needed.** + +**2. Multi-sender batch profile strategy** + +Channels batch tasks from multiple senders into one job (`singleSenderGMId_ = Nothing`). Profile tracking requires knowing which senders' profiles each recipient has seen. Three approaches: + +**Option A — Per-sender precise targeting (rejected)**: For a batch with senders {A, B, C}, construct a separate batch variant for each combination of missing profiles: recipients missing only A get `profile(A) + batch`, those missing A and C get `profile(A) + profile(C) + batch`, etc. This produces up to 2^k batch variants for k senders — a combinatorial explosion that is fundamentally at odds with batching efficiency. Constructing nearly per-recipient blobs is worse than not batching at all. **Rejected.** + +**Option B — All-or-nothing profile sidecar (probably preferable)**: Partition recipients into two groups: those who know ALL senders (get bare batch) and those missing ANY sender profile (get all sender profiles prepended). Only 2 batch variants regardless of sender count. Preserves current multi-sender batching — no changes to `getNextDeliveryTasks`. Some recipients may receive profiles they already know, but XGrpMemNew is idempotent (~200-500 bytes per profile), and this redundancy only occurs at the rare intersection of a multi-sender batch AND a partially-informed recipient. In steady state, long-standing subscribers know all active senders, so the "needs profiles" group shrinks to just newcomers. +- Pros: preserves current batching, smaller diff (no `Store/Delivery.hs` changes), 2 variants only, fast convergence to zero-redundancy steady state +- Cons: slight redundancy for partially-informed recipients in multi-sender batches (rare and transient) + +**Option C — Force single-sender jobs**: Add `sender_group_member_id` filter to `getNextDeliveryTasks` for channels, same as fully connected groups. Each delivery job has exactly one sender, so profile sidecar is always one XGrpMemNew. Clean binary partition with zero redundancy. +- Pros: zero redundant profiles, simplest per-job logic +- Cons: changes delivery task query logic, slightly less batching efficiency (separate jobs per sender), though multi-sender batches are rare anyway + +--- + +## Detailed changes + +The code below assumes Option B (all-or-nothing sidecar). Option C would simplify section 4 (always one sender) and add a query change in `Store/Delivery.hs`. + +### 1. Database migration + +New migration file: `M{date}_sent_profile_vector.hs` + +```sql +ALTER TABLE group_members ADD COLUMN sent_profile_vector BLOB; +``` + +**Files:** +- `src/Simplex/Chat/Store/SQLite/Migrations/M{date}_sent_profile_vector.hs` (new) +- `src/Simplex/Chat/Store/SQLite/Migrations.hs` (register migration) +- `src/Simplex/Chat/Store/Postgres/Migrations/M{date}_sent_profile_vector.hs` (new) +- `src/Simplex/Chat/Store/Postgres/Migrations.hs` (register migration) +- `simplex-chat.cabal` (add module) + +### 2. Sent profile vector operations + +New functions in `src/Simplex/Chat/Store/Groups.hs`: + +```haskell +getSentProfileVector :: DB.Connection -> GroupMemberId -> IO ByteString + +-- Expands vector if needed (same expand-on-write pattern as setRelation in Types/MemberRelations.hs) +markProfilesSentToMembers :: DB.Connection -> GroupMemberId -> [Int64] -> IO () + +clearSentProfileVector :: DB.Connection -> GroupMemberId -> IO () +``` + +Pure helpers: +```haskell +isProfileSentTo :: ByteString -> Int64 -> Bool +isProfileSentTo vec idx + | idx < 0 || fromIntegral idx >= B.length vec = False + | otherwise = B.index vec (fromIntegral idx) /= 0 + +markSentPositions :: [Int64] -> ByteString -> ByteString +``` + +### 3. Profile batch element encoding + +New functions in `src/Simplex/Chat/Messages/Batch.hs`: + +```haskell +-- Prepend an element to an existing binary batch body +-- batchBody format: '=' ( )* +-- Increments count and inserts element at front without parsing/re-encoding existing elements +prependBatchElement :: ByteString -> ByteString -> ByteString + +-- Encode XGrpMemNew as a batch-ready element for a given member +-- Constructs ChatMessage with XGrpMemNew (memberToMemberInfo member) Nothing +encodeMemberProfileElement :: VersionRangeChat -> GroupMember -> ByteString +``` + +Check whether `memberInfo` or similar helper already exists for constructing `MemberInfo` from `GroupMember`. + +### 4. Delivery job worker changes + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` — `processDeliveryJob` / `sendBodyToMembers` + +In the channel path (`useRelays' gInfo`, `DJSGroup {}`): + +**Before the cursor loop**, collect distinct senders from delivery tasks and load their profile data: +```haskell +senderProfiles <- forM (nub senderGMIds) $ \senderGMId -> do + sender <- withStore $ \db -> getGroupMemberById db vr user senderGMId + vec <- withStore' $ \db -> getSentProfileVector db senderGMId + pure (senderGMId, sender, vec) + +let profileElements = map (\(_, sender, _) -> encodeMemberProfileElement vr sender) senderProfiles + extBody = foldl' (flip prependBatchElement) body profileElements +``` + +**In the cursor loop**, partition recipients: +```haskell +sendLoop bucketSize cursorGMId_ = do + mems <- withStore' $ \db -> getGroupMembersByCursor ... + unless (null mems) $ do + if null senderProfiles + then deliver body mems + else do + let knowsAll m = all (\(_, _, vec) -> isProfileSentTo vec (indexInGroup' m)) senderProfiles + (hasAllProfiles, needsProfiles) = partition knowsAll mems + unless (null needsProfiles) $ deliver extBody needsProfiles + unless (null hasAllProfiles) $ deliver body hasAllProfiles + forM_ senderProfiles $ \(senderGMId, _, _) -> + withStore' $ \db -> markProfilesSentToMembers db senderGMId + (map indexInGroup' deliveredMems) + ... +``` + +Only mark vector bits for members who were actually delivered to (those with `readyMemberConn`), not all members in the cursor batch — otherwise members without ready connections get marked as "profile sent" without receiving it. + +### 5. Clear vector on profile update + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` — `xInfoMember` + +After `processMemberProfileUpdate`, if the group uses relays and the user is the relay, clear the sender's vector: + +```haskell +xInfoMember gInfo m p' msg brokerTs = do + void $ processMemberProfileUpdate gInfo m p' (Just (msg, brokerTs)) + when (useRelays' gInfo && isRelay (membership gInfo)) $ + withStore' $ \db -> clearSentProfileVector db (groupMemberId' m) + pure $ memberEventDeliveryScope m +``` + +When the vector is cleared and XInfo is forwarded, the delivery prepends XGrpMemNew before the forwarded XInfo. Recipients process both — XGrpMemNew creates/updates the member record, then XInfo updates it again. Slightly redundant but correct and harmless. + +### 6. Set vector bits when relay announces members at join time + +When a new subscriber joins and the relay sends `XGrpMemNew` for owners/existing announced members, set the corresponding bits in those members' `sent_profile_vector` for the new subscriber's index. The exact location needs to be identified during implementation — look for where the relay processes new member joins and sends XGrpMemNew announcements. + +### 7. Update channel tests + +**File:** `tests/ChatTests/Groups.hs` + +Update `testChannels1RelayDeliver` and related tests: +- After cath sends a reaction, dan and eve should no longer see "forwarded a message from an unknown member, creating unknown member record cath" +- Instead, they receive cath's profile via XGrpMemNew (processed silently before the reaction) +- Test assertions for dan and eve should show the reaction with cath's name + +Add new tests: +- Profile update triggers re-announcement (clear vector → re-send on next message) +- New subscriber joining after a sender has been active gets the profile on first forwarded message +- Multiple senders: each sender's profile is independently tracked + +--- + +## Files to modify + +| File | Change | +|------|--------| +| `src/Simplex/Chat/Store/SQLite/Migrations/M{date}_sent_profile_vector.hs` | New migration | +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | Register migration | +| `src/Simplex/Chat/Store/Postgres/Migrations/M{date}_sent_profile_vector.hs` | New migration | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | Register migration | +| `simplex-chat.cabal` | Add migration module | +| `src/Simplex/Chat/Store/Groups.hs` | Vector CRUD operations | +| `src/Simplex/Chat/Messages/Batch.hs` | `prependBatchElement`, `encodeMemberProfileElement` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Delivery job worker profile logic, xInfoMember vector clear | +| `src/Simplex/Chat/Store/Delivery.hs` | Only if Option C chosen (single-sender jobs) | +| `tests/ChatTests/Groups.hs` | Update channel tests | + +## Subscriber-side impact + +**None required for receiving.** The subscriber already handles: +- `XGrpMemNew` from relay → creates member record with full profile +- `XGrpMsgForward` → finds existing member record +- Mixed batch elements (direct + forwarded) processed in order + +The only subscriber-side change is the test expectations. + +## Verification + +1. **Build**: `cabal build --ghc-options=-O0` +2. **Run channel tests**: `cabal test simplex-chat-test --test-options='-m "channels"'` +3. **Verification scenarios**: + - New subscriber sends reaction → other subscribers receive profile + reaction (no "unknown member") + - Subscriber updates profile → next message re-sends updated profile + - New subscriber joins after sender was active → first forwarded message from that sender includes profile + +## Known considerations + +1. **Vector expansion**: A member with `index_in_group = 100000` causes vector expansion to 100KB. `markSentPositions` handles this via the same expand-on-write pattern as `setRelation` in `Types/MemberRelations.hs`. + +2. **Delivery filtering**: Only mark vector bits for members who were actually delivered to (those with `readyMemberConn`). The `deliver` function filters for ready connections — if `markProfilesSentToMembers` marked all cursor members including those without connections, disconnected members would never receive the profile on reconnection. + +3. **Scope**: Profile tracking applies only to `DJSGroup` scope. Support scope (`DJSMemberSupport`) delivers to moderators who already know members — no profile tracking needed there. + +4. **Sender exclusion**: `getGroupMembersByCursor` already filters out the sender via `singleSenderGMId_` in the WHERE clause, so no self-profile issue arises. + +5. **Race: vector clear vs delivery**: If profile update and message delivery overlap, the delivery sees an empty vector and sends the profile. This is correct — the delivery uses the current (updated) profile, so recipients get the new profile. diff --git a/plans/2026-04-29-relay-management.md b/plans/2026-04-29-relay-management.md new file mode 100644 index 0000000000..a44a9f0b2c --- /dev/null +++ b/plans/2026-04-29-relay-management.md @@ -0,0 +1,415 @@ +# Relay Management Improvements + +## Problem Statement + +Channel owners currently can only add relays during channel creation (`APINewPublicGroup`). Once a channel is created, there is no way to: +1. Add a new relay to an existing channel. +2. Remove a relay from an existing channel. +3. Have relays and subscribers automatically detect and synchronize relay state changes. + +Several TODO markers in the codebase (`[relays]`) confirm these are planned but unimplemented. The `runRelayGroupLinkChecks` function (Commands.hs:4729) is a stub. The LINK event handler (Subscriber.hs:1308-1309) has a TODO for relay deletion detection. No `APIAddGroupRelays` command exists. + +## Solution Summary + +### Add relay to existing channel + +New `APIAddGroupRelays` command that reuses the existing `addRelays` function (Commands.hs:3887, in `processChatCommand`'s `where` block). The `addRelays` flow is asynchronous: after the invitation is sent (RSNew→RSInvited), the relay responds with its relay link (→RSAccepted), and the CON event handler (Subscriber.hs:861-864) calls `setGroupLinkDataAsync` to publish the new relay link. The LINK callback then promotes RSAccepted→RSActive. + +### Remove relay from existing channel + +Use the existing `APIRemoveMembers` command, extended with relay-specific handling. In channels, `APIRemoveMembers` already sends `XGrpMemDel` to all relay members via `sendGroupMessages` (the `memberSendAction` routing ensures the message goes to relays only, which forward it to subscribers). This is the correct approach: broadcasting the removal through *other* relays ensures all subscribers learn about the removal even if the removed relay is malicious and refuses to notify them. Link data synchronization serves as a backup mechanism. + +The extension needed: when removing a relay member, also update its `GroupRelay.relay_status` to `RSInactive`. Currently `APIRemoveMembers` updates `GroupMember` status (via `deleteOrUpdateMemberRecordIO`) and calls `updatePublicGroupData` (which updates link data), but does not touch the `GroupRelay` record. + +### State synchronization + +Three actors synchronize via the group link data on the SMP server: + +- **Owner**: publishes the authoritative relay list in link data via `setGroupLinkData`. The `getConnectedGroupRelays` function (which filters by `member_status = GSMemConnected AND relay_status IN (RSAccepted, RSActive)`) determines which relays appear in link data. +- **Relay**: `runRelayGroupLinkChecks` (implement the stub) periodically fetches group link data to confirm its own link is present. If absent → self-cleanup. +- **Subscriber**: when opening a channel, the UI already calls `APIGetUpdatedGroupLinkData` (Commands.hs:1777) which fetches link data from the SMP server. This handler will be extended to also synchronize relay state: connect to newly discovered relays, disconnect from removed relays. + +--- + +## Detailed Technical Design + +### 1. Relay Deactivation on Member Removal + +**File**: `src/Simplex/Chat/Library/Internal.hs` (lines 1804-1821) + +Two member-removal primitives exist: `deleteOrUpdateMemberRecordIO` (IO, line 1808) and `updateMemberRecordDeleted` (CM, line 1816). Both run in DB context. Relay deactivation belongs inside these functions so it runs in the same transaction as the member status change. + +**New helper** in Internal.hs: + +```haskell +deactivateRelayIfNeeded :: DB.Connection -> GroupMember -> IO () +deactivateRelayIfNeeded db m = + when (isRelay m) $ do + relay_ <- runExceptT $ getGroupRelayByGMId db (groupMemberId' m) + forM_ relay_ $ \relay -> void $ updateRelayStatus db relay RSInactive +``` + +**Extend `deleteOrUpdateMemberRecordIO`** (line 1808): + +```haskell +deleteOrUpdateMemberRecordIO db user@User {userId} gInfo m = do + (gInfo', m') <- deleteSupportChatIfExists db user gInfo m + checkGroupMemberHasItems db user m' >>= \case + Just _ -> updateGroupMemberStatus db userId m' GSMemRemoved + Nothing -> deleteGroupMember db user m' + deactivateRelayIfNeeded db m + pure gInfo' +``` + +**Extend `updateMemberRecordDeleted`** (line 1816): + +```haskell +updateMemberRecordDeleted user@User {userId} gInfo m newStatus = + withStore' $ \db -> do + (gInfo', m') <- deleteSupportChatIfExists db user gInfo m + updateGroupMemberStatus db userId m' newStatus + deactivateRelayIfNeeded db m + pure gInfo' +``` + +This covers all four call sites: +- `delMember` in `deleteMemsSend` (Commands.hs:2896) — owner removing relay via `APIRemoveMembers` +- `deleteOrUpdateMemberRecord` in `xGrpMemDel` (Subscriber.hs:3123) — receiving relay deletion notification +- `updateMemberRecordDeleted` in `xGrpMemDel` (Subscriber.hs:3121) — relay deletion with forwarding +- `updateMemberRecordDeleted` in `xGrpLeave` (Subscriber.hs:3168) — relay leaves voluntarily + +For subscribers who have no `GroupRelay` records, `getGroupRelayByGMId` returns `Left`, `forM_` on `Left` is a no-op — safe. + +**Cleanup**: remove the now-redundant separate relay deactivation in `xGrpLeave` (Subscriber.hs:3169-3172): + +```haskell +-- Before: +gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft +when (isRelay m) $ + withStore' $ \db -> do + relay_ <- runExceptT $ getGroupRelayByGMId db (groupMemberId' m) + forM_ relay_ $ \relay -> void $ updateRelayStatus db relay RSInactive +gInfo'' <- updatePublicGroupData user gInfo' + +-- After: +gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft +gInfo'' <- updatePublicGroupData user gInfo' +``` + +**`APIRemoveMembers` requires no changes** — `delMember` (line 2891) already calls `deleteOrUpdateMemberRecordIO` which now handles relay deactivation internally. The `getConnectedGroupRelays` query filters by both `member_status = GSMemConnected` and `relay_status IN (RSAccepted, RSActive)`, so the removed relay is excluded from link data when `updatePublicGroupData` runs (line 2828-2829). + +**iOS UI**: The remove button is currently hidden on the relay member info page by an explicit guard in `adminDestructiveSection` (GroupMemberInfoView.swift:646: `mem.memberRole != .relay`). Changes needed: + +1. **Remove the relay guard** — change the condition to allow relay members to be removed. The `canBeRemoved()` permission check (ChatTypes.swift:2868) already validates that the user has sufficient role. + +2. **Relay-specific button text** — the `removeMemberButton` (line 708) currently shows `"Remove subscriber"` for channels (`groupInfo.useRelays`). Add a relay branch: when `mem.memberRole == .relay`, show `"Remove relay"` instead. + +3. **Relay-specific alert text** — `showRemoveMemberAlert` (GroupChatInfoView.swift:926) currently shows `"Remove subscriber?"` / `"Subscriber will be removed from channel"` for channels. Add a relay branch: `"Remove relay?"` / `"Relay will be removed from channel"`. + +4. **Last active relay warning** — when removing a relay, check if it's the last active relay (count relay members with `memberCurrent` status in `chatModel.groupMembers`). If so, show a warning: `"This is the last active relay. Removing it will prevent message delivery to subscribers."` The count is available client-side from `chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent }`. + +No new API command needed for removal — the existing `apiRemoveMembers` is used. + +### 2. New `APIAddGroupRelays` Command + +**File**: `src/Simplex/Chat/Controller.hs` + +```haskell +-- New command +| APIAddGroupRelays GroupId (NonEmpty Int64) -- group ID, chat_relay_ids + +-- New responses +| CRGroupRelaysAdded { user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay] } +| CRGroupRelaysAddFailed { user :: User, addRelayResults :: [AddRelayResult] } +``` + +**File**: `src/Simplex/Chat/Library/Commands.hs` + +New handler: + +``` +APIAddGroupRelays groupId relayIds -> withUser $ \user -> withGroupLock "addGroupRelays" groupId $ do + -- 1. Validate: user is owner, group uses relays + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + assertUserGroupRole gInfo GROwner + unless (useRelays' gInfo) $ throwCmdError "group does not use relays" + + -- 2. Get group link (needed for relay invitation) + gLink <- withFastStore $ \db -> getGroupLink db user gInfo + sLnk <- case connShortLink' (connLinkContact gLink) of + Just sl -> pure sl + Nothing -> throwChatError $ CEException "group link has no short link" + + -- 3. Load requested relay configs + relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds) + + -- 4. Reuse existing addRelays function (Commands.hs:3887) + results <- addRelays user gInfo sLnk relays + + -- 5. Check results + case partitionEithers (map snd results) of + ([], _) -> do + -- Relay connection is asynchronous: invitation sent (RSNew→RSInvited). + -- When relay responds (RSAccepted) and connects (CON at Subscriber.hs:861-864), + -- setGroupLinkDataAsync is called automatically to add the relay link. + -- The LINK callback then promotes RSAccepted→RSActive. + relays' <- withFastStore $ \db -> liftIO $ getGroupRelays db gInfo + pure $ CRGroupRelaysAdded user gInfo gLink relays' + _ -> do + let toRelayResult (r, Left e) = AddRelayResult r (Just e) + toRelayResult (r, Right _) = AddRelayResult r Nothing + pure $ CRGroupRelaysAddFailed user (map toRelayResult results) +``` + +Key points: +- Uses `withGroupLock` to prevent concurrent relay modifications. +- Reuses `addRelays` unchanged — it handles the full invitation flow (create relay member, create GroupRelay record, send `XGrpRelayInv`, update status RSNew→RSInvited). +- No synchronous `setGroupLinkData` call needed: the CON event handler calls `setGroupLinkDataAsync` when the relay connects. + +### 3. Extend `APIGetUpdatedGroupLinkData` for Subscriber Relay Sync + +**File**: `src/Simplex/Chat/Library/Commands.hs` (lines 1777-1787) + +Currently this handler fetches link data from the SMP server and updates group profile and member count. It is called by the iOS UI when a non-owner subscriber opens a channel (ChatView.swift:750). The `ConnLinkData` it receives already contains the relay list in `UserContactData.relays`. + +Extend the handler to also synchronize relay state: + +``` +APIGetUpdatedGroupLinkData groupId -> withUser $ \user -> do + gInfo@GroupInfo {groupProfile = p} <- withFastStore $ \db -> getGroupInfo db vr user groupId + case p of + GroupProfile {publicGroup = Just PublicGroupProfile {groupLink = sLnk}} | useRelays' gInfo -> do + (_, cData@(ContactLinkData _ UserContactData {relays = currentRelayLinks})) <- + getShortLinkConnReq nm user sLnk + groupSLinkData_ <- liftIO $ decodeLinkUserData cData + gInfo' <- case groupSLinkData_ of + Just sLinkData -> fst <$> updateGroupFromLinkData user gInfo sLinkData + _ -> pure gInfo + -- Sync relay state for non-owner subscribers + when (memberRole' (membership gInfo) /= GROwner) $ + syncSubscriberRelays nm user gInfo' currentRelayLinks + pure $ CRGroupInfo user gInfo' + _ -> throwCmdError "group link data not available" +``` + +**Parameterize `connectToRelay`** — move from `APIConnectPreparedGroup`'s `where` block to `processChatCommand`'s `where` block so both `APIConnectPreparedGroup` and subscriber sync can use it. The captured closure variables become explicit parameters or are derived internally: + +``` +-- In processChatCommand's where block (for connectViaContact access). +-- connectViaContact ignores incognito param for relay groups (Commands.hs:3545-3546), +-- using incognitoMembershipProfile gInfo instead. +connectToRelay :: User -> GroupInfo -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ()) +connectToRelay user gInfo relayLink = do + vr <- chatVersionRange + gVar <- asks random + relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink + r <- tryAllErrors $ do + (fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- + getShortLinkConnReq nm user relayLink + relayLinkData_ <- liftIO $ decodeLinkUserData cData + case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> + withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p + _ -> throwChatError $ CEException "relay link: no relay link data or entity id" + let cReq = linkConnReq fd + relayLinkToConnect = CCLink cReq (Just relayLink) + void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) False relayLinkToConnect Nothing Nothing + relayMember' <- withFastStore $ \db -> getGroupMember db vr user (groupId' gInfo) (groupMemberId' relayMember) + pure (relayLink, relayMember', r) +``` + +`getCreateRelayForMember` stays outside `tryAllErrors` — the member must be available for re-read even on failure (for `RelayConnectionResult` reporting). `APIConnectPreparedGroup` calls `mapConcurrently (connectToRelay user gInfo') relays` as before. + +**New function** `syncSubscriberRelays` in `processChatCommand`'s scope (reuses `connectToRelay`): + +``` +syncSubscriberRelays :: NetworkRequestMode -> User -> GroupInfo -> [ShortLinkContact] -> CM () +syncSubscriberRelays nm user gInfo currentRelayLinks = tryAllErrors $ do + vr <- chatVersionRange + -- Get local relay members (all members with GRRelay role, regardless of status) + localRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + -- GroupMember.relayLink :: Maybe ShortLinkContact (Types.hs:1041) + -- Set by getCreateRelayForMember (Store/Groups.hs:1392) when subscriber connects to a relay. + let activeRelayMembers = filter memberCurrent localRelayMembers + localRelayLinks = mapMaybe relayLink activeRelayMembers + + -- Discover new relays (in link data but not among active local relay members) + let newRelayLinks = filter (`notElem` localRelayLinks) currentRelayLinks + forM_ newRelayLinks $ \rlnk -> tryAllErrors $ + void $ connectToRelay user gInfo rlnk + + -- Discover removed relays (active local relay member whose link is absent from link data) + forM_ activeRelayMembers $ \m -> + case relayLink m of + Just rlnk | rlnk `notElem` currentRelayLinks -> + tryAllErrors $ do + deleteMemberConnection m + void $ updateMemberRecordDeleted user gInfo m GSMemRemoved + _ -> pure () +``` + +**Note on `getCreateRelayForMember` idempotency**: This function queries `WHERE m.relay_link = ?` without filtering by member status (Store/Groups.hs:1379). If a relay was previously removed (GSMemRemoved) and is later re-added by the owner, `getCreateRelayForMember` will return the old removed member. During implementation, verify whether the member status needs to be reset before reconnecting, or whether `connectViaContact` handles this correctly. + +### 4. LINK Event Handler — Detect Relay Removal (Owner) + +**File**: `src/Simplex/Chat/Library/Subscriber.hs` (lines 1308-1317) + +Replace the TODO with relay removal detection. The LINK callback fires when this owner updates link data (via `setGroupLinkData` / `setConnShortLink`). Currently multi-owner channels are not supported, so this only fires after the same owner's own actions (add/remove relay, profile update). When multi-owner support is added, another owner's link data update on the SMP server would need a separate mechanism (e.g., periodic link data fetch or subscription) for this owner to learn about it — the LINK callback only fires in response to this client's own `setConnShortLink` calls. + +```haskell +updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool) -> IO ([GroupRelay], Bool) +updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed) = + case relayLink of + Just rLink + | rLink `elem` relayLinks && relayStatus == RSAccepted -> do + -- Relay link present in link data, promote to active + relay' <- updateRelayStatus db relay RSActive + pure (relay' : acc, True) + | rLink `elem` relayLinks -> pure (relay : acc, changed) + | relayStatus `elem` [RSAccepted, RSActive, RSInactive] -> do + -- Relay link ABSENT from link data — set to inactive. + -- TODO [multi-owner] When multi-owner channels are supported, another owner removing + -- a relay updates link data on the SMP server, but this owner won't receive a LINK + -- callback for it (LINK only fires in response to own setConnShortLink calls). + -- A separate mechanism will be needed for cross-owner relay state synchronization. + relay' <- updateRelayStatus db relay RSInactive + pure (relay' : acc, True) + _ -> pure (relay : acc, changed) +``` + +After the same owner's `APIRemoveMembers` call, the relay is already `RSInactive` before `updatePublicGroupData` triggers the LINK callback. The guard matches `RSInactive` but `updateRelayStatus` is idempotent (RSInactive→RSInactive is a no-op write). + +### 5. Relay Self-Check (`runRelayGroupLinkChecks`) + +**File**: `src/Simplex/Chat/Library/Commands.hs` (lines 4729-4735) + +Implement the stub. The existing `startRelayChecks` (Commands.hs:225-233) already launches `runRelayGroupLinkChecks` as an async task via `relayGroupLinkChecksAsync`. The stub currently does `pure ()` and exits immediately. Replace with a periodic loop following the `cleanupManager` pattern (Commands.hs:4643): + +``` +runRelayGroupLinkChecks :: User -> CM () +runRelayGroupLinkChecks user = do + initialDelay <- asks (initialCleanupManagerDelay . config) + liftIO $ threadDelay' initialDelay + interval <- asks (cleanupManagerInterval . config) -- or a dedicated config field + forever $ do + flip catchAllErrors eToView $ do + lift waitChatStartedAndActivated + checkRelayGroups + liftIO $ threadDelay' $ diffToMicroseconds interval + where + checkRelayGroups = do + vr <- chatVersionRange + -- Get all groups where this client is a relay (relay_own_status is set and not RSInactive) + relayGroups <- withFastStore' $ \db -> getRelayOwnGroups db vr user + forM_ relayGroups $ \gInfo -> tryAllErrors $ do + case publicGroup (groupProfile gInfo) of + Just PublicGroupProfile {groupLink = sLnk} -> do + -- getShortLinkConnReq' returns (FixedLinkData, ConnLinkData m). + -- ConnLinkData 'CMContact = ContactLinkData VersionRangeSMPA UserContactData + -- (NOT UserContactLinkData which is for the LINK event's auData) + (_, ContactLinkData _ UserContactData {relays = relayLinks}) <- + getShortLinkConnReq' NRMBackground user sLnk + -- Check if our own relay link is present + gLink_ <- withFastStore' $ \db -> runExceptT $ getGroupLink db user gInfo + case gLink_ of + Right GroupLink {connLinkContact = CCLink _ (Just ourLink)} -> + if ourLink `elem` relayLinks + then do + -- Our link is present — promote to RSActive if still RSAccepted + gInfo' <- withFastStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSAccepted RSActive + when (relayOwnStatus gInfo' /= relayOwnStatus gInfo) $ + toView $ CEvtGroupRelayUpdated user gInfo' (membership gInfo') + else do + -- Our link is ABSENT — we have been removed + withFastStore' $ \db -> updateRelayOwnStatus_ db gInfo RSInactive + -- Per RFC: relay should forward "relay is deleted" notification to + -- connected members, then clean up. The x.grp.mem.del from owner + -- may also arrive and trigger cleanup independently. + _ -> pure () + _ -> pure () +``` + +**New store function** in `Store/Groups.hs`: + +```haskell +getRelayOwnGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo] +-- SELECT groups WHERE relay_own_status IS NOT NULL AND relay_own_status != 'inactive' +``` + +--- + +## State Synchronization Summary + +``` + SMP Server (group link data) + ┌──────────────────────────────┐ + │ UserContactData { │ + │ relays: [relay1, relay2] │ + │ } │ + └──────────┬───────────────────┘ + │ + ┌───────────────┼───────────────┐ + │ writes │ reads │ reads + ▼ ▼ ▼ + Owner Relay (self) Subscriber + setGroupLinkData runRelayGroup syncSubscriber + (via updatePublic LinkChecks Relays (in + GroupData) APIGetUpdated + GroupLinkData) + Triggers: Triggers: Triggers: + - Add relay - Periodic check - Opening channel + - Remove member (existing UI flow) + - Profile update +``` + +**Owner writes** → SMP server is updated → **Relays and Subscribers read** → discover changes → adjust local state. + +**Key design principle**: The `XGrpMemDel` message broadcast through other relays is the primary notification mechanism for relay removal. Subscribers receive it promptly via their connected relays. Link data synchronization via `APIGetUpdatedGroupLinkData` is the backup mechanism — it catches cases where the `XGrpMemDel` was missed (subscriber offline, relay connection issues) and handles new relay discovery. + +--- + +## Edge Cases and Failure Recovery + +1. **Add relay fails (network)**: `addRelays` handles temporary errors. The relay remains in RSInvited; owner can retry or the relay will process the pending invitation when it comes online. + +2. **Removed relay is malicious / refuses to notify subscribers**: Not a problem. `APIRemoveMembers` sends `XGrpMemDel` to all relay members. Other (non-malicious) relays forward it to subscribers. Subscribers learn about the removal regardless of the removed relay's behavior. + +3. **Remove relay, all relays offline**: `XGrpMemDel` is queued for delivery. Link data is still updated. Subscribers will discover the change via `APIGetUpdatedGroupLinkData` next time they open the channel. + +4. **Owner removes last relay**: Subscribers lose message delivery. Owner must add a new relay. Subscribers will discover the new relay via `syncSubscriberRelays` when they next open the channel. + +5. **Relay goes offline permanently**: Owner removes it via `APIRemoveMembers`. New subscribers won't see it in link data. Existing subscribers with connections to this relay will experience connection failures. On next channel open, `syncSubscriberRelays` discovers the relay link is gone and marks it removed locally. + +6. **Subscriber discovers new relay via link data**: `syncSubscriberRelays` calls `connectToRelay` (same function used by `APIConnectPreparedGroup`). + +--- + +## Implementation Order + +1. **Relay deactivation in member-removal primitives** — add `deactivateRelayIfNeeded` helper to `deleteOrUpdateMemberRecordIO` and `updateMemberRecordDeleted` in Internal.hs; remove redundant code from `xGrpLeave`. +2. **LINK handler relay-removal detection** — implement the TODO in Subscriber.hs to detect absent relay links. +3. **`APIAddGroupRelays`** — new command, reuses `addRelays`. +4. **`runRelayGroupLinkChecks`** — relay self-check implementation. +5. **Extend `APIGetUpdatedGroupLinkData`** — add `syncSubscriberRelays` for subscriber relay synchronization. +6. **iOS UI** — ChannelRelaysView add/remove buttons, AddGroupRelayView sheet, API functions. + +## Files Changed (Backend) + +| File | Change | +|------|--------| +| `src/Simplex/Chat/Controller.hs` | Add `APIAddGroupRelays` command; add `CRGroupRelaysAdded`, `CRGroupRelaysAddFailed` responses | +| `src/Simplex/Chat/Library/Internal.hs` | Add `deactivateRelayIfNeeded` helper; extend `deleteOrUpdateMemberRecordIO` and `updateMemberRecordDeleted` to call it | +| `src/Simplex/Chat/Library/Commands.hs` | Parameterize and move `connectToRelay` to `processChatCommand` scope; implement `APIAddGroupRelays` handler; implement `runRelayGroupLinkChecks`; extend `APIGetUpdatedGroupLinkData`; add `syncSubscriberRelays` (all in `processChatCommand` scope for `connectViaContact` access) | +| `src/Simplex/Chat/Library/Subscriber.hs` | Fix LINK handler relay removal detection; remove redundant relay deactivation from `xGrpLeave` | +| `src/Simplex/Chat/Store/Groups.hs` | Add `getRelayOwnGroups` | + +## Files Changed (iOS) + +| File | Change | +|------|--------| +| `apps/ios/Shared/Model/AppAPITypes.swift` | Add `APIAddGroupRelays` command, `CRGroupRelaysAdded`/`CRGroupRelaysAddFailed` responses | +| `apps/ios/Shared/Model/SimpleXAPI.swift` | Add `apiAddGroupRelays` function | +| `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift` | Remove `.relay` guard from `adminDestructiveSection` (line 646); add relay-specific button/alert text; add last-active-relay warning | +| `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift` | Add relay branch to `showRemoveMemberAlert` text | +| `apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift` | Add relay button | +| `apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift` | NEW: relay selection sheet | diff --git a/plans/2026-04-29-relay-request-retry-limit.md b/plans/2026-04-29-relay-request-retry-limit.md new file mode 100644 index 0000000000..34d0f4c9f0 --- /dev/null +++ b/plans/2026-04-29-relay-request-retry-limit.md @@ -0,0 +1,203 @@ +# Plan: Relay Request Worker Retry Limit + +## Context + +The relay request worker (`runRelayRequestWorker`) processes channel setup requests sequentially using a single worker (`relayRequestWorkerKey = 1`). When a request requires network calls to an unreachable server (e.g., fetching group link data via `getShortLinkConnReq'`), the worker retries indefinitely via `withRetryInterval` + `retryTmpError` — temp/host errors call `loop` with no limit. This blocks all subsequent relay requests from processing. + +This is an attack vector: a channel owner can create a channel link on a server unreachable by the relay, causing the relay request worker to retry forever and blocking all other channel setup requests. + +## Approach + +Follow the XFTP worker retry pattern (`runXFTPDelWorker` in `simplexmq/src/Simplex/FileTransfer/Agent.hs:667`): + +1. **Track retries and delay in DB**: Add `relay_request_retries` and `relay_request_delay` columns to the `groups` table +2. **Order by retries**: Query for next work item ordered by `relay_request_retries ASC, created_at ASC` — items with fewer retries are processed first, stuck items get pushed to the back +3. **Limit consecutive retries**: Replace `withRetryInterval` with `withRetryIntervalCount`, limiting to a small number of consecutive retries per pickup cycle (3, matching XFTP's `xftpConsecutiveRetries`). After the limit, the worker yields and picks the next item. +4. **Store delay for resumption**: On each retry, store the current backoff delay in DB. On next pickup, resume backoff from the stored delay (XFTP pattern: `ri {initialInterval = d, increaseAfter = 0}`) +5. **Expire old requests**: On temp error, before retrying, check if the request is older than 1 day and has 10+ retries — if so, mark as failed instead of retrying. Both conditions must hold — a request that's old but has few retries may just have been delayed, while a request with many retries that's recent is still being actively worked on. + +### How this neutralizes the attack + +- Attacker's request gets picked up, retried 3 times with backoff (~15s total), then yielded +- Worker picks the next item by retry count — legitimate requests (retries=0) go first +- Attacker's request accumulates retries, always processed last +- After 1 day and 10+ retries, the request is marked failed and permanently excluded + +--- + +## Detailed changes + +### 1. Database migration + +New migration: `M20260429_relay_request_retries.hs` + +```sql +ALTER TABLE groups ADD COLUMN relay_request_retries INTEGER NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN relay_request_delay INTEGER; +``` + +**Files:** +- `src/Simplex/Chat/Store/SQLite/Migrations/M20260429_relay_request_retries.hs` (new) +- `src/Simplex/Chat/Store/SQLite/Migrations.hs` (register) +- `src/Simplex/Chat/Store/Postgres/Migrations/M20260429_relay_request_retries.hs` (new) +- `src/Simplex/Chat/Store/Postgres/Migrations.hs` (register) +- `simplex-chat.cabal` (add modules) + +### 2. Extend RelayRequestData + +**File:** `src/Simplex/Chat/Types.hs` + +```haskell +data RelayRequestData = RelayRequestData + { relayInvId :: InvitationId, + reqGroupLink :: ShortLinkContact, + reqChatVRange :: VersionRangeChat, + relayRequestDelay :: Maybe Int64, + relayRequestRetries :: Int, + relayRequestCreatedAt :: UTCTime + } +``` + +- `relayRequestDelay`: resume backoff from stored position (XFTP pattern) +- `relayRequestRetries`: current retry count, used with `relayRequestCreatedAt` to decide expiry in `retryTmpError` +- `relayRequestCreatedAt`: group creation time, used for the 1-day expiry check + +### 3. Update store functions + +**File:** `src/Simplex/Chat/Store/RelayRequests.hs` + +**`getNextPendingRelayRequest`** — two changes: +- Order by `relay_request_retries ASC, created_at ASC` instead of `group_id ASC` +- SELECT and return `relay_request_delay`, `relay_request_retries`, `created_at` in the data query + +```haskell +getNextPendingRelayRequest db = + getWorkItem "relay request" getNextRequestGroupId getRelayRequestData (markRelayRequestFailed db) + where + getNextRequestGroupId = + maybeFirstRow fromOnly $ + DB.query db + [sql| + SELECT group_id FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + ORDER BY relay_request_retries ASC, created_at ASC + LIMIT 1 + |] + (Only RSInvited) + getRelayRequestData groupId = + firstRow' toRelayRequestData (SEGroupNotFound groupId) $ + DB.query db + [sql| + SELECT relay_request_inv_id, relay_request_group_link, + relay_request_peer_chat_min_version, relay_request_peer_chat_max_version, + relay_request_delay, relay_request_retries, created_at + FROM groups WHERE group_id = ? + |] + (Only groupId) + where + toRelayRequestData (Just relayInvId, Just reqGroupLink, Just minV, Just maxV, relayRequestDelay, relayRequestRetries, relayRequestCreatedAt) = + Right (groupId, RelayRequestData {relayInvId, reqGroupLink, reqChatVRange = fromMaybe (versionToRange maxV) $ safeVersionRange minV maxV, relayRequestDelay, relayRequestRetries, relayRequestCreatedAt}) + toRelayRequestData _ = Left $ SEInternalError "missing relay request data" +``` + +**New function: `updateRelayRequestRetries`**: + +```haskell +updateRelayRequestRetries :: DB.Connection -> GroupId -> Int64 -> IO () +updateRelayRequestRetries db groupId delay = do + currentTs <- getCurrentTime + DB.execute db + "UPDATE groups SET relay_request_retries = relay_request_retries + 1, relay_request_delay = ?, updated_at = ? WHERE group_id = ?" + (delay, currentTs, groupId) +``` + +Export `updateRelayRequestRetries` and `markRelayRequestFailed` from module (the latter is currently internal, used only as a callback in `getWorkItem`). + +### 4. Worker changes + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` + +**Import change**: Add `withRetryIntervalCount` to the import from `Simplex.Messaging.Agent.RetryInterval`. + +**Replace `withRetryInterval` with limited retry** in `runRelayRequestOperation`: + +```haskell +runRelayRequestOperation vr user uclId = + withWork_ a doWork (withStore' getNextPendingRelayRequest) $ + \(groupId, rrd@RelayRequestData {relayRequestDelay}) -> do + ri <- asks $ reconnectInterval . agentConfig . config + let ri' = maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) relayRequestDelay + withRetryIntervalLimit ri' $ \delay loop -> do + liftIO $ waitWhileSuspended a + liftIO $ waitForUserNetwork a + processRelayRequest groupId rrd `catchAllErrors` retryTmpError loop groupId rrd delay + where + maxConsecutiveRetries :: Int + maxConsecutiveRetries = 3 + withRetryIntervalLimit :: RetryInterval -> (Int64 -> CM () -> CM ()) -> CM () + withRetryIntervalLimit ri action = + withRetryIntervalCount ri $ \n delay loop -> + when (n < maxConsecutiveRetries) $ action delay loop + retryTmpError :: CM () -> GroupId -> RelayRequestData -> Int64 -> ChatError -> CM () + retryTmpError loop groupId RelayRequestData {relayRequestRetries, relayRequestCreatedAt} delay = \case + ChatErrorAgent {agentError} | temporaryOrHostError agentError -> do + currentTs <- liftIO getCurrentTime + if relayRequestRetries >= 10 && diffUTCTime currentTs relayRequestCreatedAt > nominalDay + then withStore' $ \db -> markRelayRequestFailed db groupId + else do + withStore' $ \db -> updateRelayRequestRetries db groupId delay + loop + e -> do + withStore' $ \db -> setRelayRequestErr db groupId (tshow e) + eToView e +``` + +Key changes from current code: +- `withRetryInterval` → `withRetryIntervalCount` wrapped in local `withRetryIntervalLimit` +- Resume from stored delay via `ri'` (XFTP pattern) +- `retryTmpError` receives the full `RelayRequestData` record and destructures the fields it needs +- On temp error: checks if request is older than 1 day with 10+ retries — if so, marks as failed instead of retrying; otherwise increments retries and calls `loop` +- After `maxConsecutiveRetries` (3), the `when` guard exits, worker picks next item + +--- + +## Files to modify + +| File | Change | +|------|--------| +| `src/Simplex/Chat/Store/SQLite/Migrations/M20260429_relay_request_retries.hs` | New migration | +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | Register migration | +| `src/Simplex/Chat/Store/Postgres/Migrations/M20260429_relay_request_retries.hs` | New migration | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | Register migration | +| `simplex-chat.cabal` | Add migration modules | +| `src/Simplex/Chat/Types.hs` | Add `relayRequestDelay`, `relayRequestRetries`, `relayRequestCreatedAt` to `RelayRequestData` | +| `src/Simplex/Chat/Store/RelayRequests.hs` | Retry ordering, `updateRelayRequestRetries` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Limited retry with delay storage, expiry check in `retryTmpError` | + +## Verification + +1. **Build**: `cabal build --ghc-options=-O0` +2. **Run relay tests**: `cabal test simplex-chat-test --test-options='-m "relay"'` +3. **Scenarios**: + - Request to unreachable server: retried 3 times per cycle, pushed to back of queue, marked failed after 1 day and 10+ retries + - Request to reachable server: succeeds on first attempt, unaffected by changes + - Multiple pending requests: stuck request doesn't block others — items with fewer retries processed first + - App restart with expired pending requests: worker starts, picks up expired request, attempts it — if it succeeds (server now reachable), completes normally; if it fails, `retryTmpError` marks it failed + +## Known considerations + +1. **Single stuck item re-pickup**: If only one request is pending and it's stuck, the worker picks it up repeatedly (3 retries each cycle, immediate re-pickup). This is acceptable — backoff grows via stored delay, and the request is marked failed after 1 day and 10+ retries. The main protection is that other requests aren't blocked. + +2. **`hasPendingRelayRequests` unchanged**: Expired requests still match the `hasPendingRelayRequests` query at startup, so the worker starts. It picks them up, attempts processing — if the server became reachable, the request succeeds normally. If it fails, `retryTmpError` checks the expiry condition and marks it failed. This is strictly better than filtering at query time: expired items get one last chance. + +3. **Delay resumption across pickups**: Stored delay resumes backoff at the last level (XFTP pattern). After many cycles, delay reaches `maxInterval` and stays there. This means retry frequency stabilizes at a low rate for stuck items. + +4. **Permanent errors unchanged**: Non-temp errors (validation, logic) still call `setRelayRequestErr` immediately, permanently excluding the item. The retry mechanism only affects `temporaryOrHostError`. + +5. **`withWork_` re-signals work**: After the action returns (hitting max consecutive retries), `withWork_` has already called `hasWork` (re-signaling the doWork TMVar). The outer `forever` loop immediately proceeds to the next iteration. This is the desired behavior — the worker processes all pending items before waiting. + +6. **`retries` count is from pickup time**: The `relayRequestRetries` value in `retryTmpError` is the count loaded when the item was picked up. Within a single pickup cycle (up to 3 consecutive retries), `updateRelayRequestRetries` increments the DB count but the local value stays the same. The expiry check uses the pickup-time count, which is at most 3 behind the DB. This is acceptable — the threshold (10) has margin. + +7. **Migration column defaults**: `relay_request_retries NOT NULL DEFAULT 0` ensures existing pending requests start with 0 retries. `relay_request_delay` is nullable (NULL = use default reconnectInterval), matching the `Maybe Int64` field. diff --git a/plans/2026-05-01-support-bot-list-api-pagination.md b/plans/2026-05-01-support-bot-list-api-pagination.md new file mode 100644 index 0000000000..44cfde7971 --- /dev/null +++ b/plans/2026-05-01-support-bot-list-api-pagination.md @@ -0,0 +1,377 @@ +# Plan: Fix support-bot crash on large databases — use pagination and direct lookup + +## Context + +The simplex-support-bot crashes during startup against large production +databases: + +``` +[2026-04-30T15:52:53.498Z] Grok contact from state: ID=142676 +[2026-04-30T15:52:53.498Z] Resolving team group... +:0 +[Error: Unknown failure] +``` + +The crash happens inside `chat.apiListGroups(mainUser.userId)` at +`apps/simplex-support-bot/src/index.ts:215`. The native binding marshals the +Haskell core's response to a JS string at +`packages/simplex-chat-nodejs/cpp/simplex.cc:255` (`chat_send_cmd`) → +`HandleCResult` (line 157) → `Napi::String::New` in `OnOK`. When the response +exceeds V8's max string length (~512 MB on 64-bit), N-API string allocation +fails. The literal string `"Unknown failure"` does **not** appear anywhere in +this repo — confirmed by full-tree search — so the message originates from V8 +or N-API internals rather than the binding's own error path (which would say +`chat_send_cmd failed`). Hypothesis: oversized string allocation throws a JS +exception that propagates up unannotated. + +Two distinct misuse patterns drive the payload size: + +**A. List-then-find by ID** (most call sites). The bot pulls every contact / +every group with `apiListContacts` / `apiListGroups`, then calls `find(...)` +to locate one record by a known ID. This is gratuitous — there is already +`apiGetChat(chatType, chatId, count=0)` (`packages/simplex-chat-nodejs/src/api.ts:819`) +that returns one `AChat` whose `chatInfo` carries the full `GroupInfo` / +`Contact` (with `customData`) and zero items. The Haskell parser accepts +`count=0` (`src/Simplex/Chat/Library/Commands.hs:5210`), and +`getDirectChatLast_` / `getGroupChatLast_` return empty `chatItems` with full +`chatInfo`. + +**B. Genuine multi-record scan** (one site). +`apps/simplex-support-bot/src/cards.ts:131` (`refreshAllCards`) enumerates +groups where `customData.cardItemId && !complete` to refresh in-flight cards +on restart. The Haskell side already supports paginated scans via +`APIGetChats` (`/_get chats {userId} pcc=on|off count=N`, +`src/Simplex/Chat/Library/Commands.hs:4868`). It is currently in +`undocumentedCommands` (`bots/src/API/Docs/Commands.hs:360`), so the codegen +does not emit it for the TypeScript bot library. Confirmed: the chat preview +returned by `getChatPreviews` carries `customData` on `GroupInfo` +(`src/Simplex/Chat/Store/Shared.hs:685`, `toGroupInfo`). + +Active card state is already tracked on each group via `customData.cardItemId` +and `customData.complete` (written through `apiSetGroupCustomData` at +`apps/simplex-support-bot/src/cards.ts:103,231`). No `state.json` schema +change is needed — phase 3 reads exactly the same `customData` it already +writes, just via paginated `APIGetChats` instead of a full `apiListGroups`. + +Per the constraint, `apiListContacts` / `apiListGroups` stay in the nodejs +library unchanged for other consumers. Audit confirmed no callers outside +support-bot use them today. + +## Phase 1 — Plumb `APIGetChats` through the bot library + +The codegen pipeline is test-driven: `tests/APIDocs.hs:41–44` invokes +`testGenerate` against the functions in +`bots/src/API/Docs/Generate/TypeScript.hs`, which writes to: + +- `packages/simplex-chat-client/types/typescript/src/commands.ts` +- `packages/simplex-chat-client/types/typescript/src/responses.ts` +- `packages/simplex-chat-client/types/typescript/src/types.ts` + +Run via `cabal test`. The published `@simplex-chat/types` npm package is +built from this TypeScript source; the copy under +`packages/simplex-chat-nodejs/node_modules/@simplex-chat/types/dist/` is a +downstream build artifact and is **not** edited directly. + +Currently missing from generated TS: +`T.PaginationByTime`, `T.ChatListQuery`, `CC.APIGetChats`, and the +`apiChats` response tag on `ChatResponse`. + +### 1.1 `bots/src/API/Docs/Commands.hs` + +- **Remove** `"APIGetChats",` from `undocumentedCommands` (line 360). +- **Add** an entry under "Chat commands" (next to `APIListContacts` / + `APIListGroups` at lines 145–146). Match the Haskell parser at + `src/Simplex/Chat/Library/Commands.hs:4868`: + + ```haskell + ( "APIGetChats", + [], + "Get chat previews. Supports time-based pagination — use this " <> + "instead of APIListContacts / APIListGroups when scanning at scale.", + ["CRApiChats", "CRChatCmdError"], + [], + Nothing, + "/_get chats " <> Param "userId" + <> OnOffParam "pcc" "pendingConnections" (Just False) + <> Optional "" (" " <> Param "$0") "pagination" + <> Optional "" (" " <> Json "$0") "query" + ) + ``` + + Note: the `query` segment uses `" " <> Json "$0"` (no `"json "` prefix) — + the parser accepts `A.space *> jsonP` directly. + +### 1.2 `bots/src/API/Docs/Types.hs` + +The type universe already references `PaginationByTime` and `ChatListQuery` +in commented form (lines 381, 390 and 592, 602). Uncomment all four lines. +Confirm the constructor-prefix encoding (`STRecord`/`STUnion`, prefix +`""`/`"CLQ"`) matches the existing definitions in +`src/Simplex/Chat/Controller.hs:992,998` and the JSON deriving at line 1661 +(`sumTypeJSON $ dropPrefix "CLQ"`). + +### 1.3 `bots/src/API/Docs/Responses.hs` + +- Uncomment `("CRApiChats", "...")` at line 100. +- Remove `"CRApiChats",` from `undocumentedResponses` at line 123. + +### 1.4 Regenerate TypeScript types + +Run `cabal test` (the `APIDocs` test suite drives generation). Inspect the +diffs in `packages/simplex-chat-client/types/typescript/src/{commands,responses,types}.ts`. +Verify: + +- `T.PaginationByTime` (sum type with `PTLast`/`PTAfter`/`PTBefore`) exists + with a generated `cmdString`. Compare wire format against the Haskell + `paginationByTimeP` at `src/Simplex/Chat/Library/Commands.hs:5216`: + `count=N` | `after=TS count=N` | `before=TS count=N`. +- `T.ChatListQuery` exists with `CLQFilters` / `CLQSearch` JSON-encoded + variants. +- `CC.APIGetChats.cmdString({userId, pendingConnections, pagination, query})` + exists and emits the expected wire format. +- `r.type === "apiChats"` with `r.chats: T.AChat[]` exists in the response + union (drops `CR` prefix per `sumTypeJSON`, + `src/Simplex/Chat/Controller.hs:1743`). + +Bump `@simplex-chat/types` version and re-link / re-build the +`simplex-chat-nodejs` package so the new symbols are available. + +### 1.5 `packages/simplex-chat-nodejs/src/api.ts` + +Add a single method next to `apiListGroups` (line 761): + +```ts +/** + * Get chat previews (paginated). + * Network usage: no. + * + * Prefer this over apiListContacts / apiListGroups for any scan: those + * methods load the entire history into memory and will fail on large DBs. + */ +async apiGetChats( + userId: number, + pagination: T.PaginationByTime, + query: T.ChatListQuery = {type: "filters", favorite: false, unread: false}, + pendingConnections = false, +): Promise { + const r = await this.sendChatCmd( + CC.APIGetChats.cmdString({userId, pendingConnections, pagination, query}) + ) + if (r.type === "apiChats") return r.chats + throw new ChatCommandError("error getting chats", r) +} +``` + +(Exact `T.PaginationByTime` / `T.ChatListQuery` shapes come from the codegen +output of phase 1.4 — verify the discriminator field names before locking +this signature.) + +## Phase 2 — Replace list-then-find with direct lookup + +For every site below, replace `apiList…().find(…)` with +`apiGetChat(ChatType.X, id, 0)`. Treat "not found" — the chat was deleted — +as a clean missing-record case (log + skip). The wire format +`/_get chat #{id} count=0` is already supported. + +### 2.1 Error matcher + +The Haskell `SEContactNotFound` / `SEGroupNotFound` (in +`src/Simplex/Chat/Store/Shared.hs:863` and elsewhere) surface to TS as: + +```ts +err.chatError?.type === "errorStore" + && err.chatError.storeError.type === "groupNotFound" // or "contactNotFound" +``` + +Both discriminators are already present in the generated types +(`types.d.ts:2825` and `:2788`). Add a small helper in +`apps/simplex-support-bot/src/util.ts`: + +```ts +export function isChatNotFound(err: unknown, kind: "group" | "contact"): boolean { + if (!(err instanceof core.ChatAPIError)) return false + if (err.chatError?.type !== "errorStore") return false + const seType = err.chatError.storeError.type + return kind === "group" ? seType === "groupNotFound" : seType === "contactNotFound" +} +``` + +(Strict — does not swallow other `errorStore` variants.) + +### 2.2 Ergonomic wrappers + +Add two thin helpers in `apps/simplex-support-bot/src/util.ts` (the constraint +forbids touching `apiListContacts` / `apiListGroups` in the nodejs library; +keeping these helpers in the support-bot util keeps the library surface +unchanged): + +```ts +export async function getGroupInfo(chat: api.ChatApi, groupId: number): Promise { + try { + const c = await chat.apiGetChat(T.ChatType.Group, groupId, 0) + return c.chatInfo.type === "group" ? c.chatInfo.groupInfo : null + } catch (err) { + if (isChatNotFound(err, "group")) return null + throw err + } +} + +export async function getContact(chat: api.ChatApi, contactId: number): Promise { + try { + const c = await chat.apiGetChat(T.ChatType.Direct, contactId, 0) + return c.chatInfo.type === "direct" ? c.chatInfo.contact : null + } catch (err) { + if (isChatNotFound(err, "contact")) return null + throw err + } +} +``` + +### 2.3 Call-site changes + +All sites must keep their existing `withMainProfile` / `profileMutex` +wrapping where present. + +- **`apps/simplex-support-bot/src/index.ts:165–180`** (Grok contact + resolution). Drop the `apiListContacts(mainUser.userId)` call entirely. If + `state.grokContactId` is set, call `getContact(chat, state.grokContactId)` + inside `profileMutex.runExclusive`. Preserve the existing log lines. +- **`apps/simplex-support-bot/src/index.ts:306–320`** (team member + validation). Loop and `getContact(chat, member.id)` per member. Compare + `displayName` as before. Team rosters are small; N round-trips are fine. +- **`apps/simplex-support-bot/src/index.ts:213–227`** (team group + resolution). Replace `apiListGroups` + `find` with + `getGroupInfo(chat, state.teamGroupId)`. Preserve the "create new group" + fallback when the lookup returns `null`. +- **`apps/simplex-support-bot/src/bot.ts:796–805`** (`handleJoinCommand`). + Replace with `getGroupInfo(chat, targetGroupId)`; same `businessChat` + validation. +- **`apps/simplex-support-bot/src/cards.ts:120`** (`flushOne`). Direct + `getGroupInfo(chat, groupId)` (still inside `withMainProfile`). +- **`apps/simplex-support-bot/src/cards.ts:213`** (`getRawCustomData`). + Direct lookup. **Hot path** — called on every `mergeCustomData` / + `clearCustomData`. Largest single win. +- **`apps/simplex-support-bot/src/cards.ts:251`** (`updateCard`). Direct + lookup. The "Read customData and groupInfo in one apiListGroups call" + comment goes away. + +After phase 2 the bot can boot and operate steadily on a large DB; phase 3 +is purely about startup reconciliation. + +## Phase 3 — Paginate `refreshAllCards` + +`apps/simplex-support-bot/src/cards.ts:131` is the only legitimate +multi-record scan. Convert it to a single bounded `apiGetChats` call: + +```ts +async refreshAllCards(): Promise { + // Scan the most recently active 1000 chats. Active cards live on + // recently-active customer chats by definition — a card stays open while + // the conversation is in flight. If the bot has been offline long enough + // that an active card has fallen outside the recent-1000 window, that + // card refreshes lazily on the next customer message (which moves the + // chat back into the recent window). + const chats = await this.withMainProfile(() => + this.chat.apiGetChats( + this.mainUserId, + {type: "last", count: 1000}, + ) + ) + const activeCards: {groupId: number; cardItemId: number}[] = [] + for (const c of chats) { + if (c.chatInfo.type !== "group") continue + const customData = c.chatInfo.groupInfo.customData as Record | undefined + if (customData + && typeof customData.cardItemId === "number" + && !customData.complete) { + activeCards.push({ + groupId: c.chatInfo.groupInfo.groupId, + cardItemId: customData.cardItemId, + }) + } + } + // (sort and refresh loop unchanged) +} +``` + +`count = 1000` per the constraint. No `state.json` schema change. Card +status remains entirely on the group's `customData` (`cardItemId`, +`complete`), which is what the bot already reads and writes. + +## Phase 4 — Verification + +### 4.1 Stress test + +Existing tests use `MockChatApi` (`apps/simplex-support-bot/bot.test.ts:24`), +which is in-memory and won't exercise the native binding. A meaningful +stress test needs a real `ChatApi.init` against Postgres. + +Add a new test file (e.g. +`apps/simplex-support-bot/test/stress.test.ts`) that: + +1. Starts an ephemeral Postgres or uses an existing test DB. +2. Calls `ChatApi.init` and seeds N synthetic groups + contacts via the + chat API. (No existing seeding helper — write one.) Reasonable N: 20k + each, large enough to expose the marshaling cliff but not so large that + the test takes minutes. +3. Boots the support-bot main flow against this DB and asserts: startup + completes within a wall-clock budget; resident memory stays bounded; + no native error. + +This is new infrastructure — keep scope tight. If standing up Postgres in +CI is too heavy, run as a manual stress harness rather than a CI test. + +### 4.2 Production replay + +Replay against (a copy of) the affected production DB. Confirm the bot +starts and the team group / Grok contact / team members all resolve. + +### 4.3 Smoke tests + +Existing functional flows via `bot.test.ts` continue to pass after the +phase-2 changes. Manually exercise: + +- Business-request acceptance. +- `/join` validation (the changed `bot.ts:799` path). +- Card create/update/complete cycle (`cards.ts` hot path). +- Restart-time card refresh (`refreshAllCards`). + +## Risks and footguns + +- **`/_get chats` parser default is `PTLast 5000`** + (`src/Simplex/Chat/Library/Commands.hs:4872`). Even 5000 previews can be + heavy. Support-bot now always passes an explicit `count=1000`, but the + default itself remains a footgun for other callers — flag for follow-up; + not changed here. +- **`apiListMembers` is per-group, not per-DB.** Used at `bot.ts:629,825` + and `cards.ts:165`. Bounded by group membership, not history size, so + out of scope for this fix. Flag if customer groups grow huge (>1000 + members) — would warrant a paginated members API at that point. +- **Codegen output sanity.** Phase 1.4 must be inspected by hand — the + generated `cmdString` for `T.PaginationByTime` and the `r.type` / + `r.chats` shape on the response side are the integration points the rest + of the plan depends on. Do not skip eyeballing the diff. +- **`apiGetChat(..., 0)` semantics on a non-existent chatId.** Verified: + the error tag is `chatError.type === "errorStore"` with + `storeError.type === "groupNotFound"` or `"contactNotFound"`. Both + discriminators already exist in the generated types + (`types.d.ts:2825,2788`). `isChatNotFound` matches them precisely; do + not loosen it. +- **Native binding crash hypothesis is unverified.** The literal "Unknown + failure" string is not in this tree. Most likely V8/N-API surfacing a + string-allocation or JSON-parse failure. The fix in this plan addresses + the proximate cause (oversized response payload) regardless of the exact + surfacing path; if the same error reappears after the fix, dig into the + binding's `OnOK` handler to add explicit size-check / better diagnostics. +- **`@simplex-chat/types` package version bump.** Phase 1.4 produces + TypeScript changes in `packages/simplex-chat-client/types/typescript/`. + Bumping the version and re-publishing (or rebuilding locally) is required + before phase 1.5 lands. Coordinate the release sequence. + +## Out of scope + +- Deprecating or paginating `apiListContacts` / `apiListGroups` in the + nodejs library. They stay as-is; only support-bot stops calling them. +- Lowering the `/_get chats` parser default from `PTLast 5000`. +- Adding a paginated members API. +- Native binding diagnostics for oversized responses. diff --git a/plans/2026-05-07-desktop-rtl-composer-fix.md b/plans/2026-05-07-desktop-rtl-composer-fix.md new file mode 100644 index 0000000000..b593be481d --- /dev/null +++ b/plans/2026-05-07-desktop-rtl-composer-fix.md @@ -0,0 +1,390 @@ +# Fix #4137 — desktop: RTL text rendering under send button + +Target file: +`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt` + +--- + +## 1. Problem statement + +### 1.1 Symptom + +On desktop, when the user types right-to-left text (Arabic, Hebrew, +Persian) in the chat composer **while the global system locale is LTR**, +the first characters of the typed text are rendered **under the send +button** at the bottom-right corner and become invisible while typing. + +The same defect places the voice-preview / disabled-state +`ComposeOverlay` text on the wrong horizontal side in this configuration. + +### 1.2 Configurations affected + +Tested 4 combinations of (global locale × typed-text direction): + +| Global locale | Typed text | Behavior | +|---------------|------------|----------| +| LTR | LTR | OK | +| LTR | RTL | **broken** — text under send button | +| RTL | LTR | OK | +| RTL | RTL | OK | + +Only the LTR-locale + RTL-text combination is broken. This is the +configuration where the **inner text rendering direction** (forced RTL by +`decorationBox`) **disagrees** with the **outer layout direction** (LTR). + +### 1.3 Why it matters + +- Persian/Arabic/Hebrew users on a non-localized OS (very common: most + desktop installs default to English) cannot see the start of their own + message until it grows past the send button. +- The composer is the most-used input in the app; this is a daily + papercut for the affected user population. + +--- + +## 2. Root cause + +A direction-resolution decoupling introduced by an unrelated refactor. +Two commits matter: + +### 2.1 The original RTL fix — #4675 (`2ae5a8bff`, Aug 2024) + +Added padding logic *inside* a forced-RTL scope: + +```kotlin +CompositionLocalProvider(LocalLayoutDirection provides Rtl) { + Column(Modifier.weight(1f).padding(start = startPadding, end = endPadding)) { + TextFieldDecorationBox(...) + } +} +``` + +Inside that scope `start` resolves to the **right** edge. So setting +`startPadding = 50.dp` for the RTL-text + LTR-locale case correctly +reserved 50dp on the visual right — same side as the send button. + +**The padding side and button side were aligned by accident.** `start` +tracked the forced-RTL direction in the same way that `Alignment.BottomEnd` +in `SendMsgView.kt:120` tracked the global direction — and the two +happened to coincide *as long as those directions were the same.* The +pre-existing rule expressed in code was effectively "padding follows +typed-text direction," which was equivalent to "padding follows button +side" only when the inner forced direction and the outer global direction +agreed. + +### 2.2 The breaking refactor — #5051 edge-to-edge (`4162bccc4`, Nov 2024) + +The padding modifier was lifted **out** of the forced-RTL scope onto the +outer `BasicTextField` (the wrapping `Column` and `Row` were removed). +The outer modifier now resolves `start`/`end` against the **global** +layout direction, but `decorationBox` still forces +`LayoutDirection.Rtl` for RTL characters internally. + +In LTR-global + RTL-text: + +- `padding(start = 50.dp)` → 50dp reserved on visual **left** +- Text right-aligned by forced-RTL `decorationBox` → renders against + visual **right** +- 0dp on the right → text under the send button (which is at + `Alignment.BottomEnd` in LTR global = visual right) + +The compensation logic written for the inner-scope semantics silently +became wrong when the modifier moved outward. Code compiled, tests passed, +behavior diverged. + +### 2.3 The actual invariant the layout obeys + +Reading the layout call graph (`SendMsgView` → `PlatformTextField`): + +- `SendMsgView.kt:120` — `Box(Modifier.align(Alignment.BottomEnd)...)` + places the send button using the **global** layout direction. +- `PlatformTextField.desktop.kt` — `BasicTextField` modifier chain is + applied in the **global** layout direction. + +The constraint is therefore exactly one rule: + +> **The textfield must reserve space on the global layout direction's +> `end` — the same side `Alignment.BottomEnd` resolves to in the parent +> `Box`.** + +Pre-PR code expressed a different (wrong) rule — "padding follows +typed-text direction" — which agreed with the actual invariant only when +no RTL-text/LTR-locale mismatch existed. The 4 of 4 case failure → 1 of 4 +case failure shape is the signature of this kind of accidental alignment. + +### 2.4 Why this is structural, not a typo + +The defect is not a missing case — it is the **wrong rule**. Adding a +new branch (e.g. "if RTL-text + LTR-locale, swap padding sides +*again*") would silence the symptom while leaving the wrong rule in +place. The fix is to delete the wrong rule and write the actual +invariant. + +--- + +## 3. Solution summary + +Make the two conditional assignments that compute `startPadding` and +`endPadding` unconditional, taking the values they already produced in +the `else` branch: + +```kotlin +val startPadding = 0.dp +val endPadding = startEndPadding +``` + +The surrounding code is unchanged — `startEndPadding`'s computation, +the `PaddingValues(startPadding, 12.dp, endPadding, 0.dp)` construction, +the `.padding(start = startPadding, end = endPadding)` modifier call, +and the original two-line comment all stay verbatim. + +Master's `if (isRtlByCharacters && isLtrGlobally)` predicate split each +of `startPadding` and `endPadding` into two branches. In cases 1, 3, 4 +the predicate is `false` and master takes the `else` branch — exactly +the values the surgical version produces unconditionally. Only case 2 +(the bug) takes the `then` branch, and that branch reserves space on +the wrong horizontal side. Removing the predicate removes only case 2's +wrong values; cases 1/3/4 are byte-identical to master. + +The 95dp/50dp distinction is preserved verbatim through `startEndPadding`, +which is unchanged. + +`ComposeOverlay` (called twice at the bottom of `PlatformTextField`) +reuses the same `padding` value — its placement is corrected for the +same reason without an extra change. + +**Net effect**: 2 lines changed. + +--- + +## 4. Detailed technical design + +### 4.1 Behavior matrix (post-fix) + +| Case | Locale | Text | Master `(start, end)` | Surgical `(start, end)` | Button side | +|------|--------|------|-----------------------|-------------------------|-------------| +| 1 | LTR | LTR | `(0, 50)` | `(0, 50)` | right ✓ same | +| 2 | LTR | RTL | `(50, 0)` | `(0, 50)` | right ✓ **fix** | +| 2′ | LTR | RTL + empty + voice | `(95, 0)` | `(0, 95)` | right ✓ **fix** | +| 3 | RTL | LTR | `(0, 50)` | `(0, 50)` | left ✓ same | +| 4 | RTL | RTL | `(0, 50)` | `(0, 50)` | left ✓ same | + +Three of the four pre-PR cases are byte-identical to the new code. +Only the broken case (LTR locale + RTL text) flips from `(50, 0)` to +`(0, 50)`, which matches the side where the send button resolves. + +### 4.2 Why the 95dp condition stays exactly as-is + +The 95dp special case fires only in RTL-text + LTR-locale + empty + +voice-button. In every other configuration, the placeholder text +either left-aligns (no collision with the right-side voice button row) +or sits on the visual side opposite to the buttons (RTL global puts +buttons on the left while forced-RTL placeholder displays on visual +right). + +Only the RTL-text + LTR-global case puts a right-aligned placeholder +on the same side as the wider voice-button row. The condition is +intrinsic to the architecture (forced-RTL inside `decorationBox` while +the outer layout is global LTR), not a bug — it must be preserved. + +### 4.3 What is *not* changed + +Out of scope for #4137 — listed for clarity: + +- The `CompositionLocalProvider` inside `decorationBox` that forces + `LayoutDirection.Rtl` for RTL-by-characters input (the BiDi-detection + workaround from #4675 itself). +- `lastTimeWasRtlByCharacters` state and `isRtl` detection on the first + 50 characters of the message. +- The `ComposeOverlay` composable — it inherits the corrected + `padding`. +- `SendMsgView`, the `Alignment.BottomEnd` send button placement, and + the voice-button row layout. +- The Android implementation (`PlatformTextField.android.kt`) — uses + a native Android `EditText` with `setPaddingRelative`, which + resolves against the view's own layout direction; behavior is + unaffected and out of scope. + +### 4.4 Properties of the resulting code + +- The two adjacent conditional assignments dispatching on + `isRtlByCharacters && isLtrGlobally` (one for `startPadding`, one for + `endPadding`) become unconditional. The predicate is removed; the + `else` branch's values are lifted to the bare assignments. +- All four locals (`startEndPadding`, `startPadding`, `endPadding`, + `padding`) keep the same names and continue to exist. +- The `PaddingValues(startPadding, 12.dp, endPadding, 0.dp)` call and + the `.padding(start = startPadding, end = endPadding)` modifier are + unchanged. +- The original two-line comment is unchanged. "padding from right side + should be bigger" remains accurate — `endPadding` is still `95.dp` + vs `50.dp` under the same condition as before, just consistently on + the global end side. +- No behavior is removed: RTL detection, the `decorationBox` direction + override, overlay rendering, and the empty-text/voice-button 95dp + expansion are all retained verbatim. +- Diff size: 2 lines changed, one file. No reformatting of unrelated + code. + +### 4.5 Risk surface + +- **Compose 1.7.x BiDi engine** — unchanged; we still rely on + `decorationBox`'s forced direction for right-alignment of typed RTL + text. No new BiDi dependency. +- **Padding API** — `Modifier.padding(end = X.dp)` and + `PaddingValues(start, top, end, bottom)` are stable Compose APIs. +- **Direction resolution** — `Modifier.padding`'s start/end have + resolved against the enclosing `LocalLayoutDirection` since Compose + Foundation 1.0; no version-sensitive behavior. +- **Cross-platform** — Android implementation uses a native + `EditText`; no shared change required. + +--- + +## 5. Detailed implementation plan + +### 5.1 The exact edit + +File: +`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt` + +**Lines 89–90 — replace 2 lines:** + +```kotlin +// remove + val startPadding = if (isRtlByCharacters && isLtrGlobally) startEndPadding else 0.dp + val endPadding = if (isRtlByCharacters && isLtrGlobally) 0.dp else startEndPadding + +// add + val startPadding = 0.dp + val endPadding = startEndPadding +``` + +No other lines change. No imports added or removed. The comment, the +`startEndPadding` computation, the `PaddingValues` construction, and +the `.padding(start = startPadding, end = endPadding)` modifier are +all preserved verbatim. + +### 5.2 Steps + +1. Edit `PlatformTextField.desktop.kt` at the site above (lines 89–90). +2. Build desktop module: + `cd apps/multiplatform && ./gradlew :common:desktopMainClasses` +3. Run desktop app on an LTR system locale; type + `متن راست به چپ` in the composer; verify all characters visible. +4. Type ASCII; verify no regression. +5. Switch system locale to Arabic/Persian/Hebrew; repeat both inputs; + verify send button and reservation flip together to the visual + left, with no overlap. +6. Trigger voice preview / disabled-state placeholder in each + configuration; verify the overlay text is on the side opposite + the send button. +7. Commit on a topic branch (`nd/fix-RTL`); PR title: + `desktop: fix RTL text rendering under the send button`; reference + `Fixes #4137`. + +### 5.3 Test matrix to verify manually + +| # | Locale | Typed text | Empty + voice? | Expectation | +|---|--------|-----------|----------------|-------------| +| 1 | LTR | ASCII | n/a | unchanged from current | +| 2 | LTR | RTL chars | no | chars visible, no overlap with right-side button | +| 3 | LTR | empty | yes | placeholder + voice-button row both visible | +| 4 | LTR | (was RTL) → empty | yes | placeholder clears 95dp on right (sticky `lastTimeWasRtlByCharacters`) | +| 5 | RTL | ASCII | no | unchanged | +| 6 | RTL | RTL chars | no | unchanged | +| 7 | RTL | empty | yes | unchanged | + +### 5.4 Rollback + +Revert is one commit and one file. Behavior reverts cleanly. + +--- + +## 6. Alternative approaches considered + +### 6.1 Chosen approach — drop the buggy `then` branch (§3) + +The 2-line surgical change. Removes the predicate from the +`startPadding` and `endPadding` assignments, keeping the (correct) +`else` branch values as the unconditional definition. Smallest +possible diff; preserves all variable names, the comment, the +`PaddingValues` call, and the `.padding(start, end)` modifier. +Fixes the overlay placement as a free byproduct. + +### 6.2 Re-couple padding to inner forced direction by wrapping `BasicTextField` + +Move the `CompositionLocalProvider(LocalLayoutDirection = Rtl)` *outside* +`BasicTextField` rather than inside `decorationBox`. The outer +`.padding(start, end)` would then resolve in the same direction as the +inner text, restoring the pre-#5051 invariant and letting the +historical `start = 50.dp / end = 0.dp` swap work again. + +**Pros**: padding-vs-text consistency at the source. + +**Cons**: also flips `fillMaxWidth`, `focusRequester`, `onPreviewKeyEvent`, +and the parent `Box`'s `Alignment.BottomEnd` resolution direction is +**still global** — so the textfield and the button align against +different directions, moving the mismatch instead of removing it. +Bigger refactor, broader test surface, no net gain. **Rejected.** + +### 6.3 Remove the forced-RTL override; rely on Compose BiDi + +Delete the `CompositionLocalProvider` inside `decorationBox`. Let +Compose's BiDi engine right-align RTL paragraphs without forcing a +paragraph direction. Then `start`/`end` resolve consistently against +the global direction everywhere; `isRtlByCharacters`, +`lastTimeWasRtlByCharacters`, and the 95dp special case can all be +deleted. + +**Pros**: largest simplification — eliminates the entire BiDi-detection +state machine and the 95dp branch. + +**Cons**: depends on Compose Desktop 1.7.x BiDi engine matching what +#4675 originally needed to enforce. If automatic BiDi is insufficient +(e.g. mixed Latin-RTL paragraphs, neutral characters at paragraph start, +numbers in RTL paragraphs), regressions reappear. Requires manual +verification across all the cases #4675 originally fixed. Out of scope +for #4137. **Reasonable follow-up; not part of this fix.** + +### 6.4 Derive padding from measured button-row width + +Refactor `SendMsgView` so the textfield's reservation comes from the +**measured** width of the button row (via `SubcomposeLayout` or shared +state), instead of hard-coded 50/95dp. The textfield would reserve +exactly as much as the buttons need, regardless of direction or button +configuration. + +**Pros**: removes the 50/95dp magic numbers and the +`showVoiceButton`-dependent branch. Self-correcting if the button row +ever changes. + +**Cons**: significantly larger refactor; `SubcomposeLayout` adds cost +to a frequently-recomposing view; doesn't fix the bug at hand any +better than §6.1. **Reasonable longer-term cleanup; not part of this +fix.** + +### 6.5 Add a third special case for the failing combination + +`if isRtlByCharacters && isLtrGlobally then padding(end=50) else +padding(start=startPadding, end=endPadding)`. + +**Pros**: one-line behavior fix. + +**Cons**: leaves the wrong rule in place plus a workaround on top. +Three branches where one suffices, and the underlying defect — padding +following typed-text direction instead of button side — is preserved +and now harder to spot. **Rejected as a workaround.** + +--- + +## 7. Recommendation + +Implement §3 (the chosen approach). It is the minimal structural +root-cause fix, also corrects the overlay placement as a free byproduct, +and removes the wrong-side `then` branch from both `startPadding` and +`endPadding`. + +Defer §6.3 and §6.4 to separate PRs if desired — both are reasonable +cleanups but are not necessary to fix #4137 and would expand the blast +radius beyond the bug. diff --git a/plans/2026-05-07-fullscreen-viewer-wrong-image.md b/plans/2026-05-07-fullscreen-viewer-wrong-image.md new file mode 100644 index 0000000000..068d2b07c7 --- /dev/null +++ b/plans/2026-05-07-fullscreen-viewer-wrong-image.md @@ -0,0 +1,135 @@ +# Fullscreen image viewer: opens the wrong image + +Design doc for the fix shipped in PR #6869. + +## Problem + +The fullscreen image viewer occasionally opened the chat's oldest media +instead of the image the user tapped. Reproductions were intermittent — +the gating condition turned out to be the runtime state of the +*immediately-older* sibling of the tapped item. + +## Background — pager state model + +`providerForGallery` (`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt:3537`) +backs the fullscreen viewer with a virtual pager of 10000 pages. The +pager's state is two variables, captured in a closure: + +- `initialIndex` — pager page that maps to the anchor item; starts at 5000. +- `initialChatId` — id of the anchored chat item; starts at the tapped item. + +Invariant: page `initialIndex` always shows item `initialChatId`. Other +pages are computed by walking `chatItems` older / newer from the anchor +via the local `item()` helper. + +`scrollToStart()` is called by `ImageFullScreenView.kt` to lock the +pager's leftward boundary at the user's current item, in two situations: + +- **Init probe** (`ImageFullScreenView.kt:48-55`) — at viewer open, if + `getMedia(initialIndex - 1) == null` (no older sibling reachable), + reposition so the tapped item becomes page 0. +- **Runtime branch** (`ImageFullScreenView.kt:97-112`) — during scroll, + if `getMedia(index) == null` while the user is at `index + 1`, lock + the pager so the null page isn't reachable. + +Both callers want the same outcome: **page 0 = the user's current +anchor item**, leftward = unreachable. + +## Root cause + +Pre-fix body of `scrollToStart`: + +```kotlin +override fun scrollToStart() { + initialIndex = 0 + initialChatId = chatItems.firstOrNull { canShowMedia(it) }?.id ?: return +} +``` + +The second line rewrote `initialChatId` to the chat's *oldest showable +media* — not the user's current anchor. This mismatched what both +callers wanted. It happened to coincide with the correct behavior when +the anchor already was the chat's oldest showable, which is why the bug +masked itself for years. + +The bug surfaced when the init probe fired for a non-boundary reason: + +- The immediately-older sibling existed and passed `canShowMedia` (file + marked loaded; file path resolved or remote was connected). +- But `getLoadedImage` returned `null` at decode time (undecodable + bytes, missing file on disk, crypto error). +- `getMedia(initialIndex - 1)` therefore returned `null`. +- The probe misread that null as "no older sibling exists" and called + `scrollToStart()`. +- `scrollToStart` rewrote `initialChatId` to the chat's oldest showable. +- Page 0 of the pager rendered that oldest item — the wrong image. + +## Fix + +Delete the second line. `scrollToStart` becomes: + +```kotlin +override fun scrollToStart() { + initialIndex = 0 +} +``` + +`initialChatId` is preserved across the call. Page 0 now maps to the +current anchor — exactly what both callers wanted from the start. + +## Why this is correct for both callers + +- **Init probe.** Before the call, `initialChatId` is the tapped item. + After the call, page 0 = tapped item. ✓ +- **Runtime branch.** Before the call, `currentPageChanged` has already + updated `initialChatId` to the user's currently visible item. After + the call, page 0 = current item; the user's view is preserved with no + visible jump. (Pre-fix the user got teleported to the chat's oldest + media when a null sibling tripped this branch — a latent UX bug + resolved by the same one-line change.) + +## Why a wider structural change is not in scope here + +`getMedia` returns `null` for two distinct conditions: (a) navigation +found no showable item, (b) navigation found one but decode failed. A +deeper refactor would let consumers distinguish these. That refactor is +deliberately out of scope for this fix: + +- The user-visible bug (wrong image) is fully resolved by the one-line + change. No additional code is required to address the report. +- The remaining symptom — locking the user out of older loadable items + behind one that fails to decode — is mild, pre-existing, and not part + of the report. If it becomes user-visible, address it in a follow-up. +- A wider refactor would expand the diff, the review surface, and the + regression risk for a fix that needs to ship promptly. +- `good-code-v5.md`: *"Find the minimal change. The smallest structural + modification that achieves the goal."* The smallest modification that + resolves the reported bug is the deletion of one line. + +## Verification + +`apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ProviderForGalleryTest.kt`: + +- `testScrollToStartPreservesAnchor` — drives the public provider + interface: moves the anchor off `cItemId` via `currentPageChanged`, + calls `scrollToStart`, then reads the anchor back through `onDismiss`'s + `scrollTo` callback. Pre-fix would observe `scrollTo(2)` (the chat's + oldest); post-fix `scrollTo(1)` (anchor preserved). +- `testOnDismissOnActiveItemDoesNotScroll` — pins the `onDismiss` + early-return contract that the regression test reads through. + +Manual sanity (Android + desktop): tap newest / oldest / a middle image +in a chat with multiple media — fullscreen opens on the tapped image in +each case; swipe in both directions still works. + +## Alternatives considered and rejected + +- **Distinguish "no item" from "load failed" inside `getMedia`.** + Requires either a return-type redesign (sealed result type) or an + added query method on the interface. Both expand the diff well beyond + what the user-visible bug requires. Deferred to a possible follow-up + if the milder remaining symptom is reported. +- **Hoist the local `item()` helper to a top-level testable function.** + The regression test exercises the public provider interface and + reads the anchor back via `onDismiss`'s `scrollTo` callback, so no + internal extraction is needed for testability. diff --git a/plans/2026-05-07-simplex-chat-python-design.md b/plans/2026-05-07-simplex-chat-python-design.md new file mode 100644 index 0000000000..240f88714c --- /dev/null +++ b/plans/2026-05-07-simplex-chat-python-design.md @@ -0,0 +1,575 @@ +# SimpleX Chat Python library design + +## Table of contents + +- [What](#what) +- [Why](#why) +- [How](#how) +- [Architecture](#architecture) +- [Type generation](#type-generation) +- [Native lib loading](#native-lib-loading) +- [Public API](#public-api) +- [Distribution and CI](#distribution-and-ci) +- [Testing](#testing) +- [Open questions](#open-questions) + +## What + +A Python 3 client library `simplex-chat` on PyPI for SimpleX bots. Same capability as the Node.js library at `packages/simplex-chat-nodejs/`. + +The user writes a Python script with decorator-registered handlers; the library does the rest: + +```python +from simplex_chat import Bot, BotProfile, SqliteDb, TextMessage + +bot = Bot(profile=BotProfile(display_name="Squarer"), + db=SqliteDb(file_prefix="./bot"), + welcome="Send a number, I'll square it.") + +@bot.on_message(content_type="text") +async def square(msg: TextMessage) -> None: + try: + n = float(msg.text) + await msg.reply(f"{n} * {n} = {n * n}") + except ValueError: + await msg.reply("Not a number.") + +if __name__ == "__main__": + bot.run() +``` + +`pip install simplex-chat`, run the script, done. + +## Why + +SimpleX has a Node.js library (`simplex-chat`) and Haskell-built native lib (`libsimplex.{so,dylib,dll}`) but no Python equivalent. Python is the dominant language for bot scripting, automation, and data integration. Without a Python client, those users either can't use SimpleX or have to bridge through Node. + +The native `libsimplex` already exists as prebuilt artifacts (`simplex-chat/simplex-chat-libs` GitHub releases, one zip per platform/backend). The Haskell type metadata that drives the Node lib's TypeScript types is already in `bots/src/API/Docs/`. Both can be reused — adding Python bindings is mostly wiring, not a new system. + +## How + +Three pieces: + +1. **Extend the existing Haskell type generator** in `bots/src/API/Docs/Generate/` to emit a Python types module alongside the existing TypeScript one. The metadata is the same; only the rendering changes. Already includes `pySyntaxText` (used today in COMMANDS.md docs) — just needs a Python codegen module. + +2. **A new Python package** `packages/simplex-chat-python/` that wraps the prebuilt `libsimplex.*` via `ctypes`, downloading it on first use from the existing GitHub release. Async-only (`asyncio`), Python 3.11+. Single `Bot` class with decorator-registered handlers. + +3. **One small CI job** appended to `.github/workflows/build.yml`, after the existing `release-nodejs-libs` job, that publishes the Python package to PyPI on each release tag. ~15 lines of YAML. + +No new infrastructure: no separate libs build, no per-platform wheels, no PyPI size waiver, no second CI workflow. The libs zips that already exist for the Node lib are reused unchanged. + +## Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ bots/src/API/Docs/ (Haskell, existing) │ +│ ├── Types/Commands/Events/Responses │ +│ ├── Syntax.hs (already has pySyntaxText) │ +│ ├── Generate/TypeScript.hs (existing) │ +│ └── Generate/Python.hs ← new │ +└────────────────────┬───────────────────────────────────────┘ + │ tests/APIDocs.hs runs both generators + ▼ writes auto-gen Python type files +┌────────────────────────────────────────────────────────────┐ +│ packages/simplex-chat-python/ (new) │ +│ │ +│ Bot ←── public API: decorators, lifecycle │ +│ └── ChatApi ← escape hatch: raw command access │ +│ └── core ← internal: typed FFI wrapper │ +│ └── _native ← internal: ctypes + lazy DL │ +│ ↓ │ +│ libsimplex.{so,dylib,dll} │ +│ downloaded from simplex-chat-libs releases │ +└────────────────────────────────────────────────────────────┘ +``` + +The split lets each layer be tested independently: `Bot`'s filter-routing without a real libsimplex (mock `api`), `core`'s JSON handling without ctypes (mock `_native`), `_native`'s download/ctypes work with a stub `.so`. Same shape as the Node lib (`bot.ts` → `api.ts` → `core.ts` → `simplex.cc`). + +## Type generation + +### New module: `bots/src/API/Docs/Generate/Python.hs` + +Mirrors `Generate/TypeScript.hs` line-for-line. Reuses the existing data sources (`chatCommandsDocs`, `chatResponsesDocs`, `chatEventsDocs`, `chatTypesDocs`) and `pySyntaxText` from `Syntax.hs`. Output goes to `packages/simplex-chat-python/src/simplex_chat/types/` as four files: `_types.py`, `_commands.py`, `_responses.py`, `_events.py`. + +### Type representation + +Wire types are `TypedDict` + `Literal` discriminators (matches Node lib semantics — just shapes, no runtime cost; pyright narrows tagged unions correctly). + +| Haskell | Python | +|---|---| +| `STRecord` | `class Foo(TypedDict)`; optional fields use `NotRequired[...]`. | +| `STUnion` / `STUnion1` | One `class Foo_(TypedDict)` per member with `type: Literal[""]` discriminator. Type alias `Foo = Foo_A \| Foo_B \| …`. Tag alias `Foo_Tag = Literal["", "", …]`. | +| `STEnum` / `STEnum1` / `STEnum'` | Type alias `Foo = Literal["a", "b", "c"]`. | +| `ATPrim TBool` | `bool` | +| `ATPrim TString` | `str` | +| `ATPrim TInt`/`TInt64`/`TWord32` | `int` | +| `ATPrim TDouble` | `float` | +| `ATPrim TJSONObject` | `dict[str, object]` | +| `ATPrim TUTCTime` | `str` (ISO-8601, comment-annotated) | +| `ATOptional t` | `NotRequired[]` in TypedDict fields; ` \| None` elsewhere | +| `ATArray {nonEmpty=False}` | `list[]` | +| `ATArray {nonEmpty=True}` | `list[]` with trailing `# non-empty` comment | +| `ATMap (PT k) v` | `dict[, ]` | +| `ATDef` / `ATRef` | type name as forward-string reference `""` | + +### Command serialization + +Each command becomes a `TypedDict` plus a `_cmd_string(self) -> str` function. The function body is produced by `pySyntaxText` (which already emits Python expressions for the existing Markdown docs): + +```python +class APICreateMyAddress(TypedDict): + userId: int + +def APICreateMyAddress_cmd_string(self: APICreateMyAddress) -> str: + return '/_address ' + str(self['userId']) + +APICreateMyAddress_Response = CR.UserContactLinkCreated | CR.ChatCmdError +``` + +### Field-naming convention + +| Where | Style | Why | +|---|---|---| +| Auto-generated `types/_*.py` | **camelCase** | Round-trips JSON to/from libsimplex; the keys are the wire format. | +| Hand-written user-facing types (`SqliteDb`, `BotProfile`, `Message`, …) | **snake_case** | These are Python-side configs and wrappers, never reach `chat_send_cmd` directly. | +| `CryptoArgs` (`fileKey`, `fileNonce`) | **camelCase** | Returned by `chat_write_file` as JSON; round-trips wire format. | +| Method names | **snake_case** | PEP 8. | +| Type names | **PascalCase** | PEP 8 + Haskell parity. | + +Generator must emit field names verbatim from `APIRecordField.fieldName'` — never transform. + +### Wiring + +Extend `tests/APIDocs.hs` with four `testGenerate` calls: + +```haskell +describe "Python" $ do + it "generate python commands" $ testGenerate Py.commandsCodeFile Py.commandsCodeText + it "generate python responses" $ testGenerate Py.responsesCodeFile Py.responsesCodeText + it "generate python events" $ testGenerate Py.eventsCodeFile Py.eventsCodeText + it "generate python types" $ testGenerate Py.typesCodeFile Py.typesCodeText +``` + +`cabal test` regenerates all eight files (4 TS + 4 PY) and fails on drift — same enforcement loop that already governs the TypeScript files. + +Add `API.Docs.Generate.Python` to `simplex-chat.cabal:572-580`. + +## Native lib loading + +### Approach: lazy download on first use + +Single pure-Python wheel on PyPI (`simplex-chat`, ~100 KB). On first `Bot(...)` / `ChatApi.init(...)`, the library downloads the matching `libsimplex` zip from `simplex-chat/simplex-chat-libs` releases into a user cache, then `ctypes.CDLL`s it. Subsequent runs read from cache. + +**Why not platform wheels?** Two reasons. First, simpler CI: one `python -m build` job vs a 5-platform matrix that has to download libs zips and rebuild wheels per platform. Second, the libs are already published as the source of truth (existing `release-nodejs-libs` job) — wheels would be a wrapper around those, adding nothing but build complexity. Tradeoff: first run requires internet; air-gap users set `SIMPLEX_LIBS_DIR=/path/to/libs`. + +### Cache layout + +``` +~/.cache/simplex-chat/ # XDG_CACHE_HOME on Linux +└── v6.5.1/ # = LIBS_VERSION + ├── sqlite/libsimplex.so + libHS*.so + └── postgres/libsimplex.so + libHS*.so +``` + +Platform-specific cache base: Linux `$XDG_CACHE_HOME`, macOS `~/Library/Caches/`, Windows `%LOCALAPPDATA%`. + +### Version pinning + +`src/simplex_chat/_version.py`: + +```python +__version__ = "6.5.1" # PEP 440 — bumped with each Python package release +LIBS_VERSION = "6.5.1" # simplex-chat-libs release tag (no 'v' prefix) +``` + +`__version__` is read by hatchling for wheel metadata. `LIBS_VERSION` is read by `_native.py` for the download URL. Wrapper-only patch releases use a post-suffix (`__version__ = "6.5.1.post1"`, `LIBS_VERSION` unchanged). Same pattern as Node lib's `RELEASE_TAG = 'v6.5.1'`. + +### `_native.py` responsibilities + +1. **Detect platform.** `sys.platform` × `platform.machine()` → `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`. Unsupported combos raise immediately. +2. **Resolve backend** from the `Db` instance (`isinstance(db, SqliteDb)` → sqlite, `PostgresDb` → postgres). Module-level `threading.Lock` guards selection — first call wins for the process; subsequent call with a different backend raises (one libsimplex variant per process — Haskell RTS constraint). +3. **Resolve libs path.** If `SIMPLEX_LIBS_DIR` env is set, use it directly. Otherwise: `~/.cache/simplex-chat/v{LIBS_VERSION}/{backend}/`, downloading if missing. +4. **Download URL** (`LIBS_VERSION` is stored without 'v' prefix; URL re-adds it): + + ``` + https://github.com/simplex-chat/simplex-chat-libs/releases/download/v{LIBS_VERSION}/simplex-chat-libs-{platform}-{arch}{-postgres?}.zip + ``` + +5. **Atomic install.** Download to sibling tempdir → `zipfile.extractall` → `os.replace` the `libs/` subdir into cache. The libs zip contains only regular files (no symlinks — `build.yml:751-781` builds it via `cp *.so` which resolves them), so plain `extractall` is sufficient. Two processes racing safely both extract; whichever rename lands first wins, contents identical. +6. **Load and init.** `ctypes.CDLL(libs_dir / libname)` once per process; declare `restype`/`argtypes` for the 8 FFI functions; call `hs_init_with_rtsopts` exactly once with `+RTS -A64m -H64m -xn --install-signal-handlers=no` (or without `-xn` on Windows — matches `cpp/simplex.cc:13-32`). +7. **Buffer ownership.** Haskell allocates result strings; caller must `free()` after copying. Declare `restype = c_void_p` (NOT `c_char_p`, which auto-converts to bytes and discards the pointer needed for free): + + ```python + ptr = lib.chat_send_cmd(ctrl, cmd_bytes) + if not ptr: raise RuntimeError("null result") + try: result = ctypes.string_at(ptr).decode("utf-8") + finally: libc.free(ptr) + ``` + + `libc` is `ctypes.CDLL(None)` on Linux/macOS, `ctypes.CDLL("msvcrt")` on Windows. Mirrors `HandleCResult` in `cpp/simplex.cc:157-165`. + +### Override / pre-fetch + +```bash +# Skip download — for Docker / air-gapped +SIMPLEX_LIBS_DIR=/opt/simplex/libs python my_bot.py + +# Pre-fetch in Dockerfile RUN step (avoids redundant download per container start) +python -m simplex_chat install --backend=sqlite +python -m simplex_chat install --backend=postgres +``` + +### Failure modes + +| Condition | Behavior | +|---|---| +| Unsupported platform/arch | Raise on first FFI call with explicit list of supported combinations. | +| Postgres on non-Linux-x86_64 | Raise — matches existing constraint in `download-libs.js:15-18`. | +| Download network/HTTP error | Propagate `urllib.error.URLError` with the URL. | +| Two processes downloading simultaneously | Both extract to sibling temp dirs; rename is atomic; identical contents → safe. | +| Two `Bot()` / `ChatApi.init()` calls in same process with different backends | Second raises — one libsimplex variant per process. | +| Two `Bot()` instances same backend, same process | Permitted — each has its own controller (`chat_ctrl`). | + +## Public API + +See the [Architecture](#architecture) section for the layering. This section specifies each layer's surface. + +### Construction + +User-facing config types are `@dataclass(slots=True)`, snake_case fields. + +```python +@dataclass(slots=True) +class SqliteDb: + file_prefix: str + encryption_key: str | None = None + +@dataclass(slots=True) +class PostgresDb: + connection_string: str + schema_prefix: str | None = None + +Db = SqliteDb | PostgresDb # discriminated by isinstance() + +@dataclass(slots=True) +class BotProfile: + display_name: str + full_name: str = "" + short_descr: str | None = None + image: str | None = None + +@dataclass(slots=True) +class BotCommand: + keyword: str + label: str + +class Bot: + def __init__( + self, *, + profile: BotProfile, + db: Db, + welcome: str | T.MsgContent | None = None, + commands: list[BotCommand] | None = None, # None → [] + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + # behavioral toggles — mirror BotOptions in Node lib + create_address: bool = True, + update_address: bool = True, + update_profile: bool = True, + auto_accept: bool = True, + business_address: bool = False, + allow_files: bool = False, + use_bot_profile: bool = True, + log_contacts: bool = True, + log_network: bool = False, + ) -> None: ... + + @property + def api(self) -> ChatApi: ... +``` + +### Handler registration + +Three decorators. Filters are kwargs combined with **AND**; tuples within a kwarg are **OR**; arbitrary predicates use `when=`. + +```python +class Bot: + def on_message(self, *, + content_type: T.MsgContent_Tag | tuple[T.MsgContent_Tag, ...] | None = None, + text: str | re.Pattern | None = None, # exact match or regex.search() + chat_type: T.ChatType | tuple[T.ChatType, ...] | None = None, # direct/group/local + from_role: T.GroupMemberRole | tuple[T.GroupMemberRole, ...] | None = None, + from_contact_id: int | tuple[int, ...] | None = None, + from_member_id: int | tuple[int, ...] | None = None, + group_id: int | tuple[int, ...] | None = None, + when: Callable[[Message], bool] | None = None, + ) -> Callable[[MessageHandler], MessageHandler]: ... + + def on_command(self, name: str | tuple[str, ...], *, + args: str | re.Pattern | None = None, # match command argument string + chat_type: T.ChatType | tuple[T.ChatType, ...] | None = None, + from_role: T.GroupMemberRole | tuple[T.GroupMemberRole, ...] | None = None, + from_contact_id: int | tuple[int, ...] | None = None, + group_id: int | tuple[int, ...] | None = None, + when: Callable[[Message], bool] | None = None, + ) -> Callable[[CommandHandler], CommandHandler]: ... + + # Multiple handlers per tag dispatch in registration order. + def on_event(self, event: CEvt.Tag, /, + ) -> Callable[[EventHandler], EventHandler]: ... + + def use(self, middleware: Middleware) -> None: ... + +MessageHandler = Callable[[Message], Awaitable[None] | None] +CommandHandler = Callable[[Message, ParsedCommand], Awaitable[None] | None] +EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None] | None] +``` + +`from_role` on direct chats: silent skip (not a runtime error). + +### Message wrapper and content-narrowed types + +`Message[C]` is generic in its content variant; concrete subclass aliases (`TextMessage`, `ImageMessage`, …) bind to the auto-generated `T.MsgContent_*` types. Decorator overloads narrow the handler parameter when `content_type` is a single `Literal`, so pyright sees the right concrete type. + +```python +C = TypeVar("C", bound=T.MsgContent) # bound covers the unparameterized case + +@dataclass(slots=True, frozen=True) +class Message(Generic[C]): + chat_item: T.AChatItem # raw wire object — fields below this point are camelCase + content: C # narrowed when filter pins content_type + bot: Bot + + @property + def chat_info(self) -> T.ChatInfo: ... # shortcut for chat_item["chatInfo"] + @property + def text(self) -> str | None: ... # shortcut; non-Optional for TextMessage + + async def reply(self, text: str) -> Message: ... + async def reply_content(self, content: T.MsgContent) -> Message: ... + async def react(self, emoji: str) -> None: ... + async def delete(self) -> None: ... + async def forward(self, to: T.ChatRef) -> Message: ... + +# Concrete narrowed aliases — exported from simplex_chat/__init__.py +TextMessage = Message[T.MsgContent_Text] +ImageMessage = Message[T.MsgContent_Image] +FileMessage = Message[T.MsgContent_File] +VoiceMessage = Message[T.MsgContent_Voice] +# … one per MsgContent variant + +@dataclass(slots=True, frozen=True) +class ParsedCommand: + keyword: str + args: str +``` + +Decorator overloads (one per `T.MsgContent_*` variant — ~15 lines, hand-written in `bot.py`): + +```python +class Bot: + @overload + def on_message(self, *, content_type: Literal["text"], **rest: Any + ) -> Callable[[Callable[[TextMessage], Awaitable[None] | None]], ...]: ... + @overload + def on_message(self, *, content_type: Literal["image"], **rest: Any + ) -> Callable[[Callable[[ImageMessage], Awaitable[None] | None]], ...]: ... + # … one overload per MsgContent variant … + @overload + def on_message(self, *, content_type: tuple[T.MsgContent_Tag, ...] | None = None, + **rest: Any) -> Callable[[Callable[[Message], Awaitable[None] | None]], ...]: ... +``` + +`@bot.on_message(content_type="text")` → handler typed as `TextMessage`, so `msg.text: str` (non-Optional). + +**Field-naming boundary in `Message`.** Wrapper properties (`msg.chat_info`, `msg.content`, `msg.text`) are snake_case. Descending into raw wire data via `msg.chat_item[...]` reverts to camelCase — same as accessing `T.AChatItem` returned by `bot.api`. Property shortcuts cover the common paths so most handlers never touch `chat_item` directly. + +### Lifecycle + +```python +class Bot: + # Blocking convenience — runs asyncio.run(self.serve_forever()), installs SIGINT + # via loop.add_signal_handler() (POSIX) or signal.signal() (Windows). Recommended for scripts. + def run(self) -> None: ... + + # Embedding form — caller owns the loop and signal handling. + async def __aenter__(self) -> Bot: ... + async def __aexit__(self, *exc_info: object) -> None: ... + + # Concurrent calls raise RuntimeError("already serving"). Re-callable after a clean stop(). + async def serve_forever(self) -> None: ... + + # Marks bot for shutdown. Safe from signal handler, another coroutine, or another thread. + def stop(self) -> None: ... +``` + +### Middleware + +aiogram pattern. A class with `async __call__(handler, message, data)` wraps every **handler invocation** (per-message-per-matching-handler). `data: dict[str, object]` is the cross-cutting injection channel. + +```python +class Middleware: + async def __call__(self, + handler: Callable[[Message, dict[str, object]], Awaitable[None]], + message: Message, + data: dict[str, object]) -> None: + await handler(message, data) +``` + +- **Invocation count.** A `newChatItems` event with N items × M matching handlers triggers N×M middleware calls. Per-event hooks should use `on_event`. +- **Exception propagation.** Handler exceptions propagate outward through the middleware stack. The outermost middleware can catch and swallow. Uncaught exceptions are logged via `logging.getLogger("simplex_chat")` and the chain moves to the next handler — the bot does not stop on individual handler errors. The receive loop only stops on a fatal `_native`/`core` error or explicit `bot.stop()`. +- **Order.** Registered via `bot.use(...)`. Called in registration order (first registered = outermost wrap). + +### Event-loop semantics + +`Bot` runs one event-receiver coroutine looping `chat_recv_msg_wait` (in `asyncio.to_thread`): + +1. All `on_event(tag)` handlers for the event's `type` field — registration order, sequentially. +2. If event is `newChatItems`: for each chat item, run **all matching message handlers** (each through the middleware stack, in registration order). For each command-parseable text item, also run matching command handlers. + +Handlers run **sequentially within an event**. Events are processed **sequentially**. Long-running work that shouldn't block the next event must `asyncio.create_task(...)` explicitly. + +`bot.api.api_xxx(...)` calls are safe during `serve_forever` — same controller, serialized through `chat_send_cmd`. Calling them from inside a handler is the normal pattern (`msg.reply()` does exactly this). + +### `ChatApi` (escape hatch) + +Reached via `bot.api`. ~40 async methods, one per Node `apiXxx` (api.ts:344-958). Full enumeration deferred to implementation; representative examples: + +```python +class ChatApi: + @classmethod + async def init(cls, db: Db, + confirm: MigrationConfirmation = MigrationConfirmation.YES_UP) -> ChatApi: ... + async def start_chat(self) -> None: ... + async def stop_chat(self) -> None: ... + async def close(self) -> None: ... + async def send_chat_cmd(self, cmd: str) -> CR.ChatResponse: ... + async def recv_chat_event(self, wait_us: int = 5_000_000) -> CEvt.ChatEvent | None: ... + + async def api_create_user_address(self, user_id: int) -> T.CreatedConnLink: ... + async def api_send_text_message(self, chat: T.ChatRef, text: str, + in_reply_to: int | None = None) -> list[T.AChatItem]: ... + async def api_get_chats(self, user_id: int, pagination: T.PaginationByTime, + query: T.ChatListQuery | None = None) -> list[T.AChat]: ... + # ... etc +``` + +TS `apiCreateUserAddress` → Python `api_create_user_address` (PEP 8). Wire-format type names (`T.AChatItem`, `T.UserContactLink`, …) keep their Haskell/TS spelling to match JSON keys. + +### Embedding example + +```python +import asyncio +from simplex_chat import Bot, BotProfile, SqliteDb + +async def main(): + async with Bot(profile=..., db=...) as bot: + @bot.on_message(content_type="text") + async def echo(msg): + await msg.reply(msg.text) + await asyncio.gather(bot.serve_forever(), other_task()) + +asyncio.run(main()) +``` + +## Distribution and CI + +### Project layout + +``` +packages/simplex-chat-python/ +├── pyproject.toml # hatchling, requires-python >= 3.11, no runtime deps +├── README.md +├── LICENSE # AGPL-3.0 +├── src/simplex_chat/ +│ ├── __init__.py # exports Bot, BotProfile, BotCommand, SqliteDb, PostgresDb, +│ │ # Message + TextMessage/ImageMessage/… aliases, ParsedCommand, +│ │ # ChatApi, MigrationConfirmation, Middleware, ChatAPIError +│ ├── _version.py # __version__ + LIBS_VERSION +│ ├── _native.py # ctypes + lazy lib download (internal) +│ ├── __main__.py # python -m simplex_chat install ... +│ ├── core.py # internal typed FFI wrapper +│ ├── api.py # ChatApi class — escape hatch +│ ├── bot.py # Bot class, decorators, Message wrapper, lifecycle +│ ├── filters.py # filter kwarg compilation; predicate combinators +│ ├── util.py # stateless helpers (chat_info_ref, ci_content_text, reaction_text, …) +│ ├── py.typed # PEP 561 marker +│ └── types/ +│ ├── __init__.py # re-exports T, CC, CR, CEvt +│ ├── _types.py # AUTOGEN +│ ├── _commands.py # AUTOGEN +│ ├── _responses.py # AUTOGEN +│ └── _events.py # AUTOGEN +├── examples/ +│ └── squaring_bot.py +└── tests/ +``` + +### `pyproject.toml` + +```toml +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "simplex-chat" +description = "SimpleX Chat Python library for chat bots" +license = "AGPL-3.0" +authors = [{name = "SimpleX Chat"}] +requires-python = ">=3.11" +dynamic = ["version"] + +[tool.hatch.version] +path = "src/simplex_chat/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/simplex_chat"] +``` + +No runtime Python dependencies (ctypes, urllib, zipfile are stdlib). + +### CI publishing + +One job appended to `.github/workflows/build.yml`, after `release-nodejs-libs`: + +```yaml +publish-python: + needs: [release-nodejs-libs] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: { id-token: write } # OIDC, no API key + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: { python-version: "3.11" } + - run: pip install build && python -m build --wheel + working-directory: packages/simplex-chat-python + - uses: pypa/gh-action-pypi-publish@release/v1 + with: { packages-dir: packages/simplex-chat-python/dist } +``` + +Triggered by the same `vX.Y.Z` tag that already drives the desktop and libs releases. + +### One-time setup + +1. Verify PyPI package name `simplex-chat` is available; register it. +2. On PyPI project page, configure trusted publisher → repo `simplex-chat/simplex-chat`, workflow `build.yml`, job `publish-python`. + +## Testing + +Three levels: + +1. **Codegen drift** — `tests/APIDocs.hs` adds Python generators alongside TypeScript. Same `testGenerate` mechanism enforces that committed `_types.py` etc. equal the generator output. + +2. **Python unit tests** — `pytest`, no real libsimplex needed: + - `test_native.py`: mock `urllib.request.urlretrieve` + `zipfile.ZipFile`; assert correct URL, atomic rename, cache hit on second call, override-env behavior, postgres-on-mac rejection. + - `test_codegen.py`: import every type from `simplex_chat.types`, sanity-check that `T.ChatType` is `Literal[...]` of expected size, etc. Catches generator regressions. + - `test_smoke.py`: build a fake `.so` (small C file with stub `chat_send_cmd` returning canned JSON, compiled per-test), point `SIMPLEX_LIBS_DIR` at it, run `Bot.__aenter__` → handler dispatch. Verifies FFI plumbing without real Haskell. + +3. **Integration** — `examples/squaring_bot.py` runs against real libsimplex. Not in CI (needs network + persistent state). + +## Open questions + +1. **Linux ARM64.** Existing `simplex-chat-libs` releases ship `linux-x86_64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64` — no `linux-aarch64`. Python lib will fail with a clear message there. Adding it requires changes to the existing `release-nodejs-libs` job in `build.yml` (out of scope for this spec). + +2. **`asyncio.to_thread` pool sizing.** Long-blocking `chat_recv_msg_wait` calls (default 5 s) pin executor threads. The default asyncio pool is unbounded but recycled. Bots running many concurrent chats may need a custom executor — first-pass uses `asyncio.to_thread`; document recommended pool sizing in README if it becomes a problem. diff --git a/plans/2026-05-07-simplex-chat-python-implementation.md b/plans/2026-05-07-simplex-chat-python-implementation.md new file mode 100644 index 0000000000..b1ff5b951c --- /dev/null +++ b/plans/2026-05-07-simplex-chat-python-implementation.md @@ -0,0 +1,2348 @@ +# SimpleX Chat Python library — implementation plan + +> **For agentic workers:** Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `simplex-chat` on PyPI — a Python 3 client library for SimpleX bots with the same capability as the existing Node.js library. + +**Architecture:** Two repositories of work in this monorepo: extend the Haskell type generator (`bots/src/API/Docs/`) to emit Python types alongside TypeScript, and add a new Python package (`packages/simplex-chat-python/`) that wraps the existing prebuilt `libsimplex.{so,dylib,dll}` via ctypes. Lazy download of libs from `simplex-chat/simplex-chat-libs` GitHub releases. Async-only public API with decorator-registered handlers. Single PyPI wheel. + +**Tech Stack:** Haskell (existing codegen), Python 3.11+ (ctypes, asyncio, hatchling), GitHub Actions (publish-python job in existing build.yml). + +**Spec:** [`plans/2026-05-07-simplex-chat-python-design.md`](./2026-05-07-simplex-chat-python-design.md) + +--- + +## Plan structure + +Eight phases, executed in order: + +1. Type generation (Haskell) — `Generate/Python.hs` + `tests/APIDocs.hs` wiring. +2. Python package scaffold — `pyproject.toml`, `_version.py`, layout, hatch config. +3. Native FFI layer — `_native.py` (lazy download, ctypes, hs_init, buffer ownership). +4. Core wrapper — `core.py` (typed async FFI). +5. ChatApi — escape-hatch class with raw + ~40 high-level methods. +6. Bot class — decorators, filters, Message wrapper, lifecycle, middleware. +7. Tests + CLI — pytest suite, `python -m simplex_chat install`. +8. CI publishing — append `publish-python` job to `.github/workflows/build.yml`. + +Each phase is a chunk. Phase boundaries are natural review points. + +--- + +## Chunk 1: Type generation (Haskell) + +Add `bots/src/API/Docs/Generate/Python.hs`, mirror `Generate/TypeScript.hs`, wire into the test suite. + +### Task 1.1: Create `Generate/Python.hs` skeleton + +**Files:** +- Create: `bots/src/API/Docs/Generate/Python.hs` + +- [ ] **Step 1: Copy `Generate/TypeScript.hs` as starting point** + +```bash +cp bots/src/API/Docs/Generate/TypeScript.hs bots/src/API/Docs/Generate/Python.hs +``` + +- [ ] **Step 2: Rename module and update output paths** + +Edit the new file: +- Module: `module API.Docs.Generate.Python where` +- File constants: + ```haskell + commandsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_commands.py" + responsesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_responses.py" + eventsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_events.py" + typesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_types.py" + ``` + +- [ ] **Step 3: Register module in cabal manifest** + +In `simplex-chat.cabal`, find the `other-modules:` list of the `simplex-chat-test` test-suite stanza (`type: exitcode-stdio-1.0`, `main-is: Test.hs`). Insert `API.Docs.Generate.Python` alphabetically between `API.Docs.Generate` and `API.Docs.Responses`. + +- [ ] **Step 4: Verify cabal compiles** + +``` +cabal build simplex-chat-test +``` +Expected: builds without error (the file is still TS-shaped but Haskell-valid). + +- [ ] **Step 5: Commit scaffolding** + +```bash +git add bots/src/API/Docs/Generate/Python.hs simplex-chat.cabal +git commit -m "feat(bots): add Python codegen module skeleton" +``` + +### Task 1.2: Implement Python type rendering + +Replace TypeScript-specific output with Python equivalents per the spec's [type-mapping rules](./2026-05-07-simplex-chat-python-design.md#type-representation). + +**Files:** +- Modify: `bots/src/API/Docs/Generate/Python.hs` + +- [ ] **Step 1: Rewrite `typesCodeText`** + +Output structure: +```python +# API Types +# This file is generated automatically. +from typing import Literal, NotRequired, TypedDict + +# … one class / type alias per chatTypesDocs entry … +``` + +Translation rules (mirror `TypeScript.hs` `typeCode`): +- `ATDRecord fields` → `class (TypedDict):` with translated field types. +- `ATDEnum cs` → ` = Literal["c1", "c2", …]`. +- `ATDUnion cs` → tagged TypedDicts + union alias + tag alias (see `unionTypeCode` in TS). + +Field-type translation (table in spec; mirrors `TypeScript.hs` `fieldsCode` `typeText`): +- `ATPrim` primitives → Python primitives (`bool`, `str`, `int`, `float`, `dict[str, object]`). +- `ATOptional t` inside TypedDict → `NotRequired[]`; elsewhere ` | None`. +- `ATArray {elemType, nonEmpty}` → `list[]`, append `# non-empty` comment when `nonEmpty=True`. +- `ATMap (PT k) v` → `dict[, ]`. +- `ATDef` / `ATRef` → forward-string reference `""`. + +- [ ] **Step 2: Rewrite `responsesCodeText` and `eventsCodeText`** + +Both produce union types — same shape as TS's `unionTypeCode`. Output structure: + +```python +# API Responses +# This file is generated automatically. +from . import _types as T + +ChatResponse_ = TypedDict(...) +ChatResponse_ = TypedDict(...) +… +ChatResponse = ChatResponse_ | ChatResponse_ | … +ChatResponse_Tag = Literal["", "", …] +``` + +- [ ] **Step 3: Rewrite `commandsCodeText`** + +Each command becomes a TypedDict + a `_cmd_string(self) -> str` function + a Response alias. Function body comes from `pySyntaxText` in `Syntax.hs:160` (no changes to that function — it's already correct and used today by Markdown docs). + +```python +# API Commands +# This file is generated automatically. +import json +from typing import TypedDict +from . import _types as T +from . import _responses as CR + +class APICreateMyAddress(TypedDict): + userId: int + +def APICreateMyAddress_cmd_string(self: APICreateMyAddress) -> str: + return '/_address ' + str(self['userId']) + +APICreateMyAddress_Response = CR.UserContactLinkCreated | CR.ChatCmdError +``` + +The `cmdString` body: invoke `pySyntaxText (constrName, params) syntax` analogously to TS's `funcCode`. + +- [ ] **Step 4: Build to verify Haskell compiles** + +``` +cabal build simplex-chat-test +``` +Expected: clean build. + +- [ ] **Step 5: Commit** + +```bash +git add bots/src/API/Docs/Generate/Python.hs +git commit -m "feat(bots): implement Python type generation" +``` + +### Task 1.3: Wire generators into `tests/APIDocs.hs` + +**Files:** +- Modify: `tests/APIDocs.hs` + +- [ ] **Step 1: Add import** + +Insert after line 11 (`import qualified API.Docs.Generate.TypeScript as TS`): + +```haskell +import qualified API.Docs.Generate.Python as Py +``` + +- [ ] **Step 2: Add four `testGenerate` calls** + +Inside `apiDocsTest`, after the existing `describe "TypeScript"` block (line 40-44), add: + +```haskell +describe "Python" $ do + it "generate python commands code" $ testGenerate Py.commandsCodeFile Py.commandsCodeText + it "generate python responses code" $ testGenerate Py.responsesCodeFile Py.responsesCodeText + it "generate python events code" $ testGenerate Py.eventsCodeFile Py.eventsCodeText + it "generate python types code" $ testGenerate Py.typesCodeFile Py.typesCodeText +``` + +- [ ] **Step 3: Create empty target directory** + +```bash +mkdir -p packages/simplex-chat-python/src/simplex_chat/types +``` + +- [ ] **Step 4: Run the API docs tests — they will write the four files** + +``` +cabal test simplex-chat-test --test-options="--match \"API\"" +``` + +First run: tests fail because the on-disk files are empty / missing. The `testGenerate` mechanism overwrites the file with generated content, so the second run passes. + +``` +cabal test simplex-chat-test --test-options="--match \"Python\"" +``` + +Expected: PASS on the second run. + +- [ ] **Step 5: Sanity-check generated output** + +Eyeball each of the four generated files: + +```bash +head -50 packages/simplex-chat-python/src/simplex_chat/types/_types.py +head -30 packages/simplex-chat-python/src/simplex_chat/types/_commands.py +head -30 packages/simplex-chat-python/src/simplex_chat/types/_responses.py +head -30 packages/simplex-chat-python/src/simplex_chat/types/_events.py +``` + +Verify: starts with `# This file is generated automatically.`, contains valid-looking Python, no obvious junk like `` markers. + +- [ ] **Step 6: Run them through Python parser to verify syntax** + +``` +python -c "import ast; [ast.parse(open(f).read()) for f in ['packages/simplex-chat-python/src/simplex_chat/types/_types.py', 'packages/simplex-chat-python/src/simplex_chat/types/_commands.py', 'packages/simplex-chat-python/src/simplex_chat/types/_responses.py', 'packages/simplex-chat-python/src/simplex_chat/types/_events.py']]" +``` + +Expected: no exception. Any `SyntaxError` indicates a generator bug — fix in `Generate/Python.hs` and re-run cabal test. + +- [ ] **Step 7: Commit generated artifacts** + +```bash +git add tests/APIDocs.hs packages/simplex-chat-python/src/simplex_chat/types/ +git commit -m "feat(bots): wire Python generators into APIDocs test suite" +``` + +### Task 1.4: Add `types/__init__.py` re-exporting namespaces + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/types/__init__.py` + +- [ ] **Step 1: Write namespace re-exports** + +```python +from . import _types as T +from . import _commands as CC +from . import _responses as CR +from . import _events as CEvt + +__all__ = ["T", "CC", "CR", "CEvt"] +``` + +- [ ] **Step 2: Verify import works** + +``` +python -c "from simplex_chat.types import T, CC, CR, CEvt; print(T, CC, CR, CEvt)" +``` + +(Run from `packages/simplex-chat-python/src/`.) + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/types/__init__.py +git commit -m "feat(python): add types namespace re-exports" +``` + +--- + +## Chunk 2: Python package scaffold + +Set up the package skeleton — `pyproject.toml`, version pinning, top-level `__init__.py`, AGPL license, README placeholder. + +### Task 2.1: Create `pyproject.toml` + +**Files:** +- Create: `packages/simplex-chat-python/pyproject.toml` + +- [ ] **Step 1: Write hatchling build config** + +```toml +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "simplex-chat" +description = "SimpleX Chat Python library for chat bots" +readme = "README.md" +license = "AGPL-3.0-only" +authors = [{name = "SimpleX Chat"}] +requires-python = ">=3.11" +keywords = ["simplex", "messenger", "chat", "privacy", "security", "bots"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Communications :: Chat", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-python" +Issues = "https://github.com/simplex-chat/simplex-chat/issues" + +[project.optional-dependencies] +test = ["pytest>=8", "pytest-asyncio>=0.23"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"] + +[tool.hatch.version] +path = "src/simplex_chat/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/simplex_chat"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/simplex-chat-python/pyproject.toml +git commit -m "feat(python): add pyproject.toml with hatchling backend" +``` + +### Task 2.2: Create `_version.py` + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/_version.py` + +- [ ] **Step 1: Write version constants** + +```python +"""Single source of truth for both the Python package version and the +simplex-chat-libs release tag we depend on. + +Bump both together for normal releases. For wrapper-only fixes use a PEP 440 +post-release: __version__ = "6.5.1.post1", LIBS_VERSION unchanged. +""" + +__version__ = "6.5.1" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.1" # simplex-chat-libs release tag (no 'v' prefix) +``` + +- [ ] **Step 2: Verify hatchling can read the version** + +``` +cd packages/simplex-chat-python && python -m build --wheel +``` + +Expected: produces `dist/simplex_chat-6.5.1-py3-none-any.whl`. (Wheel will be incomplete — only the types module is in src/ at this point — but build should succeed.) + +Clean up: `rm -rf packages/simplex-chat-python/dist packages/simplex-chat-python/src/simplex_chat.egg-info` + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/_version.py +git commit -m "feat(python): add _version.py with package + libs version pinning" +``` + +### Task 2.3: Add `py.typed` marker, README placeholder, AGPL license + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/py.typed` +- Create: `packages/simplex-chat-python/README.md` +- Create: `packages/simplex-chat-python/LICENSE` + +- [ ] **Step 1: Touch the empty `py.typed` marker** + +```bash +touch packages/simplex-chat-python/src/simplex_chat/py.typed +``` + +- [ ] **Step 2: Copy AGPL license from a sibling package** + +```bash +cp packages/simplex-chat-nodejs/LICENSE packages/simplex-chat-python/LICENSE +``` + +- [ ] **Step 3: Write a minimal README** + +```markdown +# SimpleX Chat Python library + +Python 3 client library for [SimpleX Chat](https://simplex.chat) bots. + +Equivalent to the [Node.js library](https://www.npmjs.com/package/simplex-chat). + +## Installation + + pip install simplex-chat + +Requires Python 3.11+. + +## Quick start + +[example to be added] + +## License + +[AGPL-3.0](./LICENSE) +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/py.typed \ + packages/simplex-chat-python/README.md \ + packages/simplex-chat-python/LICENSE +git commit -m "feat(python): add py.typed marker, README, AGPL license" +``` + +### Task 2.4: Top-level `__init__.py` with empty exports + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/__init__.py` + +- [ ] **Step 1: Write a placeholder that re-exports from submodules as they appear** + +```python +"""SimpleX Chat — Python client library for chat bots.""" + +from ._version import __version__ + +__all__ = ["__version__"] +``` + +(Will be expanded as `Bot`, `ChatApi`, etc. land in later phases.) + +- [ ] **Step 2: Verify import** + +``` +python -c "import simplex_chat; print(simplex_chat.__version__)" +``` + +Run from `packages/simplex-chat-python/src/`. + +Expected: prints `6.5.1`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/__init__.py +git commit -m "feat(python): add top-level package __init__.py" +``` + +--- + +## Chunk 3: Native FFI layer (`_native.py`) + +Lazy lib download, platform detection, ctypes signatures, `hs_init_with_rtsopts`, atomic install, buffer ownership. Single most-error-prone piece of the package — give it tests. + +### Task 3.1: `_native.py` — platform detection + URL building + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/_native.py` +- Create: `packages/simplex-chat-python/tests/test_native_url.py` + +- [ ] **Step 1: Write platform-detection tests** + +```python +# tests/test_native_url.py +from unittest.mock import patch +import pytest +from simplex_chat._native import _platform_tag, _libs_url, _libname + +@patch("sys.platform", "linux") +@patch("platform.machine", return_value="x86_64") +def test_platform_linux_x64(_): + assert _platform_tag() == "linux-x86_64" + +@patch("sys.platform", "darwin") +@patch("platform.machine", return_value="arm64") +def test_platform_macos_arm64(_): + assert _platform_tag() == "macos-aarch64" + +@patch("sys.platform", "win32") +@patch("platform.machine", return_value="AMD64") +def test_platform_windows_x64(_): + assert _platform_tag() == "windows-x86_64" + +@patch("sys.platform", "freebsd") +@patch("platform.machine", return_value="x86_64") +def test_platform_unsupported(_): + with pytest.raises(RuntimeError, match="Unsupported"): + _platform_tag() + +def test_libname_per_platform(): + with patch("sys.platform", "linux"): + assert _libname() == "libsimplex.so" + with patch("sys.platform", "darwin"): + assert _libname() == "libsimplex.dylib" + with patch("sys.platform", "win32"): + assert _libname() == "libsimplex.dll" + +@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64") +def test_url_sqlite(_): + assert _libs_url("sqlite") == \ + "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" \ + "v6.5.1/simplex-chat-libs-linux-x86_64.zip" + +@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64") +def test_url_postgres(_): + assert _libs_url("postgres") == \ + "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" \ + "v6.5.1/simplex-chat-libs-linux-x86_64-postgres.zip" +``` + +- [ ] **Step 2: Write `_native.py` skeleton with platform + URL helpers** + +```python +"""Native libsimplex loader: platform detection, lazy download, ctypes setup. + +Internal — users interact with `Bot` / `ChatApi`, never with this module. +""" +from __future__ import annotations + +import ctypes +import errno +import os +import platform +import sys +import tempfile +import threading +import urllib.request +import zipfile +from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p +from pathlib import Path +from typing import Literal + +from ._version import LIBS_VERSION + +Backend = Literal["sqlite", "postgres"] + +_GITHUB_REPO = "simplex-chat/simplex-chat-libs" + +_PLATFORM_MAP = { + "linux": ("linux", {"x86_64": "x86_64", "aarch64": "aarch64"}), + "darwin": ("macos", {"x86_64": "x86_64", "arm64": "aarch64"}), + "win32": ("windows", {"AMD64": "x86_64", "x86_64": "x86_64"}), +} + +_LIBNAME = {"linux": "libsimplex.so", "darwin": "libsimplex.dylib", "win32": "libsimplex.dll"} + +SUPPORTED = ( + "linux-x86_64", "linux-aarch64", + "macos-x86_64", "macos-aarch64", + "windows-x86_64", +) + + +def _platform_tag() -> str: + info = _PLATFORM_MAP.get(sys.platform) + if not info: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + sysname, archs = info + arch = archs.get(platform.machine()) + if not arch: + raise RuntimeError(f"Unsupported architecture: {sys.platform}/{platform.machine()}") + tag = f"{sysname}-{arch}" + if tag not in SUPPORTED: + raise RuntimeError(f"Unsupported combination: {tag}; supported: {SUPPORTED}") + return tag + + +def _libname() -> str: + return _LIBNAME[sys.platform] + + +def _libs_url(backend: Backend) -> str: + suffix = "-postgres" if backend == "postgres" else "" + return ( + f"https://github.com/{_GITHUB_REPO}/releases/download/" + f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip" + ) +``` + +- [ ] **Step 3: Run tests** + +``` +cd packages/simplex-chat-python && pip install -e . && pip install pytest +PYTHONPATH=src pytest tests/test_native_url.py -v +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/_native.py \ + packages/simplex-chat-python/tests/test_native_url.py +git commit -m "feat(python): _native platform detection + URL building" +``` + +### Task 3.2: `_native.py` — cache resolution + lazy download + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/_native.py` +- Create: `packages/simplex-chat-python/tests/test_native_cache.py` + +- [ ] **Step 1: Write cache-resolution tests** + +```python +# tests/test_native_cache.py +import zipfile +from pathlib import Path + +import pytest + +from simplex_chat._native import _cache_root, _resolve_libs_dir, _download + + +def test_cache_root_linux(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert _cache_root() == tmp_path / "simplex-chat" + +def test_cache_root_macos(tmp_path, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + assert _cache_root() == tmp_path / "Library" / "Caches" / "simplex-chat" + +def test_override_via_env(tmp_path, monkeypatch): + # _resolve_libs_dir intentionally does not validate the override directory — + # it returns it verbatim; the eventual ctypes.CDLL call surfaces any mistake. + monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert _resolve_libs_dir("sqlite") == tmp_path + +def test_resolve_downloads_when_missing(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + + called = {} + def fake_download(target_root: Path, backend: str) -> None: + called["target"] = target_root + called["backend"] = backend + target_root.mkdir(parents=True, exist_ok=True) + (target_root / "libsimplex.so").touch() + + monkeypatch.setattr("simplex_chat._native._download", fake_download) + libs_dir = _resolve_libs_dir("sqlite") + assert libs_dir == tmp_path / "simplex-chat" / "v6.5.1" / "sqlite" + assert called["backend"] == "sqlite" + assert (libs_dir / "libsimplex.so").exists() + +def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + cached = tmp_path / "simplex-chat" / "v6.5.1" / "sqlite" + cached.mkdir(parents=True) + (cached / "libsimplex.so").touch() + # Should NOT call _download — use the cached file. + monkeypatch.setattr("simplex_chat._native._download", + lambda *a: pytest.fail("download should not be called")) + assert _resolve_libs_dir("sqlite") == cached + +def test_postgres_on_macos_rejected(monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "macos-aarch64") + with pytest.raises(RuntimeError, match="postgres.*linux-x86_64"): + _resolve_libs_dir("postgres") + +def test_atomic_install(tmp_path, monkeypatch): + """Build a fake libs zip, mock urlretrieve, verify extraction + atomic rename.""" + # Build zip: libs/libsimplex.so + libs/libHS-stub.so + src = tmp_path / "src" / "libs" + src.mkdir(parents=True) + (src / "libsimplex.so").write_text("fake-so") + (src / "libHS-stub.so").write_text("fake-hs") + zip_path = tmp_path / "fake-libs.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in src.iterdir(): + zf.write(f, f"libs/{f.name}") + + def fake_urlretrieve(url: str, dest: str) -> None: + import shutil + shutil.copy(zip_path, dest) + + monkeypatch.setattr("urllib.request.urlretrieve", fake_urlretrieve) + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + + target = tmp_path / "out" + _download(target, "sqlite") + assert (target / "libsimplex.so").read_text() == "fake-so" + assert (target / "libHS-stub.so").read_text() == "fake-hs" +``` + +- [ ] **Step 2: Implement `_cache_root`, `_resolve_libs_dir`, `_download`** + +Append to `_native.py`: + +```python +def _cache_root() -> Path: + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / "simplex-chat" + if sys.platform == "win32": + return Path(os.environ["LOCALAPPDATA"]) / "simplex-chat" + base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(base) / "simplex-chat" + + +def _resolve_libs_dir(backend: Backend) -> Path: + if override := os.environ.get("SIMPLEX_LIBS_DIR"): + return Path(override) + if backend == "postgres" and _platform_tag() != "linux-x86_64": + raise RuntimeError( + "postgres backend is only supported on linux-x86_64; " + f"current platform is {_platform_tag()}" + ) + target = _cache_root() / f"v{LIBS_VERSION}" / backend + if not (target / _libname()).exists(): + _download(target, backend) + return target + + +def _download(target: Path, backend: Backend) -> None: + """Download libs zip → atomic rename into `target`. Concurrent processes safe. + + Atomicity strategy: each process extracts to its own sibling tempdir on the same + filesystem, then `os.rename` the `libs/` subdir to `target`. POSIX `os.rename` + onto a NON-EXISTENT path is atomic; if the target exists (another process won + the race), `os.rename` fails on most platforms — we then verify the winner has + what we need and proceed. NEVER rmtree the target: that creates a TOCTOU + window where another process is reading/loading the file we're deleting. + """ + target.parent.mkdir(parents=True, exist_ok=True) + print( + f"Downloading libsimplex ({_platform_tag()}, {backend}) " + f"v{LIBS_VERSION} ...", + file=sys.stderr, + flush=True, + ) + with tempfile.TemporaryDirectory(dir=target.parent) as tmp: + zip_path = Path(tmp) / "libs.zip" + urllib.request.urlretrieve(_libs_url(backend), zip_path) + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(tmp) + # zip layout: /libs/libsimplex.* + libHS*.* + extracted_libs = Path(tmp) / "libs" + if not extracted_libs.is_dir(): + raise RuntimeError(f"libs/ missing from {_libs_url(backend)}") + try: + os.rename(extracted_libs, target) + except OSError as e: + # EEXIST / ENOTEMPTY mean another process won the race — fall through + # and check that the winner left a usable libsimplex behind. Anything + # else (ENOSPC, EACCES, EROFS, Windows codes mapped to None) is a real + # failure and must propagate. Same VERSION cached → same content → + # safe to proceed once we've confirmed the file is there. + if e.errno not in (errno.EEXIST, errno.ENOTEMPTY): + raise + if not (target / _libname()).exists(): + raise RuntimeError( + f"another process partially populated {target} but libsimplex " + f"is missing; remove the directory manually and retry" + ) from e +``` + +- [ ] **Step 3: Run tests** + +``` +PYTHONPATH=src pytest tests/test_native_cache.py -v +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/_native.py \ + packages/simplex-chat-python/tests/test_native_cache.py +git commit -m "feat(python): _native cache resolution and lazy download" +``` + +### Task 3.3: `_native.py` — ctypes signatures, `hs_init`, lib loader + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/_native.py` + +- [ ] **Step 1: Append the loader** + +(Imports for `ctypes`, `threading`, and the `from ctypes import …` line were already hoisted to the top of `_native.py` in Task 3.1 — do not re-add them here.) + +```python +_lock = threading.Lock() +_lib: ctypes.CDLL | None = None +_libc: ctypes.CDLL | None = None +_backend: Backend | None = None + + +def _load_libc() -> ctypes.CDLL: + if sys.platform == "win32": + return ctypes.CDLL("msvcrt") + return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table + + +def _setup_signatures(lib: ctypes.CDLL) -> None: + """Declare argtypes/restype for the 8 chat_* functions exported by libsimplex. + + All result strings come back as raw c_void_p so the caller can free them + after copying — matches HandleCResult in cpp/simplex.cc:157-165. + """ + lib.chat_migrate_init.argtypes = [c_char_p, c_char_p, c_char_p, POINTER(c_void_p)] + lib.chat_migrate_init.restype = c_void_p + lib.chat_close_store.argtypes = [c_void_p] + lib.chat_close_store.restype = c_void_p + lib.chat_send_cmd.argtypes = [c_void_p, c_char_p] + lib.chat_send_cmd.restype = c_void_p + lib.chat_recv_msg_wait.argtypes = [c_void_p, c_int] + lib.chat_recv_msg_wait.restype = c_void_p + lib.chat_write_file.argtypes = [c_void_p, c_char_p, POINTER(c_uint8), c_int] + lib.chat_write_file.restype = c_void_p + lib.chat_read_file.argtypes = [c_char_p, c_char_p, c_char_p] + lib.chat_read_file.restype = POINTER(c_uint8) + lib.chat_encrypt_file.argtypes = [c_void_p, c_char_p, c_char_p] + lib.chat_encrypt_file.restype = c_void_p + lib.chat_decrypt_file.argtypes = [c_char_p, c_char_p, c_char_p, c_char_p] + lib.chat_decrypt_file.restype = c_void_p + + +def _hs_init(lib: ctypes.CDLL) -> None: + """Initialize the Haskell runtime exactly once. Mirrors cpp/simplex.cc:13-32.""" + if sys.platform == "win32": + argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"--install-signal-handlers=no"] + else: + argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"-xn", b"--install-signal-handlers=no"] + argc = c_int(len(argv_strs)) + arr = (c_char_p * (len(argv_strs) + 1))(*argv_strs, None) + arr_ptr = ctypes.byref(ctypes.cast(arr, POINTER(c_char_p))) + lib.hs_init_with_rtsopts.argtypes = [POINTER(c_int), POINTER(POINTER(c_char_p))] + lib.hs_init_with_rtsopts.restype = None + lib.hs_init_with_rtsopts(ctypes.byref(argc), arr_ptr) + + +def lib_for(backend: Backend) -> ctypes.CDLL: + """Resolve, load, and initialize libsimplex for the given backend. + + Idempotent for the same backend; raises if called with a different backend. + Concurrent calls serialize on the module-level lock. + """ + global _lib, _libc, _backend + with _lock: + if _lib is not None: + if _backend != backend: + raise RuntimeError( + f"libsimplex already loaded with backend={_backend!r}; " + f"cannot switch to {backend!r} in the same process" + ) + return _lib + libs_dir = _resolve_libs_dir(backend) + lib = ctypes.CDLL(str(libs_dir / _libname())) + _setup_signatures(lib) + _hs_init(lib) + _libc = _load_libc() + _lib = lib + _backend = backend + return lib + + +def libc() -> ctypes.CDLL: + """libc — needed by `core` to free Haskell-allocated result strings.""" + if _libc is None: + raise RuntimeError("lib_for() must be called before libc()") + return _libc +``` + +- [ ] **Step 2: Sanity-check imports** + +``` +PYTHONPATH=src python -c "import simplex_chat._native; print('ok')" +``` + +Expected: prints `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/_native.py +git commit -m "feat(python): _native ctypes signatures, hs_init, lib loader" +``` + +--- + +## Chunk 4: Core wrapper (`core.py`) + +Typed async wrappers around the 8 FFI functions. Handles JSON parse, buffer free, error translation. No public API yet — that lands in `ChatApi`. + +### Task 4.1: `core.py` — exceptions, enums, `chat_send_cmd`, `chat_recv_msg_wait` + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/core.py` + +- [ ] **Step 1: Write the core module** + +```python +"""Internal typed async wrapper around libsimplex's 8 C ABI functions. + +Users interact with `Bot` / `ChatApi`. This module is exposed as +`simplex_chat.core` for tests and the api.ChatApi class only. +""" +from __future__ import annotations + +import asyncio +import ctypes +import json +from enum import StrEnum +from typing import TypedDict + +from . import _native +from .types import T, CR, CEvt + + +class ChatAPIError(Exception): + """Raised when chat_send_cmd / chat_recv_msg_wait returns a chat error.""" + def __init__(self, message: str, chat_error: T.ChatError | None = None): + super().__init__(message) + self.chat_error = chat_error + + +class ChatInitError(Exception): + """Raised when chat_migrate_init returns a DBMigrationResult error.""" + def __init__(self, message: str, db_migration_error): + super().__init__(message) + self.db_migration_error = db_migration_error + + +class MigrationConfirmation(StrEnum): + YES_UP = "yesUp" + YES_UP_DOWN = "yesUpDown" + CONSOLE = "console" + ERROR = "error" + + +class CryptoArgs(TypedDict): # wire-format JSON; camelCase fields + fileKey: str + fileNonce: str + + +def _read_and_free(ptr: int | None) -> str: + """Copy a Haskell-allocated null-terminated UTF-8 string and free its buffer. + + Mirrors HandleCResult in packages/simplex-chat-nodejs/cpp/simplex.cc:157-165. + """ + if not ptr: + raise RuntimeError("null pointer returned from libsimplex") + try: + return ctypes.string_at(ptr).decode("utf-8") + finally: + _native.libc().free(ctypes.c_void_p(ptr)) + + +async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse: + def _call() -> str: + ptr = _native._lib.chat_send_cmd(ctrl, cmd.encode("utf-8")) + return _read_and_free(ptr) + raw = await asyncio.to_thread(_call) + parsed = json.loads(raw) + if "result" in parsed and isinstance(parsed["result"], dict): + return parsed["result"] # type: ignore[return-value] + err = parsed.get("error") + if isinstance(err, dict): + raise ChatAPIError(f"chat command error: {err.get('type')}", err) + raise ChatAPIError(f"invalid chat command result: {raw[:200]}") + + +async def chat_recv_msg_wait(ctrl: int, wait_us: int = 5_000_000) -> CEvt.ChatEvent | None: + def _call() -> str: + ptr = _native._lib.chat_recv_msg_wait(ctrl, wait_us) + if not ptr: + return "" + return _read_and_free(ptr) + raw = await asyncio.to_thread(_call) + if not raw: + return None + parsed = json.loads(raw) + if "result" in parsed and isinstance(parsed["result"], dict): + return parsed["result"] # type: ignore[return-value] + err = parsed.get("error") + if isinstance(err, dict): + raise ChatAPIError(f"chat event error: {err.get('type')}", err) + raise ChatAPIError(f"invalid chat event: {raw[:200]}") +``` + +- [ ] **Step 2: Verify import** + +``` +PYTHONPATH=src python -c "from simplex_chat.core import chat_send_cmd, ChatAPIError, MigrationConfirmation; print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/core.py +git commit -m "feat(python): core typed wrappers for chat_send_cmd + chat_recv_msg_wait" +``` + +### Task 4.2: `core.py` — remaining FFI functions (`chat_migrate_init`, `chat_close_store`, file ops) + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/core.py` + +- [ ] **Step 1: Append the remaining functions** + +```python +async def chat_migrate_init( + db_path: str, db_key: str, confirm: MigrationConfirmation +) -> int: + """Initialize chat controller. Returns opaque ctrl pointer as Python int.""" + def _call() -> tuple[int, str]: + ctrl = ctypes.c_void_p() + ptr = _native._lib.chat_migrate_init( + db_path.encode("utf-8"), + db_key.encode("utf-8"), + confirm.encode("utf-8"), + ctypes.byref(ctrl), + ) + return (ctrl.value or 0, _read_and_free(ptr)) + ctrl_val, raw = await asyncio.to_thread(_call) + parsed = json.loads(raw) + if parsed.get("type") == "ok": + return ctrl_val + raise ChatInitError( + "Database or migration error (see db_migration_error)", + parsed, + ) + + +async def chat_close_store(ctrl: int) -> None: + def _call() -> str: + ptr = _native._lib.chat_close_store(ctrl) + return _read_and_free(ptr) + res = await asyncio.to_thread(_call) + if res: + raise RuntimeError(res) + + +async def chat_write_file(ctrl: int, path: str, data: bytes) -> CryptoArgs: + def _call() -> str: + buf = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) + ptr = _native._lib.chat_write_file(ctrl, path.encode("utf-8"), buf, len(data)) + return _read_and_free(ptr) + raw = await asyncio.to_thread(_call) + return _crypto_args_result(raw) + + +async def chat_read_file(path: str, args: CryptoArgs) -> bytes: + def _call() -> bytes: + ptr = _native._lib.chat_read_file( + path.encode("utf-8"), + args["fileKey"].encode("utf-8"), + args["fileNonce"].encode("utf-8"), + ) + if not ptr: + raise RuntimeError("chat_read_file returned null") + addr = ctypes.cast(ptr, ctypes.c_void_p).value or 0 + try: + status = ctypes.cast(addr, ctypes.POINTER(ctypes.c_uint8))[0] + if status == 1: + msg = ctypes.string_at(addr + 1).decode("utf-8") + raise RuntimeError(msg) + if status != 0: + raise RuntimeError(f"unexpected status {status} from chat_read_file") + length = ctypes.cast(addr + 1, ctypes.POINTER(ctypes.c_uint32))[0] + return ctypes.string_at(addr + 5, length) + finally: + _native.libc().free(ctypes.c_void_p(addr)) + return await asyncio.to_thread(_call) + + +async def chat_encrypt_file(ctrl: int, src: str, dst: str) -> CryptoArgs: + def _call() -> str: + ptr = _native._lib.chat_encrypt_file( + ctrl, src.encode("utf-8"), dst.encode("utf-8") + ) + return _read_and_free(ptr) + return _crypto_args_result(await asyncio.to_thread(_call)) + + +async def chat_decrypt_file(src: str, args: CryptoArgs, dst: str) -> None: + def _call() -> str: + ptr = _native._lib.chat_decrypt_file( + src.encode("utf-8"), + args["fileKey"].encode("utf-8"), + args["fileNonce"].encode("utf-8"), + dst.encode("utf-8"), + ) + return _read_and_free(ptr) + res = await asyncio.to_thread(_call) + if res: + raise RuntimeError(res) + + +def _crypto_args_result(raw: str) -> CryptoArgs: + parsed = json.loads(raw) + if parsed.get("type") == "result": + return parsed["cryptoArgs"] + if parsed.get("type") == "error": + raise RuntimeError(parsed.get("writeError", "unknown write error")) + raise RuntimeError(f"unexpected result: {raw[:200]}") +``` + +- [ ] **Step 2: Re-verify import** + +``` +PYTHONPATH=src python -c "from simplex_chat import core; print(dir(core))" +``` + +Expected: prints a list including `chat_migrate_init`, `chat_close_store`, all eight functions. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/core.py +git commit -m "feat(python): core wrappers for migrate, close, file ops" +``` + +--- + +## Chunk 5: ChatApi class + +Escape-hatch class with the 6 control methods plus ~40 `api_xxx` methods, one per Node `apiXxx`. Repetitive — done in batches grouped by domain. + +### Task 5.1: `api.py` — `ChatApi` class with control methods + `Db` config + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/api.py` + +- [ ] **Step 1: Write Db config dataclasses + ChatApi base** + +```python +"""Low-level escape-hatch API. Most users go through `Bot` instead.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from . import core +from .core import ChatAPIError, ChatInitError, MigrationConfirmation +from .types import CC, CEvt, CR, T + + +@dataclass(slots=True) +class SqliteDb: + file_prefix: str + encryption_key: str | None = None + + +@dataclass(slots=True) +class PostgresDb: + connection_string: str + schema_prefix: str | None = None + + +Db = SqliteDb | PostgresDb + + +def _db_to_migrate_args(db: Db) -> tuple[str, str, str]: + """Returns (path-or-prefix, key-or-conn, backend).""" + if isinstance(db, SqliteDb): + return (db.file_prefix, db.encryption_key or "", "sqlite") + if isinstance(db, PostgresDb): + return (db.schema_prefix or "", db.connection_string, "postgres") + raise TypeError(f"Unknown db: {db!r}") + + +class ChatCommandError(Exception): + def __init__(self, message: str, response: CR.ChatResponse): + super().__init__(message) + self.response = response + + +class ChatApi: + def __init__(self, ctrl: int, backend: str): + self._ctrl = ctrl + self._backend = backend + + @classmethod + async def init( + cls, + db: Db, + confirm: MigrationConfirmation = MigrationConfirmation.YES_UP, + ) -> "ChatApi": + from . import _native + path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db) + # Trigger lazy lib load with the right backend BEFORE chat_migrate_init. + _native.lib_for(backend) # type: ignore[arg-type] + ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm) + return cls(ctrl, backend) + + @property + def ctrl(self) -> int: + return self._ctrl + + async def start_chat(self) -> None: + r = await self.send_chat_cmd( + CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True}) + ) + if r.get("type") not in ("chatStarted", "chatRunning"): + raise ChatCommandError("error starting chat", r) + + async def stop_chat(self) -> None: + r = await self.send_chat_cmd("/_stop") + if r.get("type") != "chatStopped": + raise ChatCommandError("error stopping chat", r) + + async def close(self) -> None: + await core.chat_close_store(self._ctrl) + + async def send_chat_cmd(self, cmd: str) -> CR.ChatResponse: + return await core.chat_send_cmd(self._ctrl, cmd) + + async def recv_chat_event(self, wait_us: int = 5_000_000) -> CEvt.ChatEvent | None: + return await core.chat_recv_msg_wait(self._ctrl, wait_us) +``` + +- [ ] **Step 2: Verify import** + +``` +PYTHONPATH=src python -c "from simplex_chat.api import ChatApi, SqliteDb, PostgresDb; print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/api.py +git commit -m "feat(python): ChatApi base class with control methods + Db config" +``` + +### Task 5.2: `api.py` — implement ~40 `api_xxx` methods + +Mirror methods from `packages/simplex-chat-nodejs/src/api.ts:344-958`. Each method is the same shape: + +```python +async def api_(self, ...args) -> : + r = await self.send_chat_cmd(CC._cmd_string({"...": args, ...})) + if r["type"] == "": + return r[""] + raise ChatCommandError("error ", r) +``` + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/api.py` + +- [ ] **Step 1: Reference the Node lib while implementing** + +```bash +# Open the Node source side-by-side +sed -n '344,958p' packages/simplex-chat-nodejs/src/api.ts | less +``` + +- [ ] **Step 2: Implement methods in groups, one commit per group** + +The Node file groups by domain — port them in the same order: + +| Group | Methods (Node names → Python snake_case) | Lines in api.ts | +|---|---|---| +| Address | apiCreateUserAddress, apiDeleteUserAddress, apiGetUserAddress, apiSetProfileAddress, apiSetAddressSettings | 344-409 | +| Messages | apiSendMessages, apiSendTextMessage, apiSendTextReply, apiUpdateChatItem, apiDeleteChatItems, apiDeleteMemberChatItem, apiChatItemReaction | 411-505 | +| Files | apiReceiveFile, apiCancelFile | 507-525 | +| Groups | apiAddMember, apiJoinGroup, apiAcceptMember, apiSetMembersRole, apiBlockMembersForAll, apiRemoveMembers, apiLeaveGroup, apiListMembers, apiNewGroup, apiUpdateGroupProfile | 527-625 | +| Group links | apiCreateGroupLink, apiSetGroupLinkMemberRole, apiDeleteGroupLink, apiGetGroupLink, apiGetGroupLinkStr | 627-672 | +| Connections | apiCreateLink, apiConnectPlan, apiConnect, apiConnectActiveUser, apiAcceptContactRequest, apiRejectContactRequest | 674-746 | +| Chats | apiListContacts, apiListGroups, apiGetChats, apiDeleteChat, apiSetGroupCustomData, apiSetContactCustomData, apiSetAutoAcceptMemberContacts, apiGetChat | 748-841 | +| Users | apiGetActiveUser, apiCreateActiveUser, apiListUsers, apiSetActiveUser, apiDeleteUser, apiUpdateProfile, apiSetContactPrefs | 843-928 | +| Member contacts | apiCreateMemberContact, apiSendMemberContactInvitation | 930-957 | + +For each method: +- TS `apiCreateUserAddress(userId)` → Python `api_create_user_address(self, user_id: int) -> T.CreatedConnLink` +- Use the autogenerated `CC._cmd_string({...})` to build the command string. Field names inside the dict are camelCase wire format. +- Use type narrowing on `r["type"]` to extract the expected response field. + +Example port: + +```python +async def api_create_user_address(self, user_id: int) -> T.CreatedConnLink: + r = await self.send_chat_cmd(CC.APICreateMyAddress_cmd_string({"userId": user_id})) + if r["type"] == "userContactLinkCreated": + return r["connLinkContact"] + raise ChatCommandError("error creating user address", r) +``` + +Commit pattern: one commit per group from the table above. Commit messages: + +```bash +git commit -m "feat(python): ChatApi address methods" +git commit -m "feat(python): ChatApi message methods" +git commit -m "feat(python): ChatApi file methods" +# … etc, one per group +``` + +- [ ] **Step 3: After all groups land, verify all methods are present** + +```bash +grep -c "async def api_" packages/simplex-chat-python/src/simplex_chat/api.py +``` + +Expected: ≥40 (matches the count of `apiXxx` methods in api.ts). + +- [ ] **Step 4: Verify import after all groups** + +``` +PYTHONPATH=src python -c "from simplex_chat.api import ChatApi; api = ChatApi.__init__; print('ok')" +``` + +Expected: `ok`. + +--- + +## Chunk 6: Bot class + +User-facing `Bot`: decorator-registered handlers, kwarg filters, `Message` wrapper with content-narrowed subclasses, dual lifecycle, middleware. + +### Task 6.1: `Message` wrapper class + content-narrowed aliases + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Write `Message` and aliases** + +```python +"""User-facing `Bot` API: decorators, filters, Message wrapper, lifecycle.""" +from __future__ import annotations + +import asyncio +import logging +import re +import signal as _signal +from dataclasses import dataclass +from typing import ( + Any, Awaitable, Callable, Generic, Literal, TYPE_CHECKING, TypeVar, overload +) + +from . import _native +from .api import ChatApi, ChatCommandError, Db, PostgresDb, SqliteDb +from .core import ChatAPIError, MigrationConfirmation +from .types import CC, CEvt, CR, T + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + +log = logging.getLogger("simplex_chat") + +C = TypeVar("C", bound=T.MsgContent) + + +@dataclass(slots=True) +class BotProfile: + display_name: str + full_name: str = "" + short_descr: str | None = None + image: str | None = None + + +@dataclass(slots=True) +class BotCommand: + keyword: str + label: str + + +@dataclass(slots=True, frozen=True) +class ParsedCommand: + keyword: str + args: str + + +@dataclass(slots=True, frozen=True) +class Message(Generic[C]): + chat_item: T.AChatItem + content: C + bot: "Bot" + + @property + def chat_info(self) -> T.ChatInfo: + return self.chat_item["chatInfo"] + + @property + def text(self) -> str | None: + c = self.content + if isinstance(c, dict): + return c.get("text") # type: ignore[return-value] + return None + + async def reply(self, text: str) -> "Message": + items = await self.bot.api.api_send_text_reply(self.chat_item, text) + return Message(chat_item=items[0], content=items[0]["chatItem"]["content"], bot=self.bot) + + async def reply_content(self, content: T.MsgContent) -> "Message": + items = await self.bot.api.api_send_messages( + self.chat_info, [{"msgContent": content, "mentions": {}}] + ) + return Message(chat_item=items[0], content=items[0]["chatItem"]["content"], bot=self.bot) + + async def react(self, emoji: str) -> None: + # Implementation defers to ChatApi.api_chat_item_reaction + ... + + async def delete(self) -> None: ... + async def forward(self, to: T.ChatRef) -> "Message": ... + + +# Concrete narrowed aliases — exported from package __init__.py +TextMessage = Message[T.MsgContent_Text] +ImageMessage = Message[T.MsgContent_Image] +FileMessage = Message[T.MsgContent_File] +VoiceMessage = Message[T.MsgContent_Voice] +VideoMessage = Message[T.MsgContent_Video] +LinkMessage = Message[T.MsgContent_Link] +# … one per T.MsgContent_* variant; full list mirrors what's emitted in _types.py +``` + +- [ ] **Step 2: Verify import** + +``` +PYTHONPATH=src python -c "from simplex_chat.bot import Message, TextMessage, BotProfile; print('ok')" +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py +git commit -m "feat(python): Bot module skeleton — Message wrapper + aliases" +``` + +### Task 6.2: Filter compilation (`filters.py`) + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/filters.py` +- Create: `packages/simplex-chat-python/tests/test_filters.py` + +- [ ] **Step 1: Write filter tests** + +```python +# tests/test_filters.py +import re +import pytest +from simplex_chat.filters import compile_message_filter + +def _msg(content_type="text", text=None, chat_type="direct", + from_role=None, from_contact_id=None, group_id=None): + """Build a minimal mock Message-like object for filter testing.""" + class M: + pass + m = M() + m.content = {"type": content_type, "text": text} if text is not None else {"type": content_type} + m.chat_item = {"chatInfo": { + "type": chat_type, + **({"groupInfo": {"groupId": group_id}} if chat_type == "group" else {}), + }} + # Sender extraction is implementation-detail of the filter — keep test fixture pragmatic. + m._from_role = from_role + m._from_contact_id = from_contact_id + return m + +def test_no_filters_matches_all(): + f = compile_message_filter({}) + assert f(_msg(content_type="text")) + assert f(_msg(content_type="image")) + +def test_content_type_singular(): + f = compile_message_filter({"content_type": "text"}) + assert f(_msg(content_type="text")) + assert not f(_msg(content_type="image")) + +def test_content_type_tuple_or(): + f = compile_message_filter({"content_type": ("text", "image")}) + assert f(_msg(content_type="text")) + assert f(_msg(content_type="image")) + assert not f(_msg(content_type="voice")) + +def test_text_exact(): + f = compile_message_filter({"text": "hello"}) + assert f(_msg(text="hello")) + assert not f(_msg(text="world")) + +def test_text_regex(): + f = compile_message_filter({"text": re.compile(r"^\d+$")}) + assert f(_msg(text="123")) + assert not f(_msg(text="abc")) + +def test_when_callable(): + f = compile_message_filter({"when": lambda m: m.content["type"] == "voice"}) + assert f(_msg(content_type="voice")) + assert not f(_msg(content_type="text")) + +def test_combined_and(): + f = compile_message_filter({"content_type": "text", "text": re.compile(r"\d")}) + assert f(_msg(content_type="text", text="abc123")) + assert not f(_msg(content_type="text", text="abc")) + assert not f(_msg(content_type="image")) +``` + +- [ ] **Step 2: Implement `compile_message_filter`** + +```python +# filters.py +from __future__ import annotations +import re +from typing import Any, Callable + +def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]: + """Compile filter kwargs into a single predicate function. + + Multiple kwargs combine with AND; tuples within a kwarg combine with OR. + `when` is the last predicate evaluated. + """ + predicates: list[Callable[[Any], bool]] = [] + + if (ct := kw.get("content_type")) is not None: + ct_set = (ct,) if isinstance(ct, str) else tuple(ct) + predicates.append(lambda m: m.content.get("type") in ct_set) + + if (t := kw.get("text")) is not None: + if isinstance(t, re.Pattern): + predicates.append(lambda m: bool(t.search(m.content.get("text", "") or ""))) + else: + predicates.append(lambda m: m.content.get("text") == t) + + if (ct := kw.get("chat_type")) is not None: + ct_set = (ct,) if isinstance(ct, str) else tuple(ct) + predicates.append(lambda m: m.chat_item["chatInfo"]["type"] in ct_set) + + if (gid := kw.get("group_id")) is not None: + gid_set = (gid,) if isinstance(gid, int) else tuple(gid) + def gid_match(m: Any) -> bool: + ci = m.chat_item["chatInfo"] + return ci["type"] == "group" and ci["groupInfo"]["groupId"] in gid_set + predicates.append(gid_match) + + # from_role, from_contact_id, from_member_id are looked up via helpers in filters.py + # — defer to integration with ChatInfo / GroupMember accessors implemented later. + + if (when := kw.get("when")) is not None: + predicates.append(when) + + if not predicates: + return lambda _m: True + return lambda m: all(p(m) for p in predicates) +``` + +- [ ] **Step 3: Run filter tests** + +``` +PYTHONPATH=src pytest tests/test_filters.py -v +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/filters.py \ + packages/simplex-chat-python/tests/test_filters.py +git commit -m "feat(python): filter kwarg compilation + tests" +``` + +### Task 6.3: `Bot` class — construction, decorators, registration + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Add `Bot` class with `__init__`, decorator methods, registration storage** + +```python +# Append to bot.py + +MessageHandler = Callable[[Message], Awaitable[None]] +CommandHandler = Callable[[Message, ParsedCommand], Awaitable[None]] +EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None]] +# Note: handlers are async-only (matches spec). Users must `async def handler(...)`. + + +class Middleware: + async def __call__( + self, + handler: Callable[[Message, dict[str, object]], Awaitable[None]], + message: Message, + data: dict[str, object], + ) -> None: + await handler(message, data) + + +class Bot: + def __init__( + self, + *, + profile: BotProfile, + db: Db, + welcome: str | T.MsgContent | None = None, + commands: list[BotCommand] | None = None, + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + create_address: bool = True, + update_address: bool = True, + update_profile: bool = True, + auto_accept: bool = True, + business_address: bool = False, + allow_files: bool = False, + use_bot_profile: bool = True, + log_contacts: bool = True, + log_network: bool = False, + ) -> None: + self._profile = profile + self._db = db + self._welcome = welcome + self._commands = commands or [] + self._confirm_migrations = confirm_migrations + self._opts = { + "create_address": create_address, + "update_address": update_address, + "update_profile": update_profile, + "auto_accept": auto_accept, + "business_address": business_address, + "allow_files": allow_files, + "use_bot_profile": use_bot_profile, + "log_contacts": log_contacts, + "log_network": log_network, + } + self._api: ChatApi | None = None + self._serving = False + self._stop_event = asyncio.Event() + self._message_handlers: list[tuple[Callable[[Message], bool], MessageHandler]] = [] + self._command_handlers: list[tuple[tuple[str, ...], Callable[[Message], bool], CommandHandler]] = [] + self._event_handlers: dict[str, list[EventHandler]] = {} + self._middleware: list[Middleware] = [] + + @property + def api(self) -> ChatApi: + if self._api is None: + raise RuntimeError("Bot not initialized — call bot.run() or use `async with bot:`") + return self._api + + # --- decorator registration (overloads omitted for brevity; see spec for full list) --- + + def on_message(self, **filter_kw: Any) -> Callable[[MessageHandler], MessageHandler]: + from .filters import compile_message_filter + predicate = compile_message_filter(filter_kw) + def deco(fn: MessageHandler) -> MessageHandler: + self._message_handlers.append((predicate, fn)) + return fn + return deco + + def on_command( + self, name: str | tuple[str, ...], **filter_kw: Any + ) -> Callable[[CommandHandler], CommandHandler]: + names = (name,) if isinstance(name, str) else tuple(name) + from .filters import compile_message_filter + predicate = compile_message_filter(filter_kw) + def deco(fn: CommandHandler) -> CommandHandler: + self._command_handlers.append((names, predicate, fn)) + return fn + return deco + + def on_event(self, event: CEvt.Tag, /) -> Callable[[EventHandler], EventHandler]: + def deco(fn: EventHandler) -> EventHandler: + self._event_handlers.setdefault(event, []).append(fn) + return fn + return deco + + def use(self, middleware: Middleware) -> None: + self._middleware.append(middleware) +``` + +- [ ] **Step 2: Discover the full `MsgContent` variant list from generated types** + +After Chunk 1 lands, the generated file lists every variant. Get the canonical list: + +```bash +grep -E '^class MsgContent_' packages/simplex-chat-python/src/simplex_chat/types/_types.py | sed 's/class \([^(]*\).*/\1/' +``` + +Expected output (approximately — exact list comes from Haskell `MsgContent` defined at `bots/src/API/Docs/Types.hs:309` with prefix `"MC"`): + +``` +MsgContent_Text +MsgContent_Link +MsgContent_Image +MsgContent_Video +MsgContent_Voice +MsgContent_File +MsgContent_Report +MsgContent_Chat +MsgContent_Unknown +``` + +For each variant in that list, define both a `Message` alias (in Step 1 above — extend the alias block to cover every variant) and one decorator overload (this step). + +- [ ] **Step 3: Add the typed overloads — one per variant from Step 2** + +After the plain `on_message` definition, add a typed overload for each variant. The pattern below shows two; repeat for every variant the previous step found: + +```python + # Type-only overloads — compiler-visible, no runtime effect. + # MUST cover every variant from `grep '^class MsgContent_' _types.py`. + @overload + def on_message(self, *, content_type: Literal["text"], **rest: Any + ) -> Callable[[Callable[[TextMessage], Awaitable[None]]], + Callable[[TextMessage], Awaitable[None]]]: ... + @overload + def on_message(self, *, content_type: Literal["image"], **rest: Any + ) -> Callable[[Callable[[ImageMessage], Awaitable[None]]], + Callable[[ImageMessage], Awaitable[None]]]: ... + # … one per MsgContent variant; verify count matches Step 2's grep output … + @overload + def on_message(self, **rest: Any + ) -> Callable[[MessageHandler], MessageHandler]: ... +``` + +The per-variant tag string (`"text"`, `"image"`, …) is the lowercased suffix after `MsgContent_` — see the `Literal[...]` member on each generated TypedDict's `type` field for the canonical spelling. + +- [ ] **Step 3: Verify import + decorator binding** + +```python +# tests/test_bot_registration.py +from simplex_chat.bot import Bot, BotProfile +from simplex_chat.api import SqliteDb + +def test_decorator_registers_handler(): + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + @bot.on_message(content_type="text") + async def h(msg): pass + assert len(bot._message_handlers) == 1 +``` + +``` +PYTHONPATH=src pytest tests/test_bot_registration.py -v +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py \ + packages/simplex-chat-python/tests/test_bot_registration.py +git commit -m "feat(python): Bot class — construction + decorator registration" +``` + +### Task 6.4: Lifecycle: `run()`, `serve_forever()`, `__aenter__/__aexit__`, `stop()` + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Append lifecycle methods** + +```python +class Bot: + # … existing methods … + + async def __aenter__(self) -> "Bot": + self._api = await ChatApi.init(self._db, self._confirm_migrations) + await self._api.start_chat() + # TODO Task 6.5: profile + address sync via mkBotProfile/createOrUpdateAddress + return self + + async def __aexit__(self, *exc_info: object) -> None: + self.stop() + if self._api is not None: + try: + await self._api.stop_chat() + finally: + await self._api.close() + self._api = None + + def run(self) -> None: + """Blocking entry: runs serve_forever() with a SIGINT handler installed.""" + async def _main() -> None: + async with self: + loop = asyncio.get_running_loop() + if hasattr(_signal, "SIGINT"): + try: + loop.add_signal_handler(_signal.SIGINT, self.stop) + loop.add_signal_handler(_signal.SIGTERM, self.stop) + except NotImplementedError: # Windows + _signal.signal(_signal.SIGINT, lambda *_: self.stop()) + await self.serve_forever() + asyncio.run(_main()) + + async def serve_forever(self) -> None: + if self._serving: + raise RuntimeError("already serving") + self._serving = True + self._stop_event.clear() + try: + await self._receive_loop() + finally: + self._serving = False + + def stop(self) -> None: + self._stop_event.set() + + async def _receive_loop(self) -> None: + while not self._stop_event.is_set(): + try: + event = await self.api.recv_chat_event(wait_us=5_000_000) + except ChatAPIError as e: + log.error("chat event error: %s", e) + continue + if event is None: + continue + await self._dispatch_event(event) +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py +git commit -m "feat(python): Bot lifecycle (run, serve_forever, async-context, stop)" +``` + +### Task 6.5: Event dispatch + handler invocation through middleware + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Append `_dispatch_event` and helpers** + +```python +class Bot: + async def _dispatch_event(self, event: CEvt.ChatEvent) -> None: + # 1. Tag-targeted on_event handlers (registration order) + tag = event["type"] + for h in self._event_handlers.get(tag, []): + try: + await h(event) + except Exception: + log.exception("on_event handler failed") + # 2. If newChatItems → message + command dispatch. + # Tag check narrows the union; pyright sees event as CEvt.ChatEvent_NewChatItems below. + if tag == "newChatItems": + evt: CEvt.ChatEvent_NewChatItems = event # type: ignore[assignment] + for ci in evt["chatItems"]: + content = ci["chatItem"]["content"] + if content["type"] != "rcvMsgContent": + continue + msg_content = content["msgContent"] + msg = Message(chat_item=ci, content=msg_content, bot=self) + await self._dispatch_message(msg) + + async def _dispatch_message(self, msg: Message) -> None: + # Run all matching message handlers + for predicate, handler in self._message_handlers: + if predicate(msg): + await self._invoke_with_middleware(handler, msg) + # Then any matching command handlers + cmd = self._parse_command(msg) + if cmd is not None: + for names, predicate, handler in self._command_handlers: + if cmd.keyword in names and predicate(msg): + await self._invoke_command_with_middleware(handler, msg, cmd) + + async def _invoke_with_middleware( + self, handler: MessageHandler, message: Message + ) -> None: + async def call(m: Message, _data: dict[str, object]) -> None: + await handler(m) + + chain: Callable[[Message, dict[str, object]], Awaitable[None]] = call + # mw=mw, inner=inner bind the loop variable (late-binding fix) + for mw in reversed(self._middleware): + inner = chain + async def _wrapped(m: Message, d: dict[str, object], mw=mw, inner=inner) -> None: + await mw(inner, m, d) + chain = _wrapped + + try: + await chain(message, {}) + except Exception: + log.exception("message handler failed") + + async def _invoke_command_with_middleware( + self, handler: CommandHandler, message: Message, cmd: ParsedCommand + ) -> None: + # Same shape as _invoke_with_middleware but the inner call gets cmd too. + async def call(m: Message, _data: dict[str, object]) -> None: + await handler(m, cmd) + chain: Callable[[Message, dict[str, object]], Awaitable[None]] = call + for mw in reversed(self._middleware): + inner = chain + async def _wrapped(m: Message, d: dict[str, object], mw=mw, inner=inner) -> None: + await mw(inner, m, d) + chain = _wrapped + try: + await chain(message, {}) + except Exception: + log.exception("command handler failed") + + @staticmethod + def _parse_command(msg: Message) -> ParsedCommand | None: + text = msg.text + if not text or not text.startswith("/"): + return None + body = text[1:].lstrip() + if not body: + return None + if " " in body: + kw, args = body.split(" ", 1) + return ParsedCommand(keyword=kw, args=args.strip()) + return ParsedCommand(keyword=body, args="") +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py +git commit -m "feat(python): Bot event dispatch + middleware chaining" +``` + +### Task 6.6: Profile + address sync (mirror Node `bot.ts:158-214`) + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Implement initial profile + address sync inside `__aenter__`** + +Mirror `bot.ts` `createBotUser`, `createOrUpdateAddress`, `updateBotUserProfile`, `mkBotProfile`. Each is straightforward — fetch via `self._api.api_get_active_user()`, compare with `self._profile`, update if `update_profile=True`, etc. Reference: `packages/simplex-chat-nodejs/src/bot.ts:158-214`. + +- [ ] **Step 2: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py +git commit -m "feat(python): Bot profile + address sync on init" +``` + +### Task 6.7: Update package `__init__.py` to export public API + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/__init__.py` + +- [ ] **Step 1: Add exports** + +```python +"""SimpleX Chat — Python client library for chat bots.""" +from ._version import __version__ +from .api import ChatApi, ChatCommandError, Db, PostgresDb, SqliteDb +from .bot import ( + Bot, + BotCommand, + BotProfile, + FileMessage, + ImageMessage, + Message, + Middleware, + ParsedCommand, + TextMessage, + VoiceMessage, + # … all aliases … +) +from .core import ChatAPIError, ChatInitError, MigrationConfirmation, CryptoArgs + +__all__ = [ + "__version__", + # … all the names above … +] +``` + +- [ ] **Step 2: Verify clean import** + +``` +PYTHONPATH=src python -c "from simplex_chat import Bot, TextMessage, SqliteDb, BotProfile; print('ok')" +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/__init__.py +git commit -m "feat(python): export public API from package __init__" +``` + +--- + +## Chunk 7: Tests + CLI + +Pytest suite covering native, codegen, smoke; pre-fetch CLI; example bot. + +### Task 7.1: `__main__.py` — `python -m simplex_chat install` + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/__main__.py` + +- [ ] **Step 1: Write the CLI** + +```python +"""CLI: python -m simplex_chat install [--backend=sqlite|postgres]""" +from __future__ import annotations +import argparse +import sys +from . import _native + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="simplex_chat") + sub = p.add_subparsers(dest="command", required=True) + install = sub.add_parser("install", help="Pre-fetch libsimplex into the user cache") + install.add_argument( + "--backend", choices=["sqlite", "postgres"], default="sqlite", + help="which libsimplex variant to download (default: sqlite)", + ) + args = p.parse_args(argv) + if args.command == "install": + try: + path = _native._resolve_libs_dir(args.backend) + print(f"libsimplex installed at: {path}") + return 0 + except Exception as e: + print(f"install failed: {e}", file=sys.stderr) + return 1 + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 2: Smoke-check the CLI exists** + +``` +PYTHONPATH=src python -m simplex_chat install --help +``` + +Expected: prints argparse help. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/__main__.py +git commit -m "feat(python): python -m simplex_chat install CLI" +``` + +### Task 7.2: Codegen smoke test + +**Files:** +- Create: `packages/simplex-chat-python/tests/test_codegen.py` + +- [ ] **Step 1: Write the test** + +```python +"""Sanity checks on auto-generated wire types — catches generator regressions.""" +import typing +from simplex_chat.types import T, CC, CR, CEvt + + +def test_types_module_imports(): + """Every generated module imports cleanly with no SyntaxError.""" + assert T is not None and CC is not None and CR is not None and CEvt is not None + + +def test_chat_type_is_literal_enum(): + """ChatType should be a Literal of expected member set.""" + origin = typing.get_origin(T.ChatType) + args = typing.get_args(T.ChatType) + # Python ≥3.11 typing.Literal: origin is Literal, args is the tuple of values + assert "direct" in args + assert "group" in args + assert "local" in args + + +def test_known_command_has_cmd_string(): + s = CC.APICreateMyAddress_cmd_string({"userId": 1}) + assert s == "/_address 1" + + +def test_chat_response_tag_alias_present(): + """ChatResponse_Tag union of literals exists.""" + assert hasattr(CR, "ChatResponse_Tag") +``` + +- [ ] **Step 2: Run** + +``` +PYTHONPATH=src pytest tests/test_codegen.py -v +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/tests/test_codegen.py +git commit -m "test(python): codegen sanity checks" +``` + +### Task 7.3: Smoke test with stub libsimplex + +**Files:** +- Create: `packages/simplex-chat-python/tests/test_smoke.py` +- Create: `packages/simplex-chat-python/tests/_stub_libsimplex.c` + +- [ ] **Step 1: Write a tiny C stub that returns canned JSON** + +```c +// _stub_libsimplex.c — compile to libsimplex.so for smoke testing +#include +#include +#include + +void hs_init_with_rtsopts(int *argc, char ***argv) { (void)argc; (void)argv; } + +static char *dup_str(const char *s) { return strdup(s); } + +char *chat_migrate_init(const char *path, const char *key, const char *confirm, void **ctrl) { + (void)path; (void)key; (void)confirm; + *ctrl = (void*)0x1; + return dup_str("{\"type\":\"ok\"}"); +} + +char *chat_close_store(void *ctrl) { (void)ctrl; return dup_str(""); } + +char *chat_send_cmd(void *ctrl, const char *cmd) { + (void)ctrl; (void)cmd; + return dup_str("{\"result\":{\"type\":\"chatStarted\"}}"); +} + +char *chat_recv_msg_wait(void *ctrl, int wait) { + (void)ctrl; (void)wait; + return NULL; +} +// stubs for the file functions: +char *chat_write_file() { return dup_str("{\"type\":\"result\",\"cryptoArgs\":{\"fileKey\":\"k\",\"fileNonce\":\"n\"}}"); } +char *chat_read_file() { return NULL; } +char *chat_encrypt_file() { return dup_str("{\"type\":\"result\",\"cryptoArgs\":{\"fileKey\":\"k\",\"fileNonce\":\"n\"}}"); } +char *chat_decrypt_file() { return dup_str(""); } +``` + +- [ ] **Step 2: Write the smoke test that compiles and uses the stub** + +```python +import asyncio +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +import pytest + + +@pytest.fixture +def stub_libs_dir(tmp_path): + """Compile _stub_libsimplex.c into a libsimplex.so in tmp_path.""" + src = Path(__file__).parent / "_stub_libsimplex.c" + if sys.platform == "win32": + pytest.skip("stub compilation not implemented for Windows") + libname = "libsimplex.dylib" if sys.platform == "darwin" else "libsimplex.so" + out = tmp_path / libname + cc = shutil.which("cc") or shutil.which("gcc") or pytest.skip("no C compiler") + subprocess.run([cc, "-shared", "-fPIC", str(src), "-o", str(out)], check=True) + return tmp_path + + +@pytest.mark.asyncio +async def test_chat_api_init_and_start(stub_libs_dir, monkeypatch): + monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(stub_libs_dir)) + from simplex_chat.api import ChatApi, SqliteDb + api = await ChatApi.init(SqliteDb(file_prefix=str(stub_libs_dir / "db"))) + await api.start_chat() + await api.close() +``` + +- [ ] **Step 3: Run** + +`test` extras are already declared in `pyproject.toml` (Task 2.1). + +``` +pip install -e ".[test]" +PYTHONPATH=src pytest tests/test_smoke.py -v +``` + +Expected: PASS on Linux/macOS; SKIPPED on Windows. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/tests/_stub_libsimplex.c \ + packages/simplex-chat-python/tests/test_smoke.py +git commit -m "test(python): smoke test against stub libsimplex" +``` + +### Task 7.4: Squaring-bot example + +**Files:** +- Create: `packages/simplex-chat-python/examples/squaring_bot.py` + +- [ ] **Step 1: Write the example from the spec** + +```python +"""Squaring bot — receives numbers, replies with their squares. + +Run: python examples/squaring_bot.py +""" +import re +from simplex_chat import ( + Bot, BotProfile, BotCommand, SqliteDb, TextMessage, Message, ParsedCommand +) + +bot = Bot( + profile=BotProfile(display_name="Squaring bot"), + db=SqliteDb(file_prefix="./squaring_bot"), + welcome="Send me a number, I'll square it.", + commands=[BotCommand(keyword="help", label="Show help")], +) + +NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$") + +@bot.on_message(content_type="text", text=NUMBER_RE) +async def square(msg: TextMessage) -> None: + n = float(msg.text) + await msg.reply(f"{n} * {n} = {n * n}") + +@bot.on_message(content_type="text") # fallback +async def fallback(msg: Message) -> None: + await msg.reply("Send me a number, like 7 or 3.14.") + +@bot.on_command("help") +async def help_cmd(msg: Message, _cmd: ParsedCommand) -> None: + await msg.reply("Send a number, I'll square it.") + +if __name__ == "__main__": + bot.run() +``` + +- [ ] **Step 2: Document in README** + +Replace the placeholder in `packages/simplex-chat-python/README.md` with the example and `pip install simplex-chat` quick-start. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/examples/squaring_bot.py \ + packages/simplex-chat-python/README.md +git commit -m "docs(python): squaring bot example + README quick-start" +``` + +--- + +## Chunk 8: CI publishing + +Append `publish-python` job to existing `.github/workflows/build.yml` after `release-nodejs-libs`. Configure PyPI trusted publisher. + +### Task 8.1: Add `publish-python` job to `build.yml` + +**Files:** +- Modify: `.github/workflows/build.yml` (append new job after `release-nodejs-libs:`) + +- [ ] **Step 1: Append the job** + +After line ~803 (end of `release-nodejs-libs`), add: + +```yaml +# ========================= +# Python package release +# ========================= + +# Publishes simplex-chat to PyPI on release tags. +# Depends on release-nodejs-libs because the Python package downloads +# its libsimplex from the simplex-chat-libs release at runtime, so the +# libs release must exist before the Python package is published. +# +# Trusted publishing is configured on PyPI: no API token, OIDC only. + + publish-python: + runs-on: ubuntu-latest + needs: [release-nodejs-libs] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + id-token: write # OIDC for trusted publishing + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install build + - run: python -m build --wheel + working-directory: packages/simplex-chat-python + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/simplex-chat-python/dist +``` + +- [ ] **Step 2: Validate YAML locally** + +``` +python -c "import yaml; yaml.safe_load(open('.github/workflows/build.yml'))" +``` + +Expected: no exception. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/build.yml +git commit -m "ci: add publish-python job for PyPI release on tag" +``` + +### Task 8.2: One-time PyPI setup (manual, document in README) + +- [ ] **Step 1: Verify package name `simplex-chat` is available on PyPI** + +```bash +curl -s https://pypi.org/pypi/simplex-chat/json | head -c 200 +``` + +If it 404s, the name is free. If it returns metadata, the name is taken — coordinate with the team. + +- [ ] **Step 2: On PyPI, create a pending publisher** + +Navigate to https://pypi.org/manage/account/publishing/ and add: + +| Field | Value | +|---|---| +| PyPI Project Name | simplex-chat | +| Owner | simplex-chat | +| Repository name | simplex-chat | +| Workflow name | build.yml | +| Environment name | (leave blank) | + +- [ ] **Step 3: Add a section to `packages/simplex-chat-python/README.md` documenting the release process** + +Brief checklist for the maintainer: +1. Bump `_version.py` `__version__` (and `LIBS_VERSION` if libs changed). +2. Tag with `vX.Y.Z` matching `__version__`. +3. Push the tag → CI runs the existing build matrix, then `release-nodejs-libs`, then `publish-python`. +4. Verify the wheel appears at https://pypi.org/project/simplex-chat/. + +- [ ] **Step 4: Commit doc update** + +```bash +git add packages/simplex-chat-python/README.md +git commit -m "docs(python): release process + PyPI trusted publisher setup" +``` + +--- + +## Final acceptance + +After all phases: + +- [ ] **Type generation parity.** `cabal test simplex-chat-test` passes for all four `Python` test cases. +- [ ] **Python package builds.** `cd packages/simplex-chat-python && python -m build --wheel` produces a single `.whl` ≤ 200 KB. +- [ ] **All Python tests pass.** `pytest packages/simplex-chat-python/tests` — green on Linux + macOS. +- [ ] **Pyright clean.** `pyright packages/simplex-chat-python/src` — zero errors. +- [ ] **Squaring bot smoke.** Run `python examples/squaring_bot.py` against a fresh database; verify (a) lazy lib download succeeds, (b) `bot.run()` blocks, (c) Ctrl-C exits cleanly. +- [ ] **CI dry run.** Push a `v0.0.0-test` tag to a fork; verify the `publish-python` job runs after `release-nodejs-libs` and the wheel uploads to TestPyPI (configure a separate test publisher if doing this). diff --git a/plans/2026-05-08-desktop-text-selection-id-anchored.md b/plans/2026-05-08-desktop-text-selection-id-anchored.md new file mode 100644 index 0000000000..0d986f62ed --- /dev/null +++ b/plans/2026-05-08-desktop-text-selection-id-anchored.md @@ -0,0 +1,145 @@ +# Desktop Text Selection — Anchor by Item Id + +## 1. The bug + +`SelectionRange` stored two **positional** indices into the reversed merged-items list: + +```kotlin +data class SelectionRange( + val startIndex: Int, + val startOffset: Int, + val endIndex: Int, + val endOffset: Int +) +``` + +`reversedChatItems` grows from the front: a new message is prepended at index 0, every existing item shifts +1. Selection indices were never adjusted, so once the user had a selection on a message and another message arrived (or was sent), the indices kept pointing to the same numerical positions while the items at those positions had changed. The highlight (and the copy result) silently moved onto neighbouring messages. + +Same root cause for the deletion case: removing an item from the list left selection indices pointing into a different item. + +## 2. Root cause + +Selection is **about items**, not positions. Storing positions into a list whose front grows is structurally wrong. The data structure must encode the stable identity (`ChatItem.id`), not the volatile position. + +Two ingredients are mandatory for any correct fix: + +1. **Remember which items** are anchor and focus (their stable `ChatItem.id`s). +2. **Update the positional indices** when the list mutates, so that everything downstream that reads `range.startIndex` / `range.endIndex` (highlight rendering, copy iteration, snap, copy-button placement, anchor/focus detection in `setupItemSelection` / `setupEmojiSelection`, drag direction in `SelectionCopyButton`) stays correct. + +Anything beyond this is structural overreach. + +## 3. Approaches considered + +| # | Approach | Note | +|---|----------|------| +| A | Replace positional indices with ids in `SelectionRange`; cache items on the manager via `mutableStateOf`; expose indices via `derivedStateOf`; rename every reader from `range?.startIndex` to `manager.startIndex`; move top-level `selectedRange` into the manager as a method. | Structurally clean (single source of truth = ids), but renames every reader and moves a function for no behaviour reason. Ripples through `setupItemSelection`, `setupEmojiSelection`, `SelectionCopyButton`, `getSelectedCopiedText`, `snapSelection`, `copyButtonOffset`. | +| B | Same as A but replace the cached `var items` with `var mergedItemsState: State?` (mirrors the existing `listState` field; eliminates duplicated state and the items-sync line in `SideEffect`). | Marginal improvement; the cost is still the renames and the function move, neither of which the bug requires. | +| C | **Final** — keep positional indices in `SelectionRange`, **add** `startItemId, endItemId` alongside them; resync the indices to the items they were anchored to on every recomposition via a `SideEffect`. | Every existing reader of `range.startIndex` / `range.endIndex` keeps working unchanged. The fix is a pure addition. | + +Approach C accepts one piece of structural duplication that A and B do not have: anchor ids and positional indices coexist in `SelectionRange`, kept consistent by `resyncIndices`. For a bug-fix change, the trade-off favours diff minimality — migrating to a single source of truth (ids only, indices derived) is a separate refactor that should not be bundled with a fix. + +## 4. Final implementation + +### 4.1 `SelectionRange` — two new fields + +```kotlin +data class SelectionRange( + val startIndex: Int, + val startItemId: Long, // NEW — stable anchor for the selection start + val startOffset: Int, + val endIndex: Int, + val endItemId: Long, // NEW — stable anchor for the selection focus + val endOffset: Int, +) +``` + +Existing `r.copy(startOffset = …)`, `r.copy(endOffset = …)`, `r.copy(startOffset = …, endOffset = …)` calls in `setAnchorOffset` / `updateFocusOffset` / `snapSelection` automatically preserve the new fields (data-class `copy` semantics). No change to those methods. + +### 4.2 `SelectionManager` — one new field, two body additions, one new method + +```kotlin +var mergedItemsState: State? = null // mirrors existing listState +``` + +`startSelection` looks up the id once at click time: + +```kotlin +fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) { + val id = mergedItemsState?.value?.items?.getOrNull(startIndex)?.newest()?.item?.id ?: return + range = SelectionRange(startIndex, id, -1, startIndex, id, -1) + selectionState = SelectionState.Selecting + anchorWindowY = anchorY + anchorWindowX = anchorX +} +``` + +`updateFocusIndex` updates `endItemId` whenever it updates `endIndex` (called both from `updateDragFocus` and from the scroll snapshotFlow — both paths covered by this single method): + +```kotlin +fun updateFocusIndex(index: Int) { + val r = range ?: return + val id = mergedItemsState?.value?.items?.getOrNull(index)?.newest()?.item?.id ?: return + range = r.copy(endIndex = index, endItemId = id) +} +``` + +New method: + +```kotlin +fun resyncIndices() { + val r = range ?: return + val items = mergedItemsState?.value?.items ?: return + val newStartIndex = items.indexOfFirst { it.newest().item.id == r.startItemId } + val newEndIndex = items.indexOfFirst { it.newest().item.id == r.endItemId } + if (newStartIndex < 0 || newEndIndex < 0) clearSelection() + else range = r.copy(startIndex = newStartIndex, endIndex = newEndIndex) +} +``` + +### 4.3 `SelectionHandler` — three new lines + +```kotlin +manager.listState = listState +manager.mergedItemsState = mergedItems // NEW — wires items into the manager +manager.onCopySelection = { … } + +// Resync after the items list mutates (new message arrives, item deleted). +SideEffect { manager.resyncIndices() } // NEW — the trigger +``` + +### 4.4 What is *not* changed + +- `selectedRange(range, index)` — still a top-level function with its existing signature. +- `getSelectedCopiedText(items, revealedItems, linkMode)` — same signature, same body. +- `snapSelection(items, linkMode)` — same signature, same body. +- `copyButtonOffset(...)` — uses `r.endIndex` directly; no change. +- `setupItemSelection`, `setupEmojiSelection`, `SelectionCopyButton` — every `range?.startIndex` / `range?.endIndex` reference is preserved verbatim. +- `startDragSelection`, `updateDragFocus`, `startSelection` (signature), `updateFocusIndex` (signature) — unchanged. `mergedItemsState` is reached via the manager's own field, so callers don't thread items. + +This is the structural property that compresses the diff: callers see no API change, and the file's structure (top-level `selectedRange`, top-level `selectedItemCopiedText`, top-level `snapOffset`, top-level extension helpers) is untouched. + +## 5. Why this works in Compose + +`SideEffect { manager.resyncIndices() }` runs after every successful composition of `SelectionHandler`. `SelectionHandler` returns a `Modifier` (non-Unit return → non-skippable), so it re-runs whenever its caller (`ChatView`) re-runs, which `ChatView` does whenever `mergedItems.value` changes (it iterates the items list directly). Within the same Compose frame, the `SideEffect` mutation of `range` invalidates the children that read `range`, and Compose re-runs them to convergence before commit. Net visible result: the selection highlight stays on the originally selected items on the same frame the new message arrives — same fidelity as a `derivedStateOf`-based approach, no observable lag. + +`mergedItemsState` is a plain `var` (not `mutableStateOf`) — this is fine because (a) it is reassigned on every recomposition of `SelectionHandler` to the same `State` reference, and (b) the values inside it are read through `State.value`, which Compose tracks. The pattern is identical to the existing `var listState: State? = null` field on the manager. + +## 6. Behaviour changes — full inventory + +1. **Selection follows the original messages when the items list mutates.** This is the bug fix. +2. **Selection clears if either anchor item is removed from the list** (e.g. message deleted from another session). Previously, indices silently slid onto neighbouring messages. The new behaviour is `clearSelection()` when `indexOfFirst` returns -1. This is a side-effect of anchoring by id — once the anchor is gone, "the selection" is no longer well-defined. It is the same class of bug as #6.1 and is fixed by the same mechanism. +3. **Defensive `?: return` in `startSelection` and `updateFocusIndex`** when the id lookup fails. In practice this branch is unreachable: `mergedItemsState` is wired before any user input; the index passed in always comes from `resolveIndexAtY` (which only returns visible-item indices); `newest().item` is non-null for any merged item. No observable change, but worth flagging for completeness. + +Nothing else changes. Verified by reading the diff against master line-by-line. + +## 7. Verification + +1. **Linux desktop build** succeeded end-to-end, producing `SimpleX_Chat-x86_64.AppImage`. No compilation errors, no Compose runtime issues from the new field on the manager or the new fields on `SelectionRange`. +2. **Manual flow against the test plan**: selection persists across `new-message-arrives`, `new-message-sent`, multi-item span; deletion clears (see §6.2); drag-select & copy button behaviour preserved. + +## 8. Trade-offs and follow-ups + +The two pieces of structural debt this change knowingly leaves in place: + +1. **Anchor ids and positional indices coexist in `SelectionRange`.** Single source of truth would store only ids and derive indices on read. The cost of unifying is the rename and function-move churn, which is independent of this bug. A follow-up could collapse these into ids-only without behaviour change, scoped to its own commit. +2. **`resyncIndices` runs on every recomposition of `SelectionHandler`.** The two `indexOfFirst` calls are O(n) on the items list. If profiling ever shows this on a hot path, the cheap fix is to gate on the pointer identity of the items list (`if (lastResyncedItems !== items) { … }`) — one extra field, one branch. Not worth doing speculatively. diff --git a/plans/2026-05-08-fix-select-in-reports.md b/plans/2026-05-08-fix-select-in-reports.md new file mode 100644 index 0000000000..a1731c34d8 --- /dev/null +++ b/plans/2026-05-08-fix-select-in-reports.md @@ -0,0 +1,182 @@ +# Fix copying selected text in reports + +PR: [#6863](https://github.com/simplex-chat/simplex-chat/pull/6863) · branch `nd/fix-select-in-reports` · final commit `96d6f3222` + +## 1. Problem statement + +Report items in desktop render as a red italic *reason prefix* followed by the user's comment, e.g. `Spam: hi @alice`. The user reported that selecting `Spam: test` and pressing Ctrl-C / clicking the copy button placed only `test` on the clipboard — the `Spam: ` prefix was silently dropped. Selecting *only* the prefix produced an empty clipboard. + +A second symptom existed for any report whose comment contained a transformed segment (mention with `localAlias`, link with `showText`): dragging a selection boundary inside that segment snapped to the wrong character on release, then copy emitted the wrong text. + +Both symptoms have a single cause and the bug is desktop-only because the touch UI does not use this selection path. + +## 2. Solution summary + +`MarkdownText` builds the on-screen `AnnotatedString` as `[prefix][body]` (one composable, one layout). Compose's `layout.getOffsetForPosition(...)` therefore returns selection offsets in **display-text space**, which includes the prefix. Pre-PR, `selectedItemCopiedText` and `snapOffset` walked `ci.formattedText` from `displayOffset = 0` — i.e. they treated those offsets as **prefix-excluded body offsets**. Every offset for a report was off by `prefix.length`. + +The fix is one structural realisation: the prefix is the **leading display-space segment**, so the loop that walks `ci.formattedText` must start at `displayOffset = prefix.length`, and any portion of the selection that falls in `[0, prefix.length)` must be emitted by appending a slice of the prefix string before the loop runs. + +To prevent the same silent decoupling from re-emerging, the prefix string itself is extracted into a single source of truth — `itemPrefixText(ci)` — used by every call site that either renders or measures the prefix. + +## 3. Detailed tech design + +### 3.1 Where the offsets come from + +``` +Compose layout + └─> SelectableText / ClickableText (TextItemView.kt) + └─> getOffsetForPosition(localPos) // returns display-space offset + └─> SelectionManager.setAnchorOffset / updateFocusOffset + └─> selectedRange(...) → IntRange in display space + └─> selectedItemCopiedText(ci, sel, linkMode) // FIX site #1 + └─> snapOffset(ci, off, linkMode, expandRight) // FIX site #2 +``` + +`MarkdownText` (TextItemView.kt) builds the `AnnotatedString` in this order: + +``` +inlineContent — never present for report items +appendSender(...) — null for the CIMarkdownText path +prefix (AnnotatedString) — "${reason}: " for reports, null otherwise +text / formatted segments — the body +typingIndicator (live only) — past selectableEnd +reserve (timestamp space) — past selectableEnd +``` + +For non-report items the prefix is null and the existing identity `displayOffset = 0` holds. For reports, the body's first character lives at display offset `prefix.length`. + +### 3.2 The minimal structural change + +Pre-PR loop: + +```kotlin +var displayOffset = 0 +for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + val overlapStart = maxOf(displayOffset, sel.first) + val overlapEnd = minOf(displayEnd, sel.last + 1) + if (overlapStart < overlapEnd) { /* emit */ } + displayOffset = displayEnd +} +``` + +Two changes only: + +1. **Seed with prefix length.** `var displayOffset = prefix.length` (or `itemPrefixText(ci).length` for `snapOffset`). Loop body is otherwise byte-for-byte identical to pre-PR. For non-reports `prefix.length == 0`, so the non-report path is unchanged. + +2. **Emit the prefix slice.** Before the loop, append the portion of `prefix` covered by the selection: + ```kotlin + if (sel.first < prefix.length) { + sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1)) + } + ``` + `selectedRange()` guarantees `sel.first ≥ 0`, so no clamping is needed at this site. + +3. **Handle the `formattedText == null` branch.** Reports with empty body have null `formattedText`, but the prefix selection still has to be returned. The early-return in the pre-PR null branch is replaced by the same `StringBuilder` path so prefix-only selections work: + ```kotlin + val formattedText = ci.formattedText ?: run { + val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length) + val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length) + if (start < end) sb.append(ci.text, start, end) + return sb.toString() + } + ``` + `coerceAtLeast(0)` on `start` is required here because `sel.first - prefix.length` is negative when the selection lies entirely inside the prefix. + +### 3.3 Single source of truth + +Pre-PR, the prefix expression `if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: "` lived inline at two render sites: + +- `FramedItemView.kt:368` — the actual report row +- `ChatPreviewView.kt:262` — the chat list preview + +Re-introducing it inline in `TextSelection.kt` would have re-created exactly the silent coupling that produced the bug — a future change to the separator (e.g. localised colon) at the renderer would silently break copy/snap. The fix factors the expression into: + +```kotlin +// TextItemView.kt +fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) { + is MsgContent.MCReport -> if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: " + else -> "" +} +``` + +Both renderers and both selection-side functions now derive the string from this one definition. + +### 3.4 Edge cases verified + +| Case | Pre-PR | Post-PR | +|---|---|---| +| Non-report, fmt non-null (markdown) | works | byte-identical loop, works | +| Non-report, fmt null (plain text) | substring fast path | StringBuilder path, value-equivalent | +| Non-report, sel out of bounds | clamped to `[L, L]` → `""` | same | +| Report, full sel `Spam: test` | returns `test` (BUG) | returns `Spam: test` | +| Report, prefix-only sel | returns `""` (BUG) | returns prefix slice | +| Report, body-only sel | returns body (offset shift was hidden by `Int.MAX_VALUE` clamp at `sel.last+1`) | returns body | +| Report, sel.first == prefix.length | works coincidentally | works | +| Report, empty body, prefix-only sel | returns `""` | returns prefix | +| Report with mention having `localAlias` (transformed) | snap snapped to wrong char (BUG) | snaps correctly | +| Multi-item interior sel (`sel.last = MAX-1`) | works | no overflow on `+1 - prefix.length` | + +### 3.5 What was deliberately not done + +- **Performance restoration of the non-report null-fmt path.** Pre-PR returned `ci.text.substring(...)` directly (1 allocation). Post-PR uses `StringBuilder` (3 allocations). `selectedItemCopiedText` runs once per selected item per copy action — never on a hot path. Restoring the pre-PR fast path with an `if (prefix.isEmpty() && formattedText == null)` early return adds 4 lines of branching for negligible gain. Not worth it. + +- **Migrating `ChatPreviewView.kt` was kept** because it crossed the 3-site extraction threshold (FramedItemView + ChatPreviewView + 2× TextSelection) and the bug we are fixing is exactly the failure mode of duplicating this expression. ChatPreviewView is not a selection site, so no behaviour change — it shifts to the same single source of truth. + +## 4. Detailed implementation plan + +### 4.1 Files touched (final state) + +| File | Δ | Purpose | +|---|---|---| +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt` | +7 / 0 | new `itemPrefixText(ci)` helper next to `itemDisplayText` | +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt` | +1 / −1 | report branch delegates to `itemPrefixText(ci)` | +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt` | +2 / −2 | preview row delegates; drops unused `val mc =` | +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt` | +16 / −8 | the actual fix in `selectedItemCopiedText` and `snapOffset`, plus import | + +Total: 4 files, +26 / −11. + +### 4.2 Step-by-step (final commit `96d6f3222`) + +1. **Add `itemPrefixText(ci)`** in `TextItemView.kt` next to `itemDisplayText` / `itemSegmentDisplayText`. Returns `""` for non-reports. + +2. **`FramedItemView.kt:365-372`** (`MCReport` branch): replace inline expression with `append(itemPrefixText(ci))`. The surrounding `withStyle(SpanStyle(color = Red, italic))` is preserved — visual rendering unchanged. + +3. **`ChatPreviewView.kt:258-264`**: replace inline expression with `append(itemPrefixText(ci))`. Drop the now-unused `val mc =` from `when (val mc = ci.content.msgContent)` (the discriminator becomes `when (ci.content.msgContent)`). + +4. **`TextSelection.kt`**: + - Add `import chat.simplex.common.views.chat.item.itemPrefixText`. + - In `selectedItemCopiedText`: + - Compute `val prefix = itemPrefixText(ci)` and `val sb = StringBuilder()` first. + - Emit prefix slice if `sel.first < prefix.length`. + - Modify the `formattedText ?: ...` early-return to a `?: run { … }` block that adds the body slice (offsets shifted by `-prefix.length`, clamped) to `sb` and returns `sb.toString()`. + - Seed the formattedText loop with `var displayOffset = prefix.length`. Loop body unchanged. + - In `snapOffset`: change `var displayOffset = 0` to `var displayOffset = itemPrefixText(ci).length`. Loop body unchanged. + - Update the docstring on `selectedItemCopiedText` to note that display-text space includes any leading `itemPrefixText`. + +### 4.3 Verification + +- `./gradlew :common:compileKotlinDesktop` — passes (warnings are pre-existing). +- `bash /home/user/build/linux.sh` — full Linux x86_64 AppImage produced (`SimpleX_Chat-x86_64-fix-select-in-reports.AppImage`). +- Manual test plan, all in desktop: + 1. Open a chat with a report whose rendered form is `Spam: test`. Select across the whole line + Ctrl-C → clipboard reads `Spam: test`. + 2. Select only the red prefix → clipboard reads the prefix. + 3. Select only the comment → clipboard reads the comment. + 4. Report comment containing `@alice (Bob)` (mention with localAlias). Drag a selection boundary into the mention → on release, highlight snaps to mention boundaries. + 5. Plain (non-report) messages: full-line, partial, mention, link selections — clipboard contents unchanged from pre-PR. + 6. Multi-item selection across non-report and report rows — prefixes appear inline at the correct positions. + +### 4.4 Risk and rollback + +- **Blast radius** is the desktop selection-copy code path. iOS / Android use separate selection mechanisms and are unaffected. +- The non-report selection path's inner loop body is byte-for-byte identical to pre-PR (the `displayOffset = 0` initialisation is unchanged when `prefix.length == 0`), so regressions on non-reports would require the prefix expression itself to fail — which is impossible because `itemPrefixText` returns `""` for any `msgContent` other than `MCReport`. +- Rollback is `git revert 96d6f3222 e97dd7bf4 6aacfa4d2` (three commits) and a force-push, restoring the pre-PR copy behaviour with the original bug. + +## 5. Why this specific shape + +- Recognising the prefix as the *first* display-space segment turns the bug into a one-line seed change. No special-cased report branch in copy/snap; the existing loop handles both. +- The inner loop of `selectedItemCopiedText` and `snapOffset` is byte-for-byte identical to pre-PR. Only the seed value of `displayOffset` and the pre-/post-amble change. +- Four sites need the prefix string (FramedItemView, ChatPreviewView, and two in TextSelection). `itemPrefixText` becomes their single point of change, closing the silent-coupling gap that produced the bug. +- `selectedRange()` guarantees `sel.first ≥ 0`, so no `coerceAtLeast(0)` is added at the prefix-slice append. The one `coerceAtLeast(0)` that survives (on the `formattedText == null` body branch) is reachable when the selection lies entirely inside the prefix and is needed. +- Final PR is 4 files, +26 / −11. The inner loop body changes by zero lines. diff --git a/plans/2026-05-08-relay-announce-impl.md b/plans/2026-05-08-relay-announce-impl.md new file mode 100644 index 0000000000..f617c78a31 --- /dev/null +++ b/plans/2026-05-08-relay-announce-impl.md @@ -0,0 +1,695 @@ +# Implementation plan: owner-pushed relay announcement (`XGrpRelayNew`) + +Companion to `/workspace/plans/2026-05-08-relay-announce.md` (overview). This file is the +file-and-symbol-level diff guide. Read the overview first. + +All file/line references are against the working tree at the start of the implementation; +they will drift slightly as edits land. Cite this plan when something looks unfamiliar. + +--- + +## 1. Step ordering and commit shape + +Compilation must hold after every step. The order below is the smallest reviewable +sequence; steps S1–S5 are intentionally split into two PRs: a wire-format-only PR and a +behaviour PR, so reviewers can evaluate the new event in isolation. + +PR 1 — wire format (compiles, no behaviour change) + +- S1 `Protocol.hs`: add `XGrpRelayNew` constructor, tag `x.grp.relay.new`, + `toCMEventTag`, JSON encode/parse, `isForwardedGroupMsg` row. +- S2 `Protocol.hs`: extend `requiresSignature` to include `XGrpRelayNew_`. +- S3 `docs/protocol/channels-protocol.md`: signing-table row + new "Relay addition" + subsection. + +PR 2 — receive + send + forward (one logical change) + +- S4 `Store/Groups.hs`: add active-status filter in place to the inner + `getGroupMemberByRelayLink` lookup inside `getCreateRelayForMember`. +- S5 `Library/Internal.hs`: introduce `connectToRelayAsync`. Move `syncSubscriberRelays` + from `Commands.hs` to `Internal.hs` and pivot its add-half to `connectToRelayAsync`. +- S6 `Library/Commands.hs`: drop the now-unused sync `connectToRelay`; `APIConnectPreparedGroup` + keeps the existing sync call (see §6 — left in place); update import of + `syncSubscriberRelays`. Keep `retryRelayConnectionAsync` as-is. +- S7 `Library/Subscriber.hs`: add forward-only case to `processEvent`, add + `XGrpRelayNew` case to `processForwardedMsg`, add owner send at end of LINK callback. +- S8 Tests in `tests/ChatTests/Channels.hs` (or split across files per §11). + +S1–S3 land in PR 1; S4–S8 in PR 2. PR 2 must not be split: the owner-side send and the +subscriber-side handler must ship together to avoid asymmetry where one direction is +emitted but not consumed. + +--- + +## 2. `Protocol.hs` — wire format (S1, S2) + +### 2.1 GADT constructor (Protocol.hs:443-445) + +Add at line 446 (immediately after `XGrpRelayTest`, before `XGrpMemNew`): + +``` +XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json +``` + +Rationale: keeps the relay-related events grouped. Single `ShortLinkContact` field, no +record syntax, mirrors `XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json` at +Protocol.hs:444. **Do not** introduce a record wrapper or `RelayInfo` envelope — the +overview locked the shape to a single field; the receiver looks the link up locally. + +### 2.2 Tag GADT and string encoding (Protocol.hs:986-988, 1043-1045, 1101-1103) + +- Insert `XGrpRelayNew_ :: CMEventTag 'Json` after `XGrpRelayTest_` (line 988). +- In `strEncode`, add `XGrpRelayNew_ -> "x.grp.relay.new"` after `XGrpRelayTest_` (line 1045). +- In `strDecode` map (line 1103), add `"x.grp.relay.new" -> XGrpRelayNew_` after + `"x.grp.relay.test" -> XGrpRelayTest_`. + +The `_` -> `XUnknown_` fallback at line 1129 already gives correct old-client behaviour; +no change there. + +### 2.3 `toCMEventTag` (Protocol.hs:1133-1184) + +Add `XGrpRelayNew _ -> XGrpRelayNew_` after the `XGrpRelayTest` line (1157). + +### 2.4 JSON parse / encode (Protocol.hs:1308-1314, 1378-1382) + +- `appJsonToCM`/`msg` parser (1271-1344): add + `XGrpRelayNew_ -> XGrpRelayNew <$> p "relayLink"` + immediately after `XGrpRelayAcpt_` (line 1309). Field name `"relayLink"` matches the + `XGrpRelayAcpt` precedent (1309) — do not invent a new key. +- `chatToAppMessage`/`params` encoder (1354-1410): add + `XGrpRelayNew relayLink -> o ["relayLink" .= relayLink]` + after the `XGrpRelayAcpt` clause (1379). Same key. + +### 2.5 `isForwardedGroupMsg` (Protocol.hs:484-503) + +Add a single case `XGrpRelayNew _ -> True` in the listed group of `True` cases (e.g. +between `XGrpMemNew {} -> True` (495) and `XGrpMemRole {} -> True` (496)). Rationale: +relays must forward this event to subscribers; it is the entire point. The comment +above the function (line 482) already says actual filtering happens in `processEvent`; +the listing here is for the send-side `memberSendAction` decisions about pre-member +forwarding (Internal.hs:2202), which we want to behave the same as `XGrpMemNew`. + +### 2.6 `requiresSignature` (Protocol.hs:1221-1231) + +Add `XGrpRelayNew_ -> True` to the list. Rationale: this is an administrative event; +must reuse the existing required-signature gate. Without this, `withVerifiedMsg` +(Subscriber.hs:3385-3407) would treat a missing signature as acceptable +(`signatureOptional` becomes `True`), breaking the threat model from +`channels-protocol.md` §"Message signing". + +### 2.7 What NOT to change + +- Do not touch `hasNotification` or `hasDeliveryReceipt` — relay-add is administrative, + not a notification surface for the user. The relay's delivery pipeline (delivery_task / + delivery_job) already handles forwarding without an entry in either table. +- Do not touch `unverifiedAllowed` (Protocol.hs:1240-1249). Owners always know their own + key; subscribers always have the owner key from link data. The "no key" branch is for + member-to-member events, not for owner-signed administrative events. + +--- + +## 3. `Store/Groups.hs` — active-status filter on relay-link lookup (S4) + +### 3.1 The current shape (Store/Groups.hs:1376-1407) + +`getCreateRelayForMember` runs `getGroupMemberByRelayLink` (an inner `let` at 1380-1385), +falls back to `createRelayMember`. The inner SQL filters on `group_id = ? AND relay_link += ?` only — no status filter. The schema permits multiple rows with the same +`(group_id, relay_link)` over time: when a relay is removed by the owner, its row is +preserved with `GSMemLeft` (this drives the "removed by operator" UI on the subscriber +side). For the existing subscriber-join flow (`APIConnectPreparedGroup → connectToRelay`, +Commands.hs:2141 / 3597-3613) the unfiltered lookup happens to work because rows in that +path are recent and active. For the new subscriber receive path we must filter to *active* +rows so that a re-add after a `GSMemLeft` creates a fresh row instead of resurrecting the +historical one. + +### 3.2 The change + +Add an active-status filter in place to the existing inner `let`. No extraction, no new +top-level function: + +``` +getGroupMemberByRelayLink = + maybeFirstRow (toContactMember vr user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.relay_link = ? AND m.member_status IN (?,?,?,?,?,?,?)") + ( (groupId, relayLink) + :. (GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced) + :. (GSMemConnected, GSMemComplete, GSMemCreator) + ) +``` + +The seven statuses are the `memberCurrent'`-true set from Types.hs:1318-1334: +`GSMemIntroduced`, `GSMemIntroInvited`, `GSMemAccepted`, `GSMemAnnounced`, +`GSMemConnected`, `GSMemComplete`, `GSMemCreator`. Tuple shape is illustrative — match the +existing `:.` chaining convention used elsewhere in the module. + +Justification for SQL-level filter (vs. Haskell post-filter): `maybeFirstRow` returns +whatever row the engine yields first. With `GSMemLeft` history rows preserved alongside +active rows, an unfiltered query is non-deterministic without `ORDER BY`. Filtering in +SQL eliminates the ambiguity at the query level. The list of statuses is tiny and +stable. + +### 3.3 Existing call site unaffected + +`getCreateRelayForMember`'s lone existing caller is `connectToRelay` (Commands.hs:3597-3613), +invoked from `APIConnectPreparedGroup` (Commands.hs:2141). Rows it creates are inserted +with `GSMemAccepted` (line 1403), which is `memberCurrent`. The filtered lookup still +finds them on retry, so the subscriber-join flow's reuse-on-retry behaviour is preserved. +No signature or call-site change is needed in `Commands.hs`. + +### 3.4 What NOT to change + +- Do not extract `getGroupMemberByRelayLink` to a top-level function. The + filter-in-place shape is the minimal diff; both call sites (existing + `APIConnectPreparedGroup → connectToRelay` and new `connectToRelayAsync`) share one + definition by going through `getCreateRelayForMember`. +- Do not modify `getGroupMember`, `getGroupMembers`, or other lookups. The change is + scoped to the relay-link lookup inside `getCreateRelayForMember`. +- Do not delete the historical `GSMemLeft` row when re-adding a relay. The + delete-or-update logic in `syncSubscriberRelays` removes only when the link is no + longer in the channel's link data (Commands.hs:3623-3633); on re-add it remains in + link data, so the historical row stays untouched and is filtered out by the new + lookup. + +--- + +## 4. `Library/Internal.hs` — `connectToRelayAsync` and moved `syncSubscriberRelays` (S5) + +### 4.1 New helper + +Place near the existing relay/group plumbing (e.g. after `setGroupLinkDataAsync` at +Internal.hs:1316-1322) so that all relay-link async helpers cluster together. + +``` +connectToRelayAsync :: User -> GroupInfo -> ShortLinkContact -> CM () +``` + +Body — described, not coded: + +1. `vr <- chatVersionRange`. +2. `gVar <- asks random` — needed by `getCreateRelayForMember` via the create branch. +3. `relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink`. + With the active-status filter from §3.2, this atomically returns the existing active + row (if any) or creates a fresh `GSMemAccepted` row. `GSMemLeft` history rows are + invisible to the lookup, so re-add after removal creates a new row beside the + historical one. +4. Idempotence check on `activeConn relayMember`: + + - `Just _` → `pure ()` (skip; an earlier path already bound a connection on this + row. The agent layer handles transient failures internally; permanent-failure + recovery is deferred to explicit retry paths and channel re-join.) + - `Nothing` → either a freshly created row or a leftover row from an attempt that + never bound a connection; proceed to step 5. +5. `subMode <- chatReadVar subscriptionMode`. +6. `newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink` + (Commands.hs:2479 — already returns `(CommandId, ConnId)` for binding). +7. `withFastStore' $ \db -> createRelayMemberConnectionAsync db user gInfo relayMember relayLink newConnIds subMode` + (Direct.hs:225-244). +8. Return. Continuation is the existing `CFGetRelayDataJoin` LDATA callback at + Subscriber.hs:1131-1160 — unchanged. + +Store-call conventions: `getCreateRelayForMember` is `ExceptT StoreError IO`, so use +`withFastStore`. `createRelayMemberConnectionAsync` is `IO`, so `withFastStore'`. Both +match what `retryRelayConnectionAsync` (Commands.hs:2168-2174) and `connectToRelay` +(Commands.hs:3597-3613) already use. + +### 4.2 Locking argument + +`connectToRelayAsync` is called from two sites (after this PR): +- The forwarded `XGrpRelayNew` handler in `processForwardedMsg`. The entire receive path + is wrapped in `withEntityLock "processAgentMessage" lockEntity` (Subscriber.hs:117) and + the lock entity for any group connection is `CLGroup groupId` (Connections.hs:51-72). +- `syncSubscriberRelays`, called from `APIGetUpdatedGroupLinkData` inside + `withGroupLock "syncSubscriberRelays" groupId` (Commands.hs:1787) — also `CLGroup groupId`. + +Both paths therefore hold the same lock for the same group. The `getCreateRelayForMember` +call (lookup-or-create, atomic within its own transaction) and the `activeConn` check on +its result are performed under that lock, and any subsequent agent commands +(`getAgentConnShortLinkAsync`, `createRelayMemberConnectionAsync`) only persist state +that will be observed under the same lock by the next event's check. No additional lock +is needed. No `justCreated` flag, no per-link mutex. + +### 4.3 Move `syncSubscriberRelays` from `Commands.hs:3614-3633` to `Internal.hs` + +Place right below `connectToRelayAsync`. Body changes: + +- Replace the single `connectToRelay` call inside the `forM_ newRelayLinks` loop + (Commands.hs:3621-3622) with `connectToRelayAsync user gInfo rlnk`. Keep the + per-relay `void . tryAllErrors` wrapping verbatim — equivalent to the existing + pattern at Commands.hs:3621-3622 with only the connect helper substituted: + + ``` + forM_ newRelayLinks $ \rlnk -> void . tryAllErrors $ + connectToRelayAsync user gInfo rlnk + ``` + + `connectToRelayAsync` can fail at three local operations + (`getCreateRelayForMember` → store error if creating; `getAgentConnShortLinkAsync` + → agent error; `createRelayMemberConnectionAsync` → store error). Per-relay error + isolation costs nothing and ensures a failure on relay R1 does not short-circuit + attempts for R2, R3 in the same batch. The outer `void . tryAllErrors` (3615) is + preserved as well; it remains the catch-all for the whole sync operation. +- Remove half: keep verbatim — `deleteMemberConnection`, `deleteOrUpdateMemberRecord` + calls (3631-3632), the `null activeRelayMembers` guard (3629), and the + `localRelayMembers` filter (3617). + +Type signature after move (matches current except for module location): + +``` +syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () +``` + +### 4.4 Imports / exports + +- `Internal.hs` likely already imports the relevant `Store.Groups`/`Store.Direct` + symbols; if `getCreateRelayForMember` or `createRelayMemberConnectionAsync` are not + imported, add them. +- Export `connectToRelayAsync` and `syncSubscriberRelays` from `Internal.hs` (it is a + module without an explicit export list — see "module Simplex.Chat.Library.Internal where" + near top — so any new top-level binding is automatically exported). + +### 4.5 What NOT to change + +- Do not change `connectToRelay` (sync, Commands.hs:3597-3613) signature. PR 2 keeps it + alive for the subscriber's initial channel-join — see §5.1. +- Do not touch `retryRelayConnectionAsync` (Commands.hs:2168-2174). Its retry semantics + are tied to the subscriber's initial channel-join (`APIConnectPreparedGroup`, + Commands.hs:2108-2161) and remain on that path. +- Do not introduce any new `withGroupLock` inside `connectToRelayAsync`. The caller's + lock is sufficient (see §4.2). + +--- + +## 5. `Library/Commands.hs` — drop unused sync helper, fix imports (S6) + +### 5.1 Decide what to delete + +Audit `connectToRelay` callers: only `APIConnectPreparedGroup` (Commands.hs:2108-2161) +uses it. That command is the **subscriber's** initial channel-join entry point +(not owner channel creation — owner-side relay invitation flows through +`APIAddGroupRelays` and `x.grp.relay.inv`/`x.grp.relay.acpt`, see channels-protocol.md +§"Relay acceptance"). At join time, the subscriber does +`mapConcurrently (connectToRelay user gInfo') relays` (Commands.hs:2141) to connect to +all relays in parallel during the join handshake. + +The sync flow is intentional there: +- the user is on a "joining channel" spinner; +- failures must surface immediately to UI so the user sees a meaningful error + instead of a stuck spinner; +- the existing flow already chains async retry via `retryRelayConnectionAsync` + (Commands.hs:2159) for the relays that fail with temporary errors — sync handles + the immediate-feedback path, async handles tail recovery. + +**Default; reviewer to confirm**: keep `connectToRelay` for the +`APIConnectPreparedGroup` path. The overview's "deletable once event-driven path is +wired" was conditional ("once no caller remains"). Subscriber join has different UX +semantics from event-driven relay sync; convergence onto async-only is a separate +concern and is out of scope for this PR. + +### 5.2 Move `syncSubscriberRelays` reference + +`APIGetUpdatedGroupLinkData` at Commands.hs:1787-1788 currently references +`syncSubscriberRelays` as a local where-binding inside `processChatCommand` (it is the +inner `where`-defined function at 3614). After moving it to `Internal.hs`, the call site +at 1788 unchanged but the local binding at 3614-3633 deleted. Imports auto-rerouted via +`Simplex.Chat.Library.Internal` (already imported at the top of Commands.hs). + +### 5.3 What NOT to change + +- Do not change `APIGetUpdatedGroupLinkData`'s `withGroupLock` wrapper or the `gInfo'` + it passes to the sync function. The lock and the link-data refresh are still required. +- Do not change `retryRelayConnectionAsync`. It is the right primitive for the + subscriber-join retry use case (`APIConnectPreparedGroup` tail recovery, + Commands.hs:2159); the new event-driven path is independent. + +--- + +## 6. `Library/Subscriber.hs` — owner send, relay forward, subscriber receive (S7) + +### 6.1 Owner — send site in LINK callback (Subscriber.hs:1300-1333) + +The relevant block: + +``` +LINK _link auData -> + withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> + case cmdFunction of + CFSetShortLink -> + case (ucGroupId_, auData) of + (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do + (gInfo, gLink, relays, relaysChanged) <- withStore $ \db -> do + gInfo <- getGroupInfo db vr user groupId + gLink <- getGroupLink db user gInfo + relays <- liftIO $ getGroupRelays db gInfo + (relays', changed) <- liftIO $ foldrM (updateRelay db) ([], False) relays + liftIO $ setGroupInProgressDone db gInfo + pure (gInfo, gLink, relays', changed) + toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged + where + updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed) = + case relayLink of + Just rLink + | rLink `elem` relayLinks && relayStatus == RSAccepted -> do + relay' <- updateRelayStatus db relay RSActive + pure (relay' : acc, True) + ... +``` + +Plan: + +1. Extend the `updateRelay` accumulator from `([GroupRelay], Bool)` to + `([GroupRelay], Bool, [ShortLinkContact])`: keep the existing `Bool` for the + `CEvtGroupLinkDataUpdated`'s `relaysChanged` flag, and add a new + `[ShortLinkContact]` collecting the links of relays that just transitioned + `RSAccepted → RSActive`. In the `RSAccepted → RSActive` branch, replace + `pure (relay' : acc, True)` with `pure (relay' : acc, True, rLink : newlyActiveLinks)`. + In the `RSActive → RSInactive` branch (which also sets `changed = True` today, + line 1330), keep the `Bool` flip but pass `newlyActiveLinks` through unchanged + — removals are explicitly out of scope for the announce push (overview + §"Owner — send site"). Other branches pass both extra fields through unchanged. +2. Bind `(gInfo, gLink, relays, relaysChanged, newlyActiveLinks)` from the `withStore` + block; pass `relaysChanged` to the existing `CEvtGroupLinkDataUpdated` `toView` + call so its semantics are preserved exactly; pass `newlyActiveLinks` to the new + send block in step 3. + +3. After the `toView`, add (still inside `(Just groupId, UserContactLinkData ...)` + case). The send block fetches all relay members and filters inline (see §6.2): + + ``` + let newlyActiveLinks = ... -- collected from the fold accumulator + forM_ (L.nonEmpty newlyActiveLinks) $ \newlyActive -> do + allRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + let recipients = filter + (\m -> memberStatus m == GSMemConnected && relayLink m `notElem` newlyActiveLinks) + allRelayMembers + events = XGrpRelayNew <$> newlyActive + unless (null recipients) $ + void $ sendGroupMessages user gInfo Nothing False recipients events + ``` + + - `sendGroupMessages` signature (Internal.hs:2049): `User -> GroupInfo -> Maybe + GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)`. + - `Nothing` for `Maybe GroupChatScope`: this is administrative, not scoped to a + support side-channel. Justified by `XGrpInfo` / `XGrpPrefs` send patterns elsewhere + where signed admin events use `Nothing`. + - `False` for `ShowGroupAsSender`: this is signed by the owner; relays must verify + the owner signature via `withVerifiedMsg` (Subscriber.hs:3385). `asGroup = True` + uses `CBChannel` binding (channels-protocol.md §"Channel-as-sender"), which has no + member ID and is not what we want — verification needs the owner's member ID. + - `void` discards the per-member result; logging is handled by the existing send + pipeline. + +### 6.2 Recipients query + +No new Store helper. Inline the filter in the LINK callback: + +- After the `withStore` block that runs the fold, call + `withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo` to get + `[GroupMember]` (Store/Groups.hs:1185-1191). +- Filter in Haskell: + `filter (\m -> memberStatus m == GSMemConnected && relayLink m \`notElem\` newlyActiveLinks)`. +- `memberStatus == GSMemConnected` already implies `memberCurrent` (Types.hs:1318-1334); + do not add a redundant `memberCurrent` check. +- Pass the filtered list as the recipients argument to `sendGroupMessages`. + +Justification: one-shot use, low frequency (LINK callback only), no benefit +to introducing a new Store function. `vr` and `user` are already in scope at +the LINK callback (Subscriber.hs:1306, inside `processContactConnMessage`). + +### 6.3 Defensive batching + +Per overview, the receive-loop group lock serializes `XGrpRelayAcpt` handling (which +calls `setGroupLinkDataAsync`) so each LINK callback typically sees a single +`RSAccepted → RSActive` transition. Coding the send as `NonEmpty (XGrpRelayNew _)` keeps +the path correct if the agent ever consolidates `setConnShortLink` writes. The +`L.nonEmpty newlyActive` guard handles the empty case (no transition this callback). + +### 6.4 Relay — `processEvent` case (Subscriber.hs:990-1032) + +Insert this case before the catch-all `_ -> Nothing <$ messageError ...` at 1032: + +``` +XGrpRelayNew _ -> pure $ Just (DeliveryTaskContext (DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}) False) +``` + +Justification by precedent: +- `XMsgNew` (991) → `newGroupContentMessage` returns `Just (ctx js)` where `ctx` is + `DeliveryTaskContext js False` (line 983). The `False` is the "don't include in + history" flag — relay forwards but doesn't snapshot. +- `XGrpMemNew` (1011) → `xGrpMemNew` returns `Just (ctx (DJSGroup {…}))`. We want + identical broadcast scope (all subscribers, no support-only channel). +- `XGrpDel` (1022) is the only event that uses `DJRelayRemoved`; that is for + relay-removal-by-owner, not relevant here. + +`DJDeliveryJob {includePending = False}` matches `XMsgNew`'s default (search +`Delivery.hs` for `DJDeliveryJob` constructor — `includePending = False` is the +non-administrative-state-change default; `XGrpInfo` uses `True` because it changes +group profile state and pending members must learn it on accept). The relay +**stores no member record for the announced relay** (overview §"Relay — forward +only"), so subscribers entering pending state later will instead learn via on-open +`syncSubscriberRelays`. `includePending = False` is correct. + +What NOT to do: +- Do not add an `xGrpRelayNew` handler on the relay side — the relay is forward-only. +- Do not create a `GroupMember` record for the announced relay on the relay. Departure + from `XGrpMemNew` semantics is intentional; relays don't connect to other relays of + the same channel. + +### 6.5 Subscriber — `processForwardedMsg` case (Subscriber.hs:3354-3383) + +Add to the inner `case event of` (just before the catch-all `_ -> messageError ...` at +3378): + +``` +XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \_author -> connectToRelayAsync user gInfo rl +``` + +Notes: +- `withAuthor` (3380-3383) requires `author_ :: Maybe GroupMember` to be `Just` — + enforces the "must be attributable to a signing owner" invariant. `FwdChannel` (3351 + via `processForwardedMsg (VMUnsigned chatMsg) Nothing`) makes `author_ = Nothing`, + which `withAuthor` rejects with `messageError`. This is the desired behaviour: the + event must be owner-signed and attributed. +- Signature verification happens upstream in `withVerifiedMsg` (3385-3407) before + `processForwardedMsg` is invoked (3348-3349). With `requiresSignature` returning + `True` for `XGrpRelayNew_` (§2.6), an unsigned forwarded `XGrpRelayNew` triggers the + bad-signature path at 3389-3391. +- The `_author` is used only as an authorisation token here. The connect helper does + not need the author identity — the author is the owner whose link data already + carried the relay key, and the relay member's keys/profile are fetched from the + relay's own short link. + +### 6.6 `xGrpMsgForward` — no change needed + +Already validates the forwarder is a relay (`isMemberGrpFwdRelay`, 3340) and dispatches +to `processForwardedMsg`. Adding the new event tag inside that switch is the entirety +of the receive-side change. + +### 6.7 What NOT to change in `Subscriber.hs` + +- Do not touch the `CFGetRelayDataJoin` LDATA callback (1131-1160). Its end state + (subscriber-side) is exactly the continuation we want; the helper hands off to it. +- Do not touch the `CON` handler at 823-865 for relay members. The `firstConnectedHost` + branch (855-859) handles the first-connected-relay UI events; subsequent relays go + through 859. After `XGrpRelayNew`-driven connect, the new relay's `CON` will land in + this same handler and get `firstConnectedHost = False` (because at least one relay is + already connected), which is correct. +- Do not modify the `CONF`/`XGrpRelayAcpt` path at 768-772. That is owner-side. + +--- + +## 7. `docs/protocol/channels-protocol.md` updates (S3) + +### 7.1 Signing-required table + +Section: `## Protocol → ### Message signing → "Which messages require signatures:"` +table (lines 84-97). Add a row after `x.grp.mem.restrict`: + +``` +| `x.grp.relay.new` | Announce new relay to subscribers | Required | +``` + +Phrasing matches existing `Description` cells (verb + object). + +### 7.2 New subsection "Relay addition" + +Insert after the existing `### Relay acceptance` subsection (lines 42-58). Heading +level `###`, four short paragraphs: + +1. **Owner-side trigger.** When the owner has accepted a relay (existing flow, + `x.grp.relay.acpt` at line 36) and the agent confirms the link-data update by + delivering the LINK event, the owner promotes the relay locally to active and + sends `x.grp.relay.new` to every other currently-connected relay of the channel + (excluding the relay being announced). +2. **Wire format.** Single-field JSON object: `{"relayLink": ""}`. + Owner-signed via the same `CBGroup` binding prefix used for all administrative + events (see [Message signing](#message-signing)). +3. **Relay forwarding semantics.** Each relay forwards `x.grp.relay.new` verbatim to + all of its subscribers via the standard delivery pipeline (delivery_task / + delivery_job, see [Delivery pipeline](#delivery-pipeline)). The relay does **not** + create a member record for the announced relay — relays do not connect to other + relays of the same channel. +4. **Subscriber receive semantics.** The subscriber resolves the announced short link + asynchronously, creates a relay-member row (or reuses an existing active row), and + binds the resulting agent connection without blocking the receive loop. If the + subscriber's client doesn't recognise the event (older version), it is parsed as + `XUnknown` and ignored; the next `APIGetUpdatedGroupLinkData` (channel open) reaches + the same end state via `syncSubscriberRelays`. +5. **Idempotence.** The receive loop wraps each agent message in a per-group entity + lock (`CLGroup groupId`); the same lock is held by `APIGetUpdatedGroupLinkData`. + A duplicate `x.grp.relay.new` arriving from a second relay finds an active row + + active connection and is a no-op. + +### 7.3 What NOT to change + +- Do not renumber existing sections. +- Do not modify the `Binary batch format` section — `x.grp.relay.new` is a + `signedElement` like every other administrative event; no new ABNF. +- Do not touch the `Channel-as-sender messages` section — `XGrpRelayNew` is owner-bound, + `CBGroup`, never `CBChannel`. + +--- + +## 8. Test plan (S8) + +All tests live under `tests/ChatTests/Channels.hs` (or a dedicated +`tests/ChatTests/Channels/RelayAnnounce.hs` if the file is getting unwieldy — confirm +with reviewer). Each test maps to a row in the overview's "Test surface". + +| Overview test | Concrete test name | Harness | +|---|---|---| +| Owner adds relay → subscribers receive `XGrpRelayNew` and connect without channel open | `testRelayAnnounceOnlineSubscriber` | uses `testChat3` (owner + relay + subscriber); after channel is up, owner adds a second relay; assert subscriber's relay-member count for that group becomes 2 with both `GSMemConnected`, no `APIGetUpdatedGroupLinkData` invoked. | +| Two relays forward the announce; subscriber connects exactly once | `testRelayAnnounceDedupes` | `testChat4` (owner + 2 existing relays + subscriber); owner adds third relay; both existing relays forward; assert exactly one new relay-member row, exactly one connection. Inspect via `withCCStore (getGroupRelayMembers …)`. | +| Race vs. `APIGetUpdatedGroupLinkData` for same relay | `testRelayAnnounceRaceWithSync` | drive `APIGetUpdatedGroupLinkData` and `XGrpRelayNew` concurrently; assert no double row; rely on the existing `withGroupLock` to serialize. | +| `GSMemLeft` row preserved on re-add | `testRelayAnnounceReAddPreservesHistory` | owner adds relay, removes it, adds again with same link; assert two `GroupMember` rows for that link (one `GSMemLeft`, one current); the historical row is what drives the "removed by operator" UI. | +| Old subscriber ignores | `testRelayAnnounceOldSubscriber` | use `chatVersionRange` overrides to simulate an older subscriber; assert event is logged as unknown but produces no error item; `syncSubscriberRelays` invocation on next channel open creates the row. | +| Old relay drops | `testRelayAnnounceOldRelay` | inverse: relay's `chatVersionRange` does not include `XGrpRelayNew_` → `processEvent` default `messageError "unsupported"`. Subscribers fall back to on-open sync. | +| Bad signature | `testRelayAnnounceBadSignature` | inject an unsigned (or wrong-signed) `XGrpRelayNew` directly via the test SMP harness; assert `RGEMsgBadSignature` chat item is created on subscriber. | + +Helpers reused: `withSmpServer`, `testChat`, `testChat3`, `testChat4`, `awaitListChat`, +`withCCStore`, `getGroupRelayMembers`. Add a small helper in the test module +`assertRelayMemberCount :: TestCC -> GroupId -> Int -> IO ()` if not already present. + +For the dedup test specifically, assertion shape: + +``` +m <- getGroupRelayMembers db vr user gInfo +let relayRows = filter (\GM -> relayLink GM == Just newRl && memberCurrent GM) m +length relayRows `shouldBe` 1 +length (filter (isJust . activeConn) relayRows) `shouldBe` 1 +``` + +--- + +## 9. Risk register + +1. **Race: event arrives during channel open.** The receive loop and + `APIGetUpdatedGroupLinkData` share `CLGroup groupId`. Whichever path runs first + creates/uses the row; the second sees an active row + active conn (or creates one + if not yet) and is a no-op. Tested via `testRelayAnnounceRaceWithSync`. + +2. **Agent coalescing of `setConnShortLink` writes.** Today the receive-loop group lock + serializes `XGrpRelayAcpt` handling, so each LINK callback sees one transition. If + the agent ever batches multiple writes into one callback, the `NonEmpty + (XGrpRelayNew _)` send path stays correct: every newly-active relay gets announced. + No fix needed; defensive shape is already there. + +3. **Old relay between owner and subscriber.** Old relay's `processEvent` default branch + drops the event with `messageError "unsupported"`. Subscribers behind that relay + recover via on-open `syncSubscriberRelays`. Documented in the protocol doc and + covered by `testRelayAnnounceOldRelay`. + +4. **Malformed signature.** `requiresSignature XGrpRelayNew_ = True` causes + `withVerifiedMsg` to reject and produce `RGEMsgBadSignature`. Standard path; tested. + +5. **Agent error during `getAgentConnShortLinkAsync` (step 3 of + `connectToRelayAsync`).** If the failure happens before + `createRelayMemberConnectionAsync` runs, `activeConn` is `Nothing` + and the next trigger retries automatically. If the call succeeds + but a later async step (LDATA, CONF, CON) stalls, `activeConn` + exists in a non-`ConnReady` state; the chat layer does not retry + by design (Option A simple skip). The agent layer's internal + retries on subscription resume drive recovery for transient + network failures. Permanent stalls are recovered via explicit + retry paths (`retryRelayConnectionAsync`, channel re-join). + +6. **Link-data fetch failure after pre-created member row.** Two + sub-cases. (a) `createRelayMemberConnectionAsync` not yet run: + `activeConn = Nothing`, next trigger retries (`XGrpRelayNew` + arrival from another relay or channel re-open via + `syncSubscriberRelays`). (b) Connection record exists but LDATA + failed: `activeConn = Just _`, chat layer skips by Option A; + agent layer retries internally on subscription resume. + +7. **Active-status filter on lookup breaks other call sites.** The filter is added in + place on `getCreateRelayForMember`'s inner lookup. Its lone existing caller is the + subscriber-join path (`APIConnectPreparedGroup` → `connectToRelay`, Commands.hs:2141 + / 3597-3613); rows there are created with `GSMemAccepted`, which is `memberCurrent`, + so the filtered lookup still finds them on retry. Observable behaviour unchanged for + the existing caller. Audit done in §3.3; reviewer to confirm. + +8. **Multiple owners (future).** `LINK` callback only fires for the local owner's own + `setConnShortLink` calls (per existing TODO at Subscriber.hs:1327-1329). A second + owner adding a relay won't trigger the event from this owner — the second owner + would emit it themselves. Out of scope for current single-owner channels. + +--- + +## 10. Backward compatibility + +- **No schema migration.** The plan adds zero columns and zero tables. The new lookup + uses an existing column (`group_members.relay_link`) with an existing index path. +- **No protocol-version bump in the chat versioning.** The new tag is parsed as + `XUnknown` by clients that do not recognise `"x.grp.relay.new"` (Protocol.hs:1129 + default branch in `strDecode`). `XUnknown` is silently ignored when reached by + `processEvent` (Subscriber.hs:1032 catch-all `messageError`); since this is on the + receive side of an old client, the message is logged as unsupported and the channel + state is unaffected. +- **No serialization-compat shim.** The single-field JSON form means old clients fall + through to `XUnknown_` cleanly without any optional-field hand-rolling. + +--- + +## 11. Out of scope + +- **Owner authorization-chain pushes.** Adding/removing owners is governed by the + multi-owner roadmap (channels-overview.md §"Governance evolution"). `XGrpRelayNew` + does not carry owner-chain payload. +- **Profile pushes.** Subscriber profile changes are out of scope; relay profile + arrives via the relay's own link data in the existing `CFGetRelayDataJoin` LDATA + flow. +- **Content batching beyond the LINK callback.** The `NonEmpty` shape is defensive + for agent-side coalescing, not a general batching mechanism. +- **Retry-on-failure semantics for the new async path.** Existing + `retryRelayConnectionAsync` (Commands.hs:2168-2174) covers the subscriber-join + retry of failed initial connects (`APIConnectPreparedGroup` tail recovery). For + event-driven re-attempts, on-open `syncSubscriberRelays` is the recovery mechanism; + per-link retry timers are not added. +- **Deletion of `connectToRelay` (sync).** Default kept. The lone caller is + `APIConnectPreparedGroup` (subscriber's initial channel-join flow, + Commands.hs:2108-2161), not owner channel creation. Deletion is a reviewer-confirmed + follow-up if subscriber-join is converged onto async — see §5.1 for why the sync + flow is intentional there. + +--- + +## 12. Concerns with overview + +- **Overview §"Files touched" lists "remove sync `connectToRelay`".** After tracing + Commands.hs:2141 (`mapConcurrently (connectToRelay user gInfo') relays` inside + `APIConnectPreparedGroup`), the sync helper still has a real caller — the + **subscriber's initial channel-join** (not owner setup). Deleting it now would + either break that path or force `APIConnectPreparedGroup` onto the async helper, + which is a separate concern (different UX expectations: spinner-blocking immediate + feedback vs. fire-and-forget). Plan defers this to a follow-up. Reviewer to confirm. + +- **Overview §"Subscriber — receive" mentions `withAuthor XGrpRelayNew_`.** That + function name (the tag) does not currently exist in `Protocol.hs`; it lands in §2.2 + of this plan. Naming preserved verbatim. + +- **Overview §"Test surface" "Owner with old relay → relay drops the event".** The + current `processEvent` default branch is at Subscriber.hs:1032. Verified: the + default `_ -> Nothing <$ messageError ("unsupported message: " <> tshow event)` + drops the event after logging. This matches the overview's expectation. diff --git a/plans/2026-05-08-relay-announce.md b/plans/2026-05-08-relay-announce.md new file mode 100644 index 0000000000..37ffbd88ba --- /dev/null +++ b/plans/2026-05-08-relay-announce.md @@ -0,0 +1,119 @@ +# Plan: owner-pushed relay announcement (`XGrpRelayNew`) + +## Goal +Subscribers learn of newly added relays immediately via an owner-pushed event, +rather than only on next channel open via `syncSubscriberRelays`. + +## Wire-format +- New event: `XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json`, tag `x.grp.relay.new`. +- Add to `isForwardedGroupMsg` in `Protocol.hs`. +- Add to required-signed-by-owner table in `docs/protocol/channels-protocol.md`. + Reuses existing `CBGroup`-prefixed signing infrastructure. + +## Owner — send site +- In LINK callback at `Subscriber.hs:1305-1322`, after the fold over relays + that drives `RSAccepted → RSActive` transitions. +- Collect `relayLink` for every relay that transitioned to Active in this callback. +- If non-empty, build `events = XGrpRelayNew rl1 :| [XGrpRelayNew rl2, ...]` + and call `sendGroupMessages user gInfo Nothing False otherRelays events`. +- Recipients: channel's currently-connected relays minus the newly-active ones + (the announced relays don't need self-announcement). +- Batched shape is defensive, not load-bearing. The receive-loop group lock + serializes `XGrpRelayAcpt` handling and the subsequent + `setGroupLinkDataAsync` → LINK chain, so each LINK callback typically + transitions at most one relay. Coding the send as a `NonEmpty` of + `XGrpRelayNew` events keeps the path correct if the agent ever consolidates + link-data writes. + +## Relay — forward only +- `processEvent` (Subscriber.hs:980-1032) gets a new case: + `XGrpRelayNew _ -> pure $ Just (DeliveryTaskContext (DJSGroup ...) False)`. +- No local handler — relay does NOT create a member record for the announced + relay (departure from `XGrpMemNew` semantics; relays don't connect to other + relays of the same channel). +- Forwarding is verbatim through binary-batch format, signature preserved. +- Old relay (no tag): `_ -> messageError "unsupported"` path drops the message. + Fallback: subscriber's on-open `syncSubscriberRelays` still works. + +## Subscriber — receive +- Add case in `processForwardedMsg` (Subscriber.hs:3357-3378): + `XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> connectToRelayAsync user gInfo rl`. +- Author resolution + signature verification via existing `withAuthor` / + `withVerifiedMsg` machinery — same boundary as `XGrpInfo` etc. today. +- `FwdChannel` (channel-as-sender) is NOT valid for this event — it is + administrative and must be attributed to a signing owner. + +## Subscriber — `connectToRelayAsync` helper +Place in `Internal.hs`. Both event handler and `syncSubscriberRelays` call it. +Body: + + 1. Look up active (`memberCurrent`) relay-member row by `relay_link`. + - If found AND has active connection → skip (already in flight or done). + - If found but no active connection → use it; proceed. + - If not found → create new relay-member row (with `relay_link`, role + `GRRelay`, status `GSMemAccepted`, no member-id/key/profile yet). + 2. `getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink` + → returns `(cmdId, agentConnId)`. + 3. `createRelayMemberConnectionAsync` binds those to the relay-member. + 4. Return. Continuation is the existing `CFGetRelayDataJoin` LDATA callback + at Subscriber.hs:1131-1160 (updates relay-member with member-id/key/profile, + calls `joinAgentConnectionAsync` → eventual `CON` flips status to + `GSMemConnected`). + +A `GSMemLeft` historical row for the same `relay_link` is left in place +(displays "removed by operator"). Lookup must filter to `memberCurrent`. + +## Idempotence and races +- Receive loop wraps each agent message in `withEntityLock` keyed by the + connection's lock entity (Subscriber.hs:115-117). Relay-member connections + resolve to `CLGroup groupId` (Store/Connections.hs:51-65). +- `APIGetUpdatedGroupLinkData` already uses `withGroupLock "syncSubscriberRelays" groupId`. +- Same key on both paths → event handler and open-channel command cannot + interleave for a given group_id. No additional lock needed. +- Inside the lock, "active row + active conn" check is sufficient. No + `justCreated` flag, no per-link mutex. + +## `syncSubscriberRelays` migration +- Move from `Commands.hs` to `Internal.hs`. +- "Add" half: replace synchronous `connectToRelay` with `connectToRelayAsync`. +- "Remove" half (Commands.hs:3623-3633): unchanged. +- `connectToRelay` (sync) deletable once event-driven path is wired and + no caller remains. + +## Old client compatibility +- Old subscriber: parses `XGrpRelayNew` as `XUnknown`, ignores. On-open + `syncSubscriberRelays` is the fallback path. +- Old relay: drops the message in `processEvent`'s default branch. Subscribers + on those relays fall back to on-open sync. Acceptable graceful degradation. + +## Test surface +- Owner adds relay → existing subscribers (online) receive `XGrpRelayNew` and + connect without channel open. +- Channel with two existing relays: owner adds a third relay; both existing + relays forward `XGrpRelayNew` for the new relay to subscribers in parallel + → shared-msg-id dedup leaves only one copy reaching the helper; subscriber + connects to the announced relay exactly once. +- `XGrpRelayNew` arrives while subscriber is mid-`APIGetUpdatedGroupLinkData` + for the same relay → group lock serializes; no double connection. +- Subscriber re-add scenario: previous `GSMemLeft` row for same `relay_link` + → new active row created, old row preserved for history. +- Old subscriber receives forwarded `XGrpRelayNew` → ignored, channel-open + sync still recovers. +- Owner with old relay → relay drops the event; subscribers learn on open. +- Bad signature on `XGrpRelayNew` → rejected with bad-signature event. + +## Files touched (anticipated) +- `src/Simplex/Chat/Protocol.hs` — event constructor, tag, JSON encode/parse, + `isForwardedGroupMsg`. +- `src/Simplex/Chat/Library/Internal.hs` — `connectToRelayAsync` helper, + `syncSubscriberRelays` moved here. +- `src/Simplex/Chat/Library/Subscriber.hs` — owner send (LINK callback), + relay forward-only `processEvent` case, subscriber forwarded + `processForwardedMsg` case. +- `src/Simplex/Chat/Library/Commands.hs` — remove sync `connectToRelay`, + `APIGetUpdatedGroupLinkData` calls async helper. +- `src/Simplex/Chat/Store/Groups.hs` — adjust relay-member lookup to filter + on `memberCurrent`. +- `docs/protocol/channels-protocol.md` — signing-required table row, + relay-addition subsection. +- `tests/ChatTests/...` — tests per "Test surface" above. diff --git a/plans/2026-05-09-desktop-tray-implementation.md b/plans/2026-05-09-desktop-tray-implementation.md new file mode 100644 index 0000000000..a3f047cd07 --- /dev/null +++ b/plans/2026-05-09-desktop-tray-implementation.md @@ -0,0 +1,422 @@ +# Desktop tray icon — implementation plan + +Companion to the design at `plans/2026-05-09-desktop-tray.md`. Read that first. + +## What + +Seven small commits that build the feature incrementally. After each commit the build is green and the app still runs; only the last commit makes the feature visible to the user end-to-end. + +## Why + +We split the work this way so each commit is reviewable on its own and revertable without unwinding others. The order keeps the build green throughout (no commit introduces a reference to something the next commit will define). + +## How + +### Pre-flight + +- Pull the branch `sh/tray` (current branch). It is at `stable`. +- Confirm dev environment can build desktop: `cd apps/multiplatform && ./gradlew :common:desktopMainClasses` — should succeed before any change. +- Read `plans/2026-05-09-desktop-tray.md` end to end. The implementation steps below assume that design is settled. + +--- + +### Task 1 — `CloseBehavior` enum + preference + +**Files** +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` + +**What to add.** The enum lives next to other small enums in this file (search for `enum class LAMode` for placement convention). The preference goes in `class AppPreferences` next to `notificationsMode`. + +Match the existing pattern (use `values().firstOrNull { it.name == this }`, not `entries`, to stay consistent with `LAMode` and others in this file): + +```kotlin +enum class CloseBehavior { + Ask, Quit, MinimizeToTray; + companion object { val default = Ask } +} + +// In AppPreferences: +val closeBehavior: SharedPreference = + mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default) +``` + +Add the constant at the bottom of `AppPreferences` next to other `SHARED_PREFS_*` constants: + +```kotlin +private const val SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR = "DesktopCloseBehavior" +``` + +**Verify.** Build: `./gradlew :common:desktopMainClasses` — succeeds. No behavior change yet. + +**Commit.** `desktop: add CloseBehavior preference` + +--- + +### Task 2 — Window-visibility state + branching close handler (no dialog, no tray yet) + +**(Note: a `Task 2 — Add ComposeNativeTray dependency` is removed. We now use Compose Multiplatform's built-in `androidx.compose.ui.window.Tray`, already on the classpath via the `org.jetbrains.compose` plugin. No new dep.)** + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` + +**What to change.** + +1. Add `windowVisible` to `SimplexWindowState` (the class at line 227): + +```kotlin +class SimplexWindowState { + // ...existing fields... + val windowVisible = mutableStateOf(true) +} +``` + +2. In `AppWindow`, pass it to `Window`: + +```kotlin +Window( + state = windowState, + visible = simplexWindowState.windowVisible.value, + icon = painterResource(MR.images.ic_simplex), + onCloseRequest = { handleCloseRequest(closedByError) }, + // ...rest unchanged... +) +``` + +3. Add the handler at file scope (or near `showApp`). Temporarily make `Ask` fall through to `Quit` — the dialog comes in Task 3: + +```kotlin +private fun ApplicationScope.handleCloseRequest(closedByError: MutableState) { + if (closedByError.value) { closedByError.value = false; exitApplication(); return } + when (appPrefs.closeBehavior.get()) { + CloseBehavior.Quit, CloseBehavior.Ask -> { + closedByError.value = false + exitApplication() + } + CloseBehavior.MinimizeToTray -> { + simplexWindowState.windowVisible.value = false + } + } +} +``` + +The `MinimizeToTray` branch will get a tray-availability guard in Task 5 (defensive: a user could have set the pref on a different machine where tray works). + +(Imports: `chat.simplex.common.model.CloseBehavior`, `chat.simplex.common.model.ChatController.appPrefs`.) + +**Verify.** Build + run desktop: + +``` +./gradlew :desktop:run +``` + +Click X — app exits exactly as today. No dialog, no tray. (Internal preference is `Ask`, branch falls through to Quit.) + +**Commit.** `desktop: branch close handler on CloseBehavior preference` + +--- + +### Task 3 — First-close dialog + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` *(new)* +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` +- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` + +**Strings.** Add to `strings.xml`: + +```xml +Minimize to tray? +If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings. +Close the app +Minimize to tray +``` + +**`DesktopTray.kt` — dialog only.** A `mutableStateOf?>` global, and a Composable that, when set, renders a non-dismissible `Dialog` with the two buttons. Skeleton: + +```kotlin +package chat.simplex.common + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource + +private val pendingCloseChoice = mutableStateOf(null) + +private data class CloseChoice(val onClose: () -> Unit, val onMinimize: () -> Unit) + +fun requestCloseBehavior(onClose: () -> Unit, onMinimize: () -> Unit) { + pendingCloseChoice.value = CloseChoice(onClose, onMinimize) +} + +@Composable +fun CloseBehaviorDialog() { + val choice = pendingCloseChoice.value ?: return + Dialog( + onCloseRequest = { /* swallow — non-dismissible */ }, + state = rememberDialogState(width = 420.dp, height = 220.dp), + title = stringResource(MR.strings.close_behavior_dialog_title), + resizable = false, + ) { + Column(Modifier.padding(24.dp)) { + Text(stringResource(MR.strings.close_behavior_dialog_text)) + Spacer(Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { pendingCloseChoice.value = null; choice.onClose() }, + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error), + ) { Text(stringResource(MR.strings.close_behavior_dialog_close)) } + // Hide the Minimize button when tray isn't supported (stock GNOME). + // The dialog still asks once so the user gets a definitive Quit answer + // and doesn't see the dialog again. trayIsAvailable is defined in Task 5; + // until then, the button is always shown. + Button( + onClick = { pendingCloseChoice.value = null; choice.onMinimize() }, + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primary), + ) { Text(stringResource(MR.strings.close_behavior_dialog_minimize)) } + } + } + } +} +``` + +**Wire it up in `DesktopApp.kt`.** Inside `application(exitProcessOnExit = false) { … }`, render `CloseBehaviorDialog()` alongside `AppWindow`. Update `handleCloseRequest`'s `Ask` branch: + +```kotlin +CloseBehavior.Ask -> requestCloseBehavior( + onClose = { + appPrefs.closeBehavior.set(CloseBehavior.Quit) + closedByError.value = false + exitApplication() + }, + onMinimize = { + appPrefs.closeBehavior.set(CloseBehavior.MinimizeToTray) + simplexWindowState.windowVisible.value = false + } +) +``` + +**Verify.** Run, click X — dialog appears with the exact text and button colors. Click "Close the app" → exits. Reopen, click X — exits without dialog (preference is `Quit`). + +To reset the preference for re-testing, delete the SimpleX Chat desktop preferences file: +- Linux: `~/.config/simplex/SimpleXChatDesktop.properties` +- macOS: `~/Library/Preferences/SimpleXChatDesktop.properties` +- Windows: `%AppData%\SimpleX\SimpleXChatDesktop.properties` + +Click "Minimize to tray" → window hides; the app process keeps running but is invisible (no tray icon yet — that's Task 6). Kill the JVM with Ctrl-C in the terminal to recover. + +**Commit.** `desktop: first-close dialog for tray choice` + +--- + +### Task 4 — Tray icon resources + +**Files** +- `apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg` +- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` + +**Icons.** Reuse the existing `MR.images.ic_simplex` for the no-unread case and add a single new asset for the unread case: + +- `ic_simplex_tray_dot` — copy of `ic_simplex.svg` with a small red filled circle added in the bottom-right (~6px radius in the 40×40 viewBox). + +Drop the SVG into `MR/images/`. Moko picks it up; refer to as `MR.images.ic_simplex_tray_dot`. Run a build to check generation: `./gradlew :common:generateMRcommonMain`. + +**Strings.** Tray menu items + tooltip strings: + +```xml +Show SimpleX +Quit SimpleX +SimpleX +SimpleX — %d unread +``` + +**Verify.** Build succeeds; the generated `MR.images.ic_simplex_tray_dot` and `MR.strings.tray_*` symbols compile when referenced from a temporary scratch file (delete after). + +**Commit.** `desktop: tray icon assets and menu strings` + +--- + +### Task 5 — Tray composable (no unread indicator yet) + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` + +**Add to `DesktopTray.kt`.** Tray-availability probe, functions to show window and quit, the Tray composable itself. + +```kotlin +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.Tray +import androidx.compose.ui.window.MenuBar +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.awt.SystemTray + +// Probed once at startup. Performs a real add/remove of a transparent TrayIcon +// because SystemTray.isSupported() can return true while add() throws (JDK-8322750). +val trayIsAvailable: Boolean by lazy { + if (!SystemTray.isSupported()) return@lazy false + try { + val tray = SystemTray.getSystemTray() + val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) + tray.add(probe); tray.remove(probe); true + } catch (e: AWTException) { false } catch (e: SecurityException) { false } +} + +fun showWindow() { + simplexWindowState.windowVisible.value = true + simplexWindowState.window?.toFront() + simplexWindowState.window?.requestFocus() +} + +@Composable +fun ApplicationScope.SimplexTray(closedByError: MutableState) { + if (!trayIsAvailable) return + if (appPrefs.closeBehavior.state.value != CloseBehavior.MinimizeToTray) return + Tray( + icon = painterResource(MR.images.ic_simplex_tray), + tooltip = stringResource(MR.strings.tray_tooltip), + onAction = ::showWindow, + menu = { + Item(stringResource(MR.strings.tray_show), onClick = ::showWindow) + Separator() + Item(stringResource(MR.strings.tray_quit), onClick = { + closedByError.value = false + exitApplication() + }) + } + ) +} +``` + +(Note: this uses Compose Multiplatform's built-in `androidx.compose.ui.window.Tray`. The API is `icon: Painter`, `onAction` (not `primaryAction`), menu DSL uses `Separator()` (not `Divider()`).) + +**Update `DesktopApp.kt`'s close handler** to add the defensive tray-availability check from Task 2's TODO: + +```kotlin +CloseBehavior.MinimizeToTray -> { + if (trayIsAvailable) { + simplexWindowState.windowVisible.value = false + } else { + closedByError.value = false + exitApplication() + } +} +``` + +**Wire into `DesktopApp.kt`.** Inside `application(exitProcessOnExit = false) { … }`: + +```kotlin +SimplexTray(closedByError) +CloseBehaviorDialog() +AppWindow(closedByError) +``` + +The order doesn't affect rendering — the tray and dialog are top-level surfaces. + +**Verify.** Run; in the dialog pick "Minimize to tray". Window hides; tray icon appears. Left-click tray — window restores. Right-click tray — menu has "Show SimpleX" and "Quit SimpleX". Both work. Quit, restart — preference persists; clicking X hides directly without dialog. Tray icon appears at app startup (because the preference is now `MinimizeToTray`). + +**Commit.** `desktop: system tray icon with show/quit menu` + +--- + +### Task 6 — Unread indicator + tooltip count + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` + +**Change `SimplexTray`.** Replace the static icon and tooltip with reactive ones: + +```kotlin +// UserInfo.unreadCount is incremented only when ntfsEnabled(item) — see SimpleXAPI.kt:2781-2783. +val unread by remember { + derivedStateOf { ChatModel.users.sumOf { it.unreadCount } } +} +val iconRes = if (unread > 0) MR.images.ic_simplex_tray_dot else MR.images.ic_simplex +val tooltip = + if (unread > 0) stringResource(MR.strings.tray_tooltip_unread, unread) + else stringResource(MR.strings.tray_tooltip) + +Tray( + icon = painterResource(iconRes), + tooltip = tooltip, + // onAction + menu unchanged +) +``` + +**Verify.** +1. With "Minimize to tray" enabled, hide the window. +2. Trigger a notification (have another account/contact send you a message; or open a direct chat with notifications enabled and post from another device). +3. Tray icon switches to the red-dot variant; tooltip shows "SimpleX — 1 unread" (or higher). +4. Click tray, view the message in the relevant chat. Icon reverts to the plain variant; tooltip becomes "SimpleX". + +**Commit.** `desktop: unread indicator on tray icon` + +--- + +### Task 7 — Appearance settings toggle + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt` +- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` + +**Strings.** + +```xml +Minimize to tray when closing window +Keep SimpleX running in the background to receive messages. +``` + +**UI row.** In `AppearanceLayout` (the Composable around line ~38), add a new section row using the existing `SectionItemView` / `SettingsActionItemWithContent` / similar patterns visible in this file. The entire row is gated on `trayIsAvailable` — if the OS has no tray host, the toggle is omitted. Read the surrounding rows for the exact convention; the snippet below is illustrative: + +```kotlin +if (trayIsAvailable) { + val pref = remember { appPrefs.closeBehavior.state } + val on = pref.value == CloseBehavior.MinimizeToTray + SectionItemView { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(MR.strings.appearance_minimize_to_tray)) + Text( + stringResource(MR.strings.appearance_minimize_to_tray_desc), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) + ) + } + Switch( + checked = on, + onCheckedChange = { checked -> + appPrefs.closeBehavior.set(if (checked) CloseBehavior.MinimizeToTray else CloseBehavior.Quit) + } + ) + } + } +} +``` + +Place the row in the existing `AppearanceLayout` Composable, after the theme/dark-mode rows and before the language selector — that grouping is for general window-and-display preferences and the new toggle fits there. Match the styling of nearby rows. If a clearer section emerges during implementation, add a new `SectionView` with a "Window" header instead. + +**Verify.** Open Appearance settings; toggle the row off — tray icon disappears; click X exits with no dialog. Toggle back on — tray icon reappears (Compose recomposes the gated `Tray` composable). Window-close behavior still depends on the toggle. + +**Commit.** `desktop: Appearance toggle for minimize-to-tray` + +--- + +### Final manual test pass + +Run the full test plan from the spec on each platform you can reach (Linux KDE, Windows 11, macOS): + +1. Fresh install (clear `~/.config/simplex/` or per-OS data dir). Click X → dialog with the right text and button colors. Esc / outside-tap do nothing. +2. Pick Close → exits. Reopen → click X → exits with no dialog. +3. Reset, pick Minimize to tray → window hides, tray icon shows. +4. Receive a message → red-dot variant + tooltip count. +5. Click tray → window restores and focuses (acceptable if focus is best-effort per spec). +6. Right-click tray → Show / Quit both work. +7. Appearance toggle off → tray vanishes, X exits without dialog. +8. Appearance toggle on → tray reappears. + +If anything fails, file follow-ups; the spec's "out of scope" list catches the expected omissions (autostart, number-on-icon, etc.). diff --git a/plans/2026-05-09-desktop-tray.md b/plans/2026-05-09-desktop-tray.md new file mode 100644 index 0000000000..ce67a83240 --- /dev/null +++ b/plans/2026-05-09-desktop-tray.md @@ -0,0 +1,197 @@ +# Desktop tray icon — minimize to tray on close + +## What + +Add a system tray icon (Windows notification area, Linux StatusNotifierItem, macOS menu bar) to the SimpleX desktop app, with a "minimize to tray" close behavior gated on first-time user choice. + +Three pieces: + +1. **First-close dialog** — the first time the user clicks the window's close (X) button, a modal asks whether to close the app or minimize it to the tray. The choice is remembered. +2. **Tray icon** — when the user has chosen "minimize to tray", the app installs a tray icon with a small right-click menu (Show / Quit) and an unread indicator. Clicking the icon restores the window. +3. **Appearance setting** — a "Minimize to tray when closing window" toggle in Appearance settings lets the user change their mind later. + +Scope: Linux + Windows + macOS. No autostart. No number-on-icon unread badge. No profile switcher in the tray menu. + +## Why + +Today, closing the SimpleX desktop window quits the process and the user stops receiving messages until they reopen the app. There is no way to keep the app running quietly in the background, which is the standard expectation for a chat client. + +We want this to be opt-in rather than a behavior change for existing users — hence the dialog on first close. Users who prefer the current quit-on-close behavior get exactly that with one click and never see the dialog again. Users who want background message delivery get it with one click and can manage it from settings. + +We are using Compose Multiplatform's built-in `androidx.compose.ui.window.Tray` rather than a third-party library. It works cleanly on Windows, macOS, and Linux desktops with a system tray host (KDE Plasma, XFCE, Cinnamon, MATE, GNOME with the AppIndicator extension). The trade-off is that on stock GNOME the JDK deliberately returns `false` from `SystemTray.isSupported()` (per JDK-8322750), so we **probe at startup and disable the feature entirely** when the OS reports no tray support — the dialog hides the "Minimize to tray" option and the Appearance toggle is hidden too. Users with a working tray get the feature; users without never see broken/invisible UI. + +All tray-specific code lives in `desktopMain` only. The Android target compiles none of it — there are no expect/actual surfaces calling into tray functionality from `commonMain`. + +Users upgrading from a prior version will see the dialog on their first window-close after the update — that is intentional. The dialog is the chosen mechanism for getting consent before keeping a process running in the background, and an existing user has no way to give that consent in advance. + +## How + +### Close behavior — preference and flow + +Add an enum preference: + +```kotlin +enum class CloseBehavior { Ask, Quit, MinimizeToTray } + +// in AppPreferences: +val closeBehavior: SharedPreference = + mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default) +``` + +`Ask` is the default for fresh installs and for users upgrading from a version that did not have this preference. + +Replace the inline close handler in `DesktopApp.kt` (currently `onCloseRequest = { closedByError.value = false; exitApplication() }`) with a function that branches on the preference: + +- **Crash recovery first.** If `closedByError.value == true`, exit immediately with no dialog, no minimize. The crash handler at `DesktopApp.kt:46-47` dispatches `WINDOW_CLOSING` and depends on the application loop ending so it can re-enter. Honouring `closedByError` is what keeps that path working. +- `Quit` → exit immediately, as today. +- `MinimizeToTray` → set `simplexWindowState.windowVisible.value = false` and return. +- `Ask` → show the first-close dialog. The dialog's button writes the preference and then performs the corresponding action. + +The same handler is invoked for the X button, Alt+F4 on Windows, and the macOS red traffic-light close — Compose routes all three through `onCloseRequest`. **macOS Cmd+Q is not routed through `onCloseRequest`**: it goes through the application menu's Quit and calls `exitApplication()` directly. We accept that as "always quit" — Cmd+Q is an explicit user intent to quit the application and should not be intercepted by the dialog. Programmatic `WindowEvent.WINDOW_CLOSING` (e.g. from the crash handler) reaches `onCloseRequest` and is handled by the `closedByError` branch above. + +The dialog is non-dismissible (no Esc, no outside-tap) so the user must choose. Wording verbatim: + +> **Minimize to tray?** +> +> If you choose Close, messages won't be received. +> You can change it later in Appearance settings. +> +> [ Close the app ] [ Minimize to tray ] + +The "Close the app" button uses `MaterialTheme.colors.error` (red); "Minimize to tray" uses `MaterialTheme.colors.primary` (blue). The dialog is implemented bespoke (not via the existing `AlertManager`), because `AlertManager` does not support the non-dismissible + custom-button-color combination needed here. + +The Compose application loop already runs with `exitProcessOnExit = false`, so hiding the window does not exit the process. No restructuring of `showApp()` is needed. + +### Tray icon + +No new dependency. We use `androidx.compose.ui.window.Tray` (built into Compose Multiplatform, already on the classpath). It wraps `java.awt.SystemTray` under the hood — works wherever AWT's tray works, returns silently when it doesn't. + +**Tray availability probe.** `java.awt.SystemTray.isSupported()` alone is not reliable — there is a JDK pattern where it returns `true` but `SystemTray.add()` then throws `AWTException` (and Compose-MP does not catch it). We expose a `desktopMain` value that runs a real add/remove of a transparent `TrayIcon` inside a `try/catch` and caches the result: + +```kotlin +val trayIsAvailable: Boolean by lazy { + if (!SystemTray.isSupported()) return@lazy false + try { + val tray = SystemTray.getSystemTray() + val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) + tray.add(probe) + tray.remove(probe) + true + } catch (e: AWTException) { false } + catch (e: SecurityException) { false } +} +``` + +The probe is force-evaluated at the top of `showApp()` (off the EDT) so the JDK-8322750 GNOME detection subprocess does not block composition. When `false`: the Appearance toggle is hidden, the first-close dialog is skipped (`Ask` migrates silently to `Quit`), and the close handler treats `MinimizeToTray` as `Quit` (in case the preference was carried over from a tray-capable machine). + +The tray composable lives next to `AppWindow` inside `application(exitProcessOnExit = false) { … }` in `showApp()`. It is gated by the preference AND by tray availability: + +```kotlin +if (trayIsAvailable && appPrefs.closeBehavior.state.value == CloseBehavior.MinimizeToTray) { + // UserInfo.unreadCount is the pre-aggregated, ntfs-filtered counter — see SimpleXAPI.kt:2781-2783. + val unread by remember { derivedStateOf { + ChatModel.users.sumOf { it.unreadCount } + } } + val iconRes = if (unread > 0) MR.images.ic_simplex_tray_dot else MR.images.ic_simplex + val tooltip = if (unread > 0) + stringResource(MR.strings.tray_tooltip_unread, unread) + else + stringResource(MR.strings.tray_tooltip) + Tray( + icon = painterResource(iconRes), + tooltip = tooltip, + onAction = ::showWindow, + menu = { + Item(stringResource(MR.strings.tray_show), onClick = ::showWindow) + Separator() + Item(stringResource(MR.strings.tray_quit), onClick = { exitApplication() }) + } + ) +} +``` + +Note: Compose's `Tray` takes `icon: Painter` (not `iconContent`), `onAction` (not `primaryAction`), and the menu DSL uses `Separator()` (not `Divider()`). These are the right names for the built-in API. + +`showWindow()` sets `windowVisible.value = true` and calls `window?.toFront()` + `window?.requestFocus()`. Quitting from the tray menu just calls `exitApplication()` — `closedByError` is already `false` in the non-crash path, so the outer loop in `showApp()` terminates cleanly. + +**Unread indicator.** Icon swap based on `hasUnread`: reuse `ic_simplex` when zero, `ic_simplex_tray_dot` (same icon with a red dot overlay in the bottom-right) otherwise. Compose passes the `Painter` into AWT via `Painter.toAwtImage(density, layoutDirection, size)` — a single bitmap per state. One new image resource is enough: +- `MR.images.ic_simplex_tray_dot` — base icon with the red-dot overlay. + +**Icon size.** Compose `Tray` rasterises the `Painter` once at a per-platform target size: Linux 22×22, Windows 16×16, macOS 22×22 (with retina 2×). It's a single bitmap, so we source the painter at a comfortable size (e.g. via a `painterResource(MR.images.ic_simplex)` from the 40×40 SVG already shipped) and let the conversion handle the scale. We accept the slight scaling cost on 16×16 Windows panels rather than ship multiple size variants. + +**Tooltip.** Plain "SimpleX" when unread is zero; "SimpleX — N unread" otherwise. + +**Window restore is best-effort.** Compose Multiplatform issue [#4231](https://github.com/JetBrains/compose-multiplatform/issues/4231) documents that `toFront()` does not always pull the restored window above other windows on Linux/Windows — the OS may flash the taskbar entry instead. Acceptable for v1; if it bites users we can add the `isAlwaysOnTop = true; toFront(); isAlwaysOnTop = false` workaround in a follow-up. + +**No collision with the existing notification path.** `NtfManager.desktop.kt:178-188` contains an `java.awt.SystemTray` hack inside a private helper that turns out to be unreachable — the live notification path is `displayNotificationViaLib` (TwoSlices). The hack will not fire and cannot conflict with our tray icon. Cleaning up that dead code is out of scope here. + +**Toggling at runtime.** The `Tray { … }` composable is gated on `closeBehavior.state.value == MinimizeToTray`; Compose's recomposition lifecycle handles install/uninstall when the user flips the setting. No `LaunchedEffect` is needed. + +**Android isolation.** All tray code (the `Tray` composable, the close-behavior dialog, `showWindow`, the `trayIsAvailable` probe) lives in `desktopMain` only. The Android target compiles none of it — there are no expect/actual surfaces from `commonMain` calling into tray functionality. The only shared piece is the `CloseBehavior` enum + `closeBehavior` preference in `SimpleXAPI.kt`, which is plain data and never references tray APIs. + +### Appearance settings row + +In `Appearance.desktop.kt`, add one row to the existing settings section — **only when `trayIsAvailable`**: + +> ☑ **Minimize to tray when closing window** +> *Keep SimpleX running in the background to receive messages.* + +The toggle maps to the preference: + +- `MinimizeToTray` → on. +- `Quit` or `Ask` → off. + +Flipping on writes `MinimizeToTray`. Flipping off writes `Quit`. Touching the toggle resolves the `Ask` state to a definitive value — so a fresh-install user who opens Appearance settings, flips the row off, and then closes the window will *not* see the dialog (their preference is now `Quit`). This matches the user's apparent intent (they made a choice in settings) and avoids the surprise of a dialog appearing for a setting they thought they had already configured. + +When `trayIsAvailable` is `false` (stock GNOME without AppIndicator extension), the entire row is omitted from Appearance settings, the first-close dialog is skipped (`Ask` migrates silently to `Quit`), and the close handler treats `MinimizeToTray` as `Quit` (in case the user previously enabled it on a different machine). + +The wording "Minimize to tray" is used uniformly across all platforms, including macOS where the more native term would be "menu bar". A consistent in-app term is more important here than per-platform purity. + +### Files changed + +| File | Change | +|---|---| +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | Add `CloseBehavior` enum, `closeBehavior` preference, `SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR` constant. *(already in this branch as commit 1)* | +| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Replace inline `onCloseRequest`; add `windowVisible` to `SimplexWindowState`; wire `Window(visible = …)`; host the `Tray` composable conditionally on `trayIsAvailable && closeBehavior == MinimizeToTray`. | +| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` *(new)* | `trayIsAvailable` probe, `requestCloseBehavior` + `CloseBehaviorDialog`, `SimplexTray` composable, `showWindow` helper. | +| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt` | Add the toggle row (gated on `trayIsAvailable`). | +| `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` | Add 8 new strings (dialog title/body/buttons, settings row, tray menu). | +| `apps/multiplatform/common/src/commonMain/resources/MR/images/` | Add `ic_simplex_tray` + `ic_simplex_tray_dot`. | + +No `build.gradle.kts` change — Compose's `Tray` is already on the classpath via the existing `org.jetbrains.compose` plugin. + +### New strings + +```xml +Minimize to tray? +If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings. +Close the app +Minimize to tray +Minimize to tray when closing window +Keep SimpleX running in the background to receive messages. +Show SimpleX +Quit SimpleX +``` + +### Out of scope + +The following are deliberately not in this PR: + +- **Run on system startup / autostart entries.** Per-platform integration (Windows registry Run key, Linux `~/.config/autostart/*.desktop`, macOS LaunchAgents) is its own design. +- **Number-on-icon unread badges.** Cross-platform text rendering on tray icons is fragile across DPIs and macOS menu bar tinting. +- **Per-profile switcher / mute / mark-all-read** in the tray menu. Keep the menu to Show / Quit for now. +- **macOS template (auto-tinting) icon.** Compose `Tray` doesn't expose `NSImage.setTemplate:`; the tray icon will be a colored bitmap on macOS. Acceptable initial cost. +- **GNOME workaround documentation.** Users on stock GNOME won't see the option at all (probe returns false). We don't bundle or recommend the AppIndicator extension from the app itself; if we want to surface that guidance, it goes in the website/help docs, not in this PR. + +### Test plan + +Verified manually on at least one Linux (KDE Plasma), Windows 11, and macOS host: + +1. Fresh install. Click X on the window. Dialog appears with the exact text and button colors. Dialog cannot be dismissed by Esc or outside-click. +2. Click "Close the app". App exits. Reopen, click X — app exits with no dialog (preference is now `Quit`). +3. Reset preference (or fresh install). Click X, click "Minimize to tray". Window hides. Tray icon appears. +4. Send a message to yourself / receive one. Tray icon switches to the red-dot variant; tooltip updates with unread count. +5. Click tray icon (left-click). Window restores and gains focus. Unread is cleared on viewing the chat. +6. Right-click tray icon. Menu shows "Show SimpleX" and "Quit SimpleX". Both work. +7. Open Appearance settings, flip "Minimize to tray when closing window" off. Tray icon disappears. Click X — app exits with no dialog. +8. Flip the toggle back on. Tray icon appears immediately (the composable is gated on the preference, so installation/removal follows the toggle). diff --git a/plans/2026-05-09-fix-image-text-overlap.md b/plans/2026-05-09-fix-image-text-overlap.md new file mode 100644 index 0000000000..f759a454e0 --- /dev/null +++ b/plans/2026-05-09-fix-image-text-overlap.md @@ -0,0 +1,83 @@ +# Fix tall image preview overlapping caption text + +Branch: `nd/fix-image-text-overlap` · commit `0a3dcd249` · analogous to iOS PR [#6732](https://github.com/simplex-chat/simplex-chat/pull/6732). + +## 1. Problem statement + +For a tall image (height/width ≳ 2.33), the chat-bubble caption text was rendered on top of the bottom of the image instead of cleanly below it. The image looked like it had a semi-transparent text watermark across its lower section. Reproduced on Android and desktop with any sufficiently tall image; never reproduced on images with `height ≤ 2.33 × width`. + +iOS already had the analogous fix (PR #6732, commit `b24d003a8`); Android/desktop did not. + +## 2. Solution summary + +One line in `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt`. The image preview Box's `Modifier.aspectRatio(width / height)` is wrapped with `.coerceAtLeast(1f / 2.33f)` so the aspect ratio cannot go below the floor at which the layout starts to break. + +```diff +- Modifier.width(w).aspectRatio(previewBitmap.width.toFloat() / previewBitmap.height.toFloat()) ++ Modifier.width(w).aspectRatio((previewBitmap.width.toFloat() / previewBitmap.height.toFloat()).coerceAtLeast(1f / 2.33f)) +``` + +Total diff: 1 file, +1 / −1. + +## 3. Root cause + +`PriorityLayout` (`FramedItemView.kt:480`, added in #6726) caps image-region height at `constraints.maxWidth × 2.33f` and passes that as `maxHeight` to its image child: + +```kotlin +val maxImageHeight = (constraints.maxWidth * 2.33f).toInt().coerceAtMost(constraints.maxHeight) +val imageConstraints = constraints.copy(maxHeight = maxImageHeight) +val imagePlaceable = … .measure(imageConstraints) +``` + +The image child is `CIImageView`'s outer Box, modified (line 185 pre-fix) by `Modifier.width(w).aspectRatio(previewBitmap.width / previewBitmap.height)`. Compose's `aspectRatio` modifier picks a layout size satisfying both the parent's constraints AND the requested ratio — by trying `tryMaxWidth`, `tryMaxHeight`, `tryMinWidth`, `tryMinHeight` in order and returning the first satisfying `IntSize`. + +When the natural ratio is below `1/2.33`, every candidate violates one bound: + +- `tryMaxWidth`: implied `height = W / ratio > W × 2.33 = parentMaxHeight` → fails the height cap. +- `tryMaxHeight`: implied `width = parentMaxHeight × ratio < W` → fails the fixed `.width(W)` lower bound. +- `tryMinWidth` / `tryMinHeight`: same failures. + +`findSize()` returns `IntSize.Zero`, and `aspectRatio` falls through to passing the parent's UNBOUNDED-height-shape constraints down (`wrappedConstraints = constraints` — see Compose Foundation's `AspectRatioNode`). The inner `Image` (`.width(W).contentScale=FillWidth`) then sizes itself by intrinsic aspect, with `height = W × imgH/imgW`, drawing past the layout box `clipToBounds` would have given it. The text below then renders on the same vertical strip as the painter's overflow — visible as overlap. + +`PriorityLayout`'s height cap (added in #6726) prevents the original crash but does not, on its own, prevent this visual overflow, because the cap propagates through `imageConstraints` to a modifier that silently drops it. + +## 4. The fix in detail + +`coerceAtLeast(1f / 2.33f)` raises the ratio's floor to exactly the value where Compose's `tryMaxWidth` succeeds: + +- At `ratio = 1/2.33`, implied `height = W / (1/2.33) = W × 2.33 ≤ parentMaxHeight` (since `W ≤ parentMaxWidth` and `parentMaxHeight = parentMaxWidth × 2.33`). +- `findSize` returns `(W, W × 2.33)`. `wrappedConstraints` becomes `Constraints.fixed(W, W × 2.33)` — both dimensions fixed. +- Inner `Image`'s `paint` modifier sees `hasFixedDimens=true` and returns the constraints unchanged. Image bounds = `(W, W × 2.33)`. +- `Image`'s built-in `clipToBounds` clips the painter's `FillWidth` overflow to the bounded layout. `PriorityLayout` reads `imagePlaceable.measuredHeight = W × 2.33` and places the caption text immediately below. + +For images at or above the floor (ratio ≥ 1/2.33), `coerceAtLeast` is a no-op — the ratio is unchanged, the layout is unchanged, no behavioural diff. The change only takes effect for images that triggered the bug. + +`coerceAtLeast` (not `maxOf`) matches the idiom already used at `FramedItemView.kt:480` (`.coerceAtMost(constraints.maxHeight)`) — both clamp at the bound where the layout starts to break. Reads as "ensure the ratio is at least 1/2.33". + +## 5. Why this specific shape + +- **Why preserve the existing expression untouched.** The pre-fix `previewBitmap.width.toFloat() / previewBitmap.height.toFloat()` is the aspect-ratio computation. Wrapping it `(...).coerceAtLeast(1f / 2.33f)` makes the diff purely additive — a reviewer reading the patch sees "the existing ratio is now floored", with zero risk that the inner expression silently changed. Diff is one line, character-minimal. + +- **Why no `MAX_IMAGE_HEIGHT_RATIO` constant.** Two use sites of `2.33f` after the fix (`PriorityLayout` line 480 and the new clamp). `good-code-v5.md`: *"Three similar lines are better than a premature abstraction."* If a third site appears (link preview, video preview, etc.) the constant earns its place. Until then, local duplication mirrors the convention already in `PriorityLayout`. + +- **Why no edit to `CIVideoView`.** The screenshot showed only the image-preview bug. iOS PR #6732 also touched `CIVideoView` and `CILinkView`, but on Android/desktop the video preview's outer Box has no `aspectRatio` modifier at all — it is sized by its inner `VideoPreviewImageView`'s `.width(width).FillWidth`, which is paint-clamped by `imageConstraints.maxHeight` and reports a correct layout size. If a tall-video overlap is actually reported in the wild, the fix is a separate commit; speculatively replicating the iOS scope would expand the diff and review surface beyond what the bug requires. + +- **Why no `fillMaxSize + ContentScale.Crop` rewrite of the inner `Image`.** A previous iteration of this fix did that to mirror iOS's `scaledToFill` change. It works, but is structurally redundant: once the outer Box's `aspectRatio` is bounded, the inner `Image`'s existing `paint` modifier (`hasFixedDimens=true → return constraints`) and `clipToBounds` already produce the same visual result. The rewrite was extra structure, not bug-fix; reverted. + +- **Why no `PriorityLayout` constant rename.** `2.33f` is already inline at line 480 and works as-is. Extracting it to `MAX_IMAGE_HEIGHT_RATIO` would be a rename bundled with a bugfix — `good-code-v5.md`: *"a rename in a diff signals a meaningful change to the reviewer — a gratuitous rename wastes reviewer attention and can mask real changes."* Out of scope. + +- **Why `coerceAtLeast(1f / 2.33f)` and not `coerceAtLeast(0.43f)`.** The form `1f / 2.33f` makes the relationship to `PriorityLayout`'s `2.33f` height multiplier visually explicit. `0.43f` would be opaque and would drift independently if either value changed. + +## 6. Verification + +Manual sanity (Android debug APK): + +- Send a tall screenshot (height ≫ width) with a caption → caption now sits below the cropped image preview, no overlap. +- Send an image where `height ≤ 2.33 × width` → preview unchanged from pre-fix (the clamp is a no-op for these). +- Tap the cropped preview → fullscreen viewer opens the full image at native aspect (the clamp only affects the inline preview, not the viewer). + +## 7. Risk and rollback + +- **Blast radius** is the single-Box modifier in `CIImageView` for non-`smallView` mode. `smallView` (used by `ChatPreviewView` thumbnails) takes the `else Modifier` branch and is untouched. +- The clamp is a no-op for images that did not trigger the bug, so regression risk on non-tall images is zero by construction. +- Rollback: `git revert 0a3dcd249` and force-push the branch (or just drop the commit before merge). diff --git a/plans/2026-05-11-channel-owner-unlimited-delete.md b/plans/2026-05-11-channel-owner-unlimited-delete.md new file mode 100644 index 0000000000..29461f3ebd --- /dev/null +++ b/plans/2026-05-11-channel-owner-unlimited-delete.md @@ -0,0 +1,59 @@ +# Channel editorial delete from history + +## Problem + +Channel owners cannot delete content older than 24 hours. The limit makes sense in p2p groups (no authority, each member holds independent copy). In channels, the owner is the authority - their content, their right to remove it. + +## Design + +Rather than bypassing the 24-hour time limit for broadcast deletes, we add a new delete mode: "delete from history." The relay removes the message from its store but does not forward the deletion to subscribers. Subscribers who already received the message keep it - the operation cleans the relay's history, not the subscriber's device. + +This is the right separation: within 24 hours, broadcast delete reaches subscribers and removes from relays. After 24 hours, history delete cleans relays only - no retroactive rewriting of subscriber devices. + +## Changes + +### Protocol.hs + +`XMsgDel` gains `onlyHistory :: Bool` field. Defaults to `False` (backward compatible - old clients don't send it, parser defaults missing field to `False`). Encoded only when `True` via `justTrue`. + +### CIContent.hs + +New `CIDMHistory` delete mode alongside `CIDMBroadcast`, `CIDMInternal`, `CIDMInternalMark`. + +### Commands.hs + +`APIDeleteChatItem` group path: +- `CIDMHistory` - validates `publicGroupEditor` (channel + role >= GRModerator), sends `XMsgDel` with `onlyHistory = True`, no time check. Rejected for non-channels and insufficient role. +- `CIDMInternal` - rejected for channel editorial roles (they should use `CIDMHistory` instead). +- `CIDMBroadcast` - unchanged (24-hour time check applies to everyone). + +### Subscriber.hs + +Relay `processEvent`: when `XMsgDel` has `onlyHistory = True`, processes the delete locally (marks item in relay's store) but returns `Nothing` for the delivery task - no forwarding to subscribers. + +Subscriber `processForwardedMsg`: ignores the `onlyHistory` flag (wildcard match) - if a message somehow arrives, process as normal delete. + +### Types.hs + +`publicGroupEditor :: GroupInfo -> GroupMember -> Bool` - shared predicate for channel editorial role check. + +### UI (iOS + Kotlin) + +Delete dialog for channel editorial roles (owner/admin/moderator): +- First button: "Delete from history" (iOS) / "From history" (Kotlin) - always available, sends `CIDMHistory` +- Second button: "For everyone" - only within 24-hour window, sends `CIDMBroadcast` +- No "Delete for me" option for editorial roles + +Batch selection follows the same logic - "From history" replaces "Delete for me" for editorial roles. + +API error handling: `apiDeleteChatItems` and `apiDeleteMemberChatItems` now show error alerts on failure (was silently swallowed on Android). + +## Backward compatibility + +- Old relays: don't recognize `onlyHistory`, process `XMsgDel` normally (forward to subscribers). Acceptable - the delete reaches subscribers, which is more than the owner intended but not harmful. +- Old subscribers: `onlyHistory` field is ignored (not in their parser, and the relay won't forward it anyway). +- Old owners: never send `onlyHistory = True`, behavior unchanged. + +## Test + +`testChannelMessageDeleteFromHistory` - owner sends message, deletes with `history` mode, verifies relay processes locally but subscribers don't receive deletion, verifies `internal` mode is rejected for channel editorial roles. diff --git a/plans/2026-05-11-fix-call-bind-port.md b/plans/2026-05-11-fix-call-bind-port.md new file mode 100644 index 0000000000..2c1ee016da --- /dev/null +++ b/plans/2026-05-11-fix-call-bind-port.md @@ -0,0 +1,168 @@ +# Desktop call server: pick a free port when `localhost:50395` is busy + +Branch: `nd/fix-call-bind-port` · code commit `587b79779` · PR [#6963](https://github.com/simplex-chat/simplex-chat/pull/6963). + +## 1. Problem statement + +On Desktop, a WebRTC call runs in the system browser, served by an embedded `NanoWSD` HTTP+WebSocket server. `startServer()` bound that server to a hard-coded port, `SERVER_PORT = 50395` (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt:23`). If port 50395 was already in use — another instance of the app, a leftover server thread, or any unrelated process — `NanoHTTPD.start()` propagated the bind failure and the call could not start: + +``` +java.net.BindException: Address already in use: bind + at java.base/sun.nio.ch.Net.bind0(Native Method) + at java.base/sun.nio.ch.Net.bind(Unknown Source) + at java.base/sun.nio.ch.Net.bind(Unknown Source) + at java.base/sun.nio.ch.NioSocketImpl.bind(Unknown Source) + at java.base/java.net.ServerSocket.bind(Unknown Source) + at java.base/java.net.ServerSocket.bind(Unknown Source) + at org.nanohttpd.protocols.http.ServerRunnable.run(ServerRunnable.java:63) + at java.base/java.lang.Thread.run(Unknown Source) +``` + +A call should not be a single point of contention on one fixed TCP port. When 50395 is taken, the call should bind a different port and proceed. + +Scope: Desktop only. Android renders the call in an in-process `WebView` via `WebViewAssetLoader` — no local server, no port — and is unaffected. + +## 2. Solution summary + +Three changes, all in `CallView.desktop.kt`, plus a one-line spec note. Total diff: 2 files, +24 / −15. + +1. **`startServer` retries on a free port.** It gains a `port: Int = SERVER_PORT` parameter (used only by the retry; the single existing call site is unchanged by the default). `server.start()` is wrapped: on `BindException`, log a warning, stop the half-initialised server, and recurse once with `port = 0` — which makes the OS assign any free port. The recursion terminates because `port == 0` rethrows (the kernel does not hand out a busy ephemeral port). +2. **`WebRTCController` opens the browser at the port actually bound.** Previously it opened `http://localhost:50395/simplex/call/` *before* calling `startServer`; now it starts the server first and uses `server.listeningPort` for the URL — which equals `50395` in the normal case, and equals the OS-assigned port after a fallback. +3. **Spec note** in `apps/multiplatform/spec/services/calls.md` describing the fallback. + +```diff ++import java.net.BindException + + val server = remember { +- try { +- uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") +- } catch (e: Exception) { +- ... endCall() ... +- } +- startServer(onResponse) ++ startServer(onResponse).apply { ++ try { ++ uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") ++ } catch (e: Exception) { ++ ... endCall() ... ++ } ++ } + } + +-fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { +- val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { /* unchanged */ } +- server.start(60_000_000) ++fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { ++ val server = object: NanoWSD(SERVER_HOST, port) { /* unchanged */ } ++ try { ++ server.start(60_000_000) ++ } catch (e: BindException) { ++ if (port == 0) throw e ++ Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") ++ server.stop() ++ return startServer(onResponse, port = 0) ++ } + return server + } +``` + +The `NanoWSD` object body (request handling, resource serving) is untouched. + +## 3. Root cause / how NanoHTTPD binds + +`startServer()` builds an anonymous `NanoWSD(SERVER_HOST, SERVER_PORT)` and calls `server.start(60_000_000)`. Inside `NanoHTTPD.start(timeout)`: + +```java +this.myServerSocket = this.getServerSocketFactory().create(); +this.myServerSocket.setReuseAddress(true); +ServerRunnable serverRunnable = createServerRunnable(timeout); +this.myThread = new Thread(serverRunnable); +this.myThread.start(); +while (!serverRunnable.hasBinded() && serverRunnable.getBindException() == null) { Thread.sleep(10L); } +if (serverRunnable.getBindException() != null) throw serverRunnable.getBindException(); +``` + +`ServerRunnable.run()` does the actual `myServerSocket.bind(new InetSocketAddress(hostname, myPort))` on its own thread; if that throws (port in use → `java.net.BindException`, a subclass of `IOException`), it stores the exception, returns, and the accept loop is never entered. `start()` observes the stored exception and rethrows it — which is why the stack trace in the report shows `ServerRunnable.run` rather than `NanoHTTPD.start`: it is the same exception object, captured at the failed `bind`. + +`setReuseAddress(true)` already handles the benign case (a just-closed server in `TIME_WAIT`), so the only way `start()` fails this way is a genuine conflict: something else is listening on `50395`. Pre-fix that exception escaped `startServer` → escaped the `remember {}` initialiser in `WebRTCController` → the call view could not establish its control channel. + +A fixed port is also unnecessary on the wire. The browser page only ever connects back to *the origin it was served from*: `apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js` opens `new WebSocket(`ws://${location.host}`)`, and `call.html` references its assets with root-relative paths (`/desktop/style.css`, `/call.js`, …). So the page follows whatever host:port the Kotlin side opened in the browser — there is no second place that hard-codes `50395`. + +## 4. The fix in detail + +### 4.1 Retry on `port = 0` + +`NanoHTTPD`/`NanoWSD` accept port `0`, the standard "let the OS pick a free ephemeral port" convention; after `start()`, `getListeningPort()` (Kotlin: `listeningPort`) returns the concrete port the kernel assigned. So the retry needs no port-scanning loop and no arbitrary range — one fallback attempt, guaranteed to find a free port if one exists at all. + +```kotlin +fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { + val server = object: NanoWSD(SERVER_HOST, port) { /* unchanged */ } + try { + server.start(60_000_000) + } catch (e: BindException) { + if (port == 0) throw e + Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") + server.stop() + return startServer(onResponse, port = 0) + } + return server +} +``` + +- `port: Int = SERVER_PORT` — the parameter exists for the recursive retry. `startServer` has exactly one caller in the tree (`WebRTCController`), and the default keeps that call site byte-identical. A default-valued parameter used for internal recursion is a routine Kotlin idiom (`fun f(x, acc = init)`). +- `catch (e: BindException)` — deliberately narrower than `IOException`. The reported failure mode is specifically "address already in use"; any *other* `start()` failure (e.g. an I/O error creating the socket) is not something a different port fixes, so it propagates exactly as before. Surgical: handle the bug, nothing else. +- `if (port == 0) throw e` — terminates the recursion. If even the OS-assigned port fails to bind, that is a pathological condition (no ephemeral ports at all); rethrow rather than loop, preserving the original "give up" behaviour on the second failure. +- `server.stop()` — `start()` assigns `myServerSocket` (an unbound `ServerSocket`) and `myThread` (which has already exited, having caught the bind error) *before* failing. `stop()` closes that orphaned socket and joins the dead thread. Pre-fix this leak was transient (the exception terminated the call attempt); now that the call *recovers* instead of failing, the half-initialised server must be released explicitly. `stop()` is the same call the existing `onDispose` already makes on the live server. + +### 4.2 Start the server before opening the browser + +The browser URL must carry the port the server actually bound, which is only known after `start()`. So the order in `WebRTCController`'s `remember {}` is inverted: `startServer` first, then `uriHandler.openUri`. + +```kotlin +val server = remember { + startServer(onResponse).apply { + try { + uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") + } catch (e: Exception) { + Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.unable_to_open_browser_title), + text = generalGetString(MR.strings.unable_to_open_browser_desc) + ) + endCall() + } + } +} +``` + +- `.apply { … }` keeps the whole thing one memoized expression that yields the `NanoWSD` (as before), with no `val server = …; …; server` shadowing, and reads as "start the server, then (side effect) open the browser at its port". `listeningPort` resolves on the `apply` receiver. +- In the normal case `listeningPort == 50395`, so the opened URL is character-for-character what it was pre-fix — the browser keeps its per-origin permissions (camera/mic are granted to `localhost:50395`). Only a fallback changes the origin, and only for that call. +- Side benefit: pre-fix the browser was launched *before* the server's `start()` returned, so the page could (briefly) hit a not-yet-listening socket and rely on its own retry; now the server is provably listening before the browser is told about it. Strictly safer ordering. +- The error handling (alert + `endCall()`) is preserved verbatim; only its position moved. + +### 4.3 Spec note + +`apps/multiplatform/spec/services/calls.md` (the file the code links back to) gains one sentence on the NanoWSD bullet — "If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL" — and the WebRTCController bullet now reads "Starts the server, then opens `http://localhost:/simplex/call/` (normally `50395`)". + +## 5. Why this specific shape — alternatives considered + +- **Always bind `port = 0`, drop the fixed port.** Simplest possible code, no retry. Rejected: browser permissions (camera/mic, autoplay) are scoped per *origin* = `scheme://host:port`. A port that changes every call would re-prompt the user for camera/mic on every call. Keeping `50395` as the primary value preserves the granted permission; `0` is the *fallback*, used only on conflict. +- **Scan a fixed range (`50395, 50396, … 50404`).** More "predictable-ish" than an ephemeral port, but it can still be exhausted, needs a loop with an off-by-one boundary, and re-introduces the very problem (a finite set of fixed ports) in miniature. `port = 0` delegates the search to the kernel — one call, can't be exhausted while any port is free. Standard idiom; NanoHTTPD supports it directly. +- **Catch `IOException` instead of `BindException`.** Broader than the bug. A non-bind `start()` failure isn't fixed by retrying on another port; let it propagate. A narrow catch makes the diff describe exactly the failure it handles. +- **Extract the `NanoWSD` object into a local `fun newServer(port)` and `try { newServer(SERVER_PORT)… } catch { newServer(0)… }`.** Functionally equivalent, but it re-indents the ~25-line object body for no behavioural reason — a noisier diff. The default-parameter + tail-recursion form leaves the object body byte-identical and adds only the retry wrapper. +- **Move the browser-open into a `LaunchedEffect`.** Cleaner separation of "construct" vs "side effect", but it defers the launch past first composition (a behaviour change beyond the bug) and adds an effect to reason about. The pre-fix code already opened the browser inside `remember {}`; `.apply { }` keeps that timing while removing the only real wart (the open ran *before* the server existed). +- **Update every doc that mentions `localhost:50395`** (`product/flows/calling.md`, `product/glossary.md`, `product/rules.md`, `product/views/call.md`). Out of scope here: `50395` is still the primary port and those are higher-level narrative docs; only `spec/services/calls.md` (which the code references and which describes the exact mechanism) is updated. A follow-up can sweep the rest if desired. + +## 6. Verification + +- `./gradlew :common:compileKotlinDesktop` → `BUILD SUCCESSFUL` (only pre-existing deprecation warnings; nothing in the changed file). +- A full Linux x86_64 AppImage was built from this branch and launched (Compose software renderer in the test VM); the desktop app starts normally. +- Manual, normal path: starting a call opens the system browser at `http://localhost:50395/simplex/call/` exactly as before; the WebSocket connects and the call proceeds. +- Manual, fallback path: occupy the port first (e.g. `python3 -m http.server 50395`, or `nc -l 50395`) and then start a call → the log shows `Call server port 50395 is busy, using a random port: …`, the browser is opened at the OS-assigned port, the page's `ws://${location.host}` WebSocket connects to that same port, and the call proceeds. + +## 7. Risk and rollback + +- **Blast radius**: `startServer` and the `remember {}` initialiser in `WebRTCController`, Desktop only. Android (WebView, no server) is untouched; iOS is unrelated. +- The fallback branch executes only when `50395` is genuinely occupied — rare. The common path is unchanged except for the start-then-open ordering, which is strictly safer. +- Per-origin browser permissions are preserved on the common path (port unchanged); a fallback resets them for that one call — a clear improvement over the call failing outright. +- **Rollback**: `git revert 587b79779` (and drop the commit before merge if desired). No data, schema, or protocol surface is touched. diff --git a/plans/2026-05-11-link-tracking-whitelist.md b/plans/2026-05-11-link-tracking-whitelist.md new file mode 100644 index 0000000000..7bebe0ea03 --- /dev/null +++ b/plans/2026-05-11-link-tracking-whitelist.md @@ -0,0 +1,85 @@ +# "Remove link tracking" strips whitelisted query parameters (`?list=` in YouTube links, github `ref`) + +Design doc for the fix in PR #6965 (`nd/fix-list-in-link` → `master`). + +## Problem — what prompted this + +With **"remove link tracking"** enabled (Settings → Privacy & security), sending a message +with a YouTube link that has a `list` query parameter — `https://www.youtube.com/playlist?list=PL...` +or a video-in-playlist link `https://www.youtube.com/watch?v=...&list=PL...` — sent the URL +with `?list=...` removed, so the recipient got a plain (non-playlist) link instead of the +playlist. Fixing that `?list=` stripping is the immediate purpose of this change. + +iOS, Android and desktop are all affected — the URI sanitiser lives in the shared Haskell +core (`src/Simplex/Chat/Markdown.hs`). + +## Cause + +"Remove link tracking" on send uses *safe mode* of `sanitizeUri`: +`ComposeView.sanitizeMessage` → `parseSanitizeUri(_, safe = true)` → `chatParseUri 1` → +`sanitizeUri True`. + +`sanitizeUri` has three branches that pick which query parameters to keep; two of them +already consult `qsWhitelist` (the list of parameter names known *not* to be tracking — `q`, +`search`, `list`, `page`, youtube's `v`/`t`, github's `ref`, …): + +```haskell +let sanitizedQS + | safe = filter (not . isSafeBlacklisted . fst) originalQS -- ← whitelist NOT consulted + | isNamePath = case originalQS of + p@(n, _) : ps -> (if isWhitelisted n || not (isBlacklisted n) then (p :) else id) $ filter (isWhitelisted . fst) ps + [] -> [] + | otherwise = filter (isWhitelisted . fst) originalQS +... +isSafeBlacklisted p = any (`B.isPrefixOf` p) qsSafeBlacklist +qsSafeBlacklist = [ "ad", "af", ..., "li", ..., "ref", ... ] -- name *prefixes*; "li" → LinkedIn (li_fat_id, lipi, licu) +``` + +The safe-mode branch is the odd one out: it drops a parameter whenever its name *starts +with* a known tracking prefix, and never looks at `qsWhitelist`. So `list` was dropped +because `"li"` is a tracking prefix, and github's whitelisted `ref` was dropped because +`"ref"` is itself a tracking prefix — even though both are explicitly listed as non-tracking +and are kept by every other branch. + +## Fix + +Make the safe-mode branch apply the same "whitelisted *or* not blacklisted" rule the other +branches already use: + +```haskell +| safe = filter (\(n, _) -> isWhitelisted n || not (isSafeBlacklisted n)) originalQS +``` + +This *removes* a special case rather than adding one — `list` is no longer handled +differently from any other whitelisted parameter; `qsWhitelist` becomes authoritative in all +three branches. Effects relative to the previous behaviour: + +- `list` is kept everywhere (the reported `?list=` bug); +- github's `ref` is kept on `github.com` in safe mode too (it was already kept in eager + mode — it's in the whitelist for exactly that reason); +- nothing else changes: of all whitelist entries, only `list` (vs the `"li"` prefix) and + `ref` (vs the `"ref"` prefix) collide with a `qsSafeBlacklist` prefix today; +- every actual tracking parameter is still stripped — `qsWhitelist` does not contain + `li_fat_id`, `lipi`, `licu`, `utm*`, etc., and `ref` on any non-github host stays stripped. + +Regression tests added in `testSanitizeUri` (`tests/MarkdownTests.hs`): + +```haskell +it "should keep whitelisted parameters in safe mode even if they match a blacklist prefix" $ do + "https://example.com/playlist?list=abc" `sanitized` Nothing -- "list" is whitelisted, "li" is blacklisted + "https://example.com/playlist?list=abc&si=def" `sanitized` Just "https://example.com/playlist?list=abc" + "https://github.com/owner/repo?ref=main" `sanitized` Nothing -- "ref" is whitelisted for github.com +``` + +Verified: full library + test rebuild, then `cabal run simplex-chat-test -- --match /sanitizeUri/` +→ 4 examples, 0 failures (the new block plus the three pre-existing `sanitizeUri` cases). + +## Alternatives considered + +- **Special-case `list`** (`isSafeBlacklisted p = p /= "list" && …`). Smallest possible diff, + provably zero collateral, but it hard-codes one parameter name into a predicate and leaves + the structural inconsistency (safe mode ignoring the whitelist) in place — a fix by + exception rather than by rule. (This was the first version; replaced.) +- **Narrow the `"li"` blacklist entry to `"li_"`.** Fixes `list` but stops matching `lipi` + and `licu` (real LinkedIn email-link params), i.e. changes more than `list` while still + not addressing `ref` or the underlying inconsistency. diff --git a/plans/2026-05-12-link-trailing-underscore-exclamation.md b/plans/2026-05-12-link-trailing-underscore-exclamation.md new file mode 100644 index 0000000000..c92da7e8df --- /dev/null +++ b/plans/2026-05-12-link-trailing-underscore-exclamation.md @@ -0,0 +1,151 @@ +# Links: trailing `_` and `!` dropped from the highlighted link + +Design doc for the fix shipped in PR #6973. + +## Problem + +A bare URL or domain ending in `_` (or `!`) was highlighted as a link only up +to the last non-`_` character — the trailing `_` rendered as plain, +non-clickable text. For example `https://en.wikipedia.org/wiki/The_Lord_of_the_Rings_` +showed `…The_Lord_of_the_Rings` as a blue link followed by a separate, plain +`_`. Reported for `_`; the same defect applies to `!`. + +## Background — how bare links are parsed + +`parseMarkdown` (`src/Simplex/Chat/Markdown.hs`) splits a message into +fragments; a fragment that isn't a recognized markdown construct falls through +`wordP` → `wordMD`, which decides whether the "word" (the run up to the next +space) is a URI / SimpleX link / domain / email. + +To handle the very common case of a link immediately followed by sentence +punctuation — `check out https://simplex.chat.` or `(https://simplex.chat)` — +`wordMD` peels a trailing run of "punctuation" off the word and re-emits it as +`unmarked` text: + +```haskell +where + punct = T.takeWhileEnd isPunctuation' s + s' = T.dropWhileEnd isPunctuation' s + res md' = if T.null punct then md' else md' :|: unmarked punct +``` + +`isPunctuation'` is `Data.Char.isPunctuation` with exemptions for characters +that legitimately *end* a URL: `/` (trailing path separator, e.g. +`https://github.com/simplex-chat/`) and `)` (Wikipedia disambiguation, e.g. +`…/wiki/Servo_(software)`). + +All link-highlighting surfaces derive from the result of this parser: the +desktop/Android UI (`TextItemView.kt`) and iOS UI (`MsgContentView.swift`) +both call `chatParseMarkdown` (FFI into the bundled Haskell core) and style a +`Uri` / `HyperLink` fragment by its whole `text`; the compose-preview path uses +the same function; `Styled.hs` does the terminal rendering. So whatever the +parser puts inside the `Uri` fragment is exactly what gets highlighted, on +every platform. + +## Root cause + +`Data.Char.isPunctuation '_' == True` — `_` is Unicode `ConnectorPunctuation` +(`Pc`). `isPunctuation '!' == True` — `!` is `OtherPunctuation` (`Po`). Neither +was in the `isPunctuation'` exemption list, so a trailing `_` or `!` was always +stripped from the URI text. + +For `https://simplex.chat/page_name_`: + +- `punct = "_"`, `s' = "https://simplex.chat/page_name"` +- output: `Uri "https://simplex.chat/page_name" :|: unmarked "_"` + +The UI highlights the `Uri` fragment by its text, so the `_` lands outside the +blue/clickable span — exactly the reported behaviour. + +## Fix + +Add `_` and `!` to the `isPunctuation'` exemptions, alongside `/` and `)`: + +```haskell +isPunctuation' = \case + '/' -> False + ')' -> False + '_' -> False + '!' -> False + c -> isPunctuation c +``` + +`T.takeWhileEnd isPunctuation'` now stops at a trailing `_`/`!`, so the full +token is kept in `s'` and emitted as a single `Uri` fragment. Anything still +trailing it (`.`, `,`, ` …`) is peeled off as before: + +- `https://simplex.chat/page_name_` → `Uri "https://simplex.chat/page_name_"` +- `https://simplex.chat/page_name_, hello` → `Uri "…/page_name_" :|: unmarked ", hello"` +- `https://simplex.chat/page!` → `Uri "https://simplex.chat/page!"` + +## Why this is the right place + +- `wordMD`/`isPunctuation'` is the single point where bare-link text is + trimmed, and it already encodes "these characters legitimately end a link." + `_` and `!` belong in that list next to `/` and `)`. +- `_` and `!` are RFC 3986–valid URL characters (`_` is in `unreserved`, `!` is + a `sub-delim`); `_` is never sentence-ending punctuation. +- Fixing it in the parser fixes every surface at once (desktop, Android, iOS, + terminal, compose preview), because they all consume the same `FormattedText`. + A UI-layer patch would have to be repeated per platform and would leave + `Styled.hs` wrong. + +## Why a wider change is not in scope + +- The reported bug is fully resolved by the two-line addition to the exemption + `case`. Nothing more is required. +- `isPunctuation'` is shared by the URI, domain and email branches of `wordMD`. + Exempting `_`/`!` for all three is the intended behaviour, with one minor + knock-on: `user@example.com!` now renders as plain text rather than + `Email "user@example.com" :|: unmarked "!"`, because `user@example.com!` + isn't a valid email so the whole token isn't recognized. This is acceptable — + `!` is now treated consistently as part of the token everywhere — and is + preferable to splitting `isPunctuation'` into a URI predicate and an email + predicate, which adds structure for a marginal case. Phones are unaffected + (`phoneP` is a separate parser that doesn't use `isPunctuation'`). +- `good-code-v5.md` — *"Find the minimal change … the smallest structural + modification that achieves the goal."* The smallest modification that + resolves the report is two lines in the exemption `case`. + +## Backward compatibility + +Pure parsing change, no wire-format impact. `FormattedText` keeps the same +shape; only which characters fall inside a `Uri`/`Email` fragment changes. +Messages already stored keep their previously-parsed formatting — re-parsing +happens on compose / receive, not on display of stored items. An old client +receiving a message authored by a fixed client parses the raw text itself and +behaves per its own (older) rule — no incompatibility either way. + +## Verification + +`tests/MarkdownTests.hs`, `describe "text with Uri"` — four cases added: + +- `"https://simplex.chat/page_name_" <==> uri "https://simplex.chat/page_name_"` + — the trailing `_` is part of the link. +- `"https://simplex.chat/page_name_, hello" <==> uri "https://simplex.chat/page_name_" <> ", hello"` + — `_` kept, the `, hello` after it still peeled off. +- `"https://simplex.chat/page!" <==> uri "https://simplex.chat/page!"` +- `"https://simplex.chat/page!, hello" <==> uri "https://simplex.chat/page!" <> ", hello"` + +`MarkdownTests` suite: 38 examples, 0 failures. The existing exemption / peel +coverage is unchanged — `…/simplex-chat/`, `…/wiki/Servo_(software)`, +`https://simplex.chat.` → link + `.`, `https://simplex.chat, hello` → link + +`, hello`, etc. + +Manual sanity (desktop, Linux AppImage build): a message containing +`https://en.wikipedia.org/wiki/The_Lord_of_the_Rings_` highlights the whole URL +including the trailing `_`. + +## Alternatives considered and rejected + +- **Split `isPunctuation'` into a URI predicate and an email predicate** so `!` + is kept only inside URLs. Adds a second predicate and a branch solely to + preserve `Email "x@y.z" :|: unmarked "!"` on `x@y.z!` — a marginal case. The + shared predicate is simpler; rejected. +- **Strip `_`/`!` only when followed by more URL-looking text.** Requires + look-ahead the trailing-trim model doesn't have, for no real benefit — `_` + and `!` aren't sentence punctuation in the first place. +- **Extend the link span over a trailing `_` in the UI layer.** Wrong layer: + the parser is the single source of truth for `FormattedText`, consumed by + three platforms plus the terminal renderer; a UI-only patch would diverge per + platform. diff --git a/plans/2026-05-13-desktop-single-instance.md b/plans/2026-05-13-desktop-single-instance.md new file mode 100644 index 0000000000..87c5f7c9bb --- /dev/null +++ b/plans/2026-05-13-desktop-single-instance.md @@ -0,0 +1,30 @@ +# Desktop single instance - restore on duplicate launch + +## Problem + +After tray support (#6970), the desktop app can minimize to tray. The process stays alive holding the database. When the user clicks the app launcher again (forgetting about the tray), a second process starts and either crashes on the SQLite lock or runs in a degraded state. + +## Design + +Two files in `dataDir`: `simplex.started` (lock file) and `simplex.show` (signal file). + +### Startup + +1. Try `FileChannel.tryLock(0, 1, false)` on `simplex.started`. +2. **Lock acquired**: delete stale `simplex.show` if present (leftover from crash), start a daemon `WatchService` on `dataDir` for `ENTRY_CREATE`, start the app normally. +3. **Lock taken** (another process holds it): create `simplex.show`, exit. The running instance detects it and shows its window. +4. **Lock fails** (IOException, filesystem doesn't support locks, etc.): start normally but disable minimize-to-tray. Close quits the app. No worse than before tray support existed. + +### Signal handling + +While the lock is held, the daemon watcher runs for the JVM lifetime. When `simplex.show` appears it deletes the file and posts `showWindow()` to the EDT. `showWindow()` sets `windowVisible = true`, clears `ICONIFIED`, and brings the window to front — restores from tray, from taskbar-minimize, or just raises if visible-but-behind. + +Minimize-to-tray is only available when `singleInstanceLock` is held. If the lock couldn't be acquired (case 4), close always quits - preventing the scenario where two tray'd instances fight over the database. + +### Crash recovery + +The OS releases the file lock when the process dies. `simplex.show` may be left behind but is harmless - the next startup (step 2) deletes it. + +## Scope + +Linux, Windows, macOS. Per-data-directory - separate installs with different `dataDir` run independently. diff --git a/plans/2026-05-13-fix-group-link-share.md b/plans/2026-05-13-fix-group-link-share.md new file mode 100644 index 0000000000..65c9999156 --- /dev/null +++ b/plans/2026-05-13-fix-group-link-share.md @@ -0,0 +1,122 @@ +# Share Channel Link — Filter Saved Messages, Gate Plain Groups (Multiplatform) + +PR: [#6958](https://github.com/simplex-chat/simplex-chat/pull/6958) · branch `nd/fix-group-link-share` · final commit `312072a5e` + +## 1. The bug + +Two failures on the Android/Desktop "Share via chat" flow for a channel link, both ending in a server error string the user sees in a red toast: + +1. **Picking Saved Messages as the destination.** Server returns `chat commandError Failed reading: empty`. The user cannot share a channel link to their own note folder. +2. **Source group is not a channel.** The "Share via chat" button on the link-management screen (`GroupLinkView.kt`) renders for plain groups too. Tapping it produces `chat commandError not a public group`. + +Both are reachable from the multiplatform client only. iOS does not hit either: it uses a different picker filter and gates the button by `publicGroup != nil` (which already implies `useRelays`). + +## 2. Root cause + +### Bug #1 — Saved Messages is not a valid destination for this command + +`APIShareChatMsgContent` is parsed with `sendRefP` (`src/Simplex/Chat/Library/Commands.hs:5426`): + +```haskell +sendRefP = + (A.char '@' $> SRDirect <*> A.decimal) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP <*> asGroupP) +``` + +The client emits `*` for `ChatType.Local` (Saved Messages) via the standard `chatType.rawValue` prefix. `sendRefP` has no `*` branch, attoparsec returns `Failed reading: empty`, the handler never runs. + +This is the correct server behaviour. Sharing a channel link to one's own note folder is not a meaningful operation — the user can save the channel link by other means (copy from the channel-link screen). The client offered the destination by accident: the picker (`ShareListView.kt:199`) included `ChatInfo.Local` for every `SharedContent` flavour, including `SharedContent.ChatLink`. + +### Bug #2 — share button rendered for plain groups + +`GroupLinkView.kt:261` renders the share-via-chat button whenever `shareGroupInfo` is non-null: + +```kotlin +if (shareGroupInfo != null) { + SettingsActionItem(painterResource(MR.images.ic_forward), stringResource(MR.strings.share_via_chat), …) +} +``` + +Two callers pass `shareGroupInfo` — `GroupChatInfoView.kt:170` and `ChatView.kt:3207` — both pass it unconditionally as `shareGroupInfo = groupInfo`. So a plain group (`useRelays == false`) ends up with a button whose action calls `APIShareChatMsgContent` against a source the server refuses with `not a public group`. + +The sibling button in `GroupChatInfoView.kt:602` is already wrapped in `if (groupInfo.useRelays) { … if (channelLink != null) … }`. `GroupLinkView` was missing the equivalent gate. + +## 3. Approaches considered + +| # | Approach | Note | +|---|----------|------| +| A | Widen the server: add `SRLocal NoteFolderId` to `SendRef` with a parser branch; replace the `Nothing → throwCmdError "not a public group"` arm with a build-from-short-link path producing an unsigned `MCChat`. | The first commit on this branch (`7d4648b9f`). Widens the protocol surface (a new destination grammar) and the message domain (an unsigned card variant whose recipient story is unspecified), to make reachable a feature the user can achieve another way. Rejected as poor design. | +| B | **Final** — client-side, multiplatform only: filter `ChatInfo.Local` out of the picker for `SharedContent.ChatLink`; add `&& isChannel` to the button gate in `GroupLinkView`. | Two single-line changes. The failing paths become unreachable from the UI. Forward to Saved Messages, all other share flavours, iOS, and Haskell are untouched. | + +Approach B is the smaller fix and aligns the UI with the server's grammar — the picker no longer offers a destination the server refuses, and the button no longer appears where there is no channel link to share. + +## 4. Final implementation + +### 4.1 `ShareListView.kt:199` — exclude Local from the channel-link picker + +```kotlin +val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local } +``` + +One clause appended to the existing predicate: `&& !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local)`. Reads as "exclude (sharing-link AND local)". Kotlin's `is` binds tighter than `&&`, so the inner parens are only around the AND for `!` to negate. + +`chatModel.sharedContent.value` is read inside the filter lambda, once per chat. Inside `derivedStateOf`, each read registers a Compose dependency — same dependency set as a hoisted `val` would produce, and small enough (`chats.value.size`) that there is no observable cost. The hunk is +1/-1. + +The trailing `sortedByDescending { it.chatInfo is ChatInfo.Local }` is left untouched. It is a no-op when no Locals are present, and removing it would touch a line that does not need to change. + +Other `SharedContent` flavours (`Text`, `Media`, `File`, `Forward`) keep their previous behaviour. Forwarding to Saved Messages still works — the new clause is false when `sharedContent` is not `ChatLink`. + +### 4.2 `GroupLinkView.kt:261` — gate the share button by `isChannel` + +```kotlin +if (shareGroupInfo != null && isChannel) { + SettingsActionItem(painterResource(MR.images.ic_forward), stringResource(MR.strings.share_via_chat), …) +} +``` + +`isChannel` is the existing parameter of this view (declared at line 35 and 175, used for channel-specific rows throughout the file). Both callers already pass `isChannel = groupInfo.useRelays`, so the new clause is equivalent to "render only when `useRelays == true`" — matching the rule for the sibling button in `GroupChatInfoView.kt:602`. The hunk is +1/-1. + +### 4.3 What is *not* changed + +- **Haskell.** `src/Simplex/Chat/Controller.hs` and `src/Simplex/Chat/Library/Commands.hs` stay at master. `SendRef` has no `SRLocal` constructor; `APIShareChatMsgContent` still refuses non-public sources with `not a public group`; `sendRefP` has no `*` branch. The client just never sends those commands now. +- **iOS.** No file under `apps/ios/` is touched. `filterChatsToForwardTo` (`apps/ios/SimpleXChat/ChatUtils.swift:56`) still inserts `.local` at index 0 — iOS's share-channel picker uses the same function as forward, and changing it would touch the forward picker. `GroupLinkView.swift:110` already gates by `groupInfo?.groupProfile.publicGroup != nil`, which already implies `useRelays` on iOS (only channels carry a `publicGroup` profile in practice). Neither failure has been reported on iOS through this flow. +- **`GroupChatInfoView.kt`** `ShareViaChatButton`. Already wrapped in `if (groupInfo.useRelays) { … if (channelLink != null) … }` (lines 602–614). Nothing to change. +- **`ChatItemForwardingView`** equivalent on iOS, `ComposeView.kt` consumer of `SharedContent.ChatLink`, the `apiShareChatMsgContent` API surface, and every other share/forward path. The new filter clause is false outside `SharedContent.ChatLink`, so all other consumers see the same picker. + +## 5. Why this works + +The server is the source of truth for which destinations and which sources are valid for `APIShareChatMsgContent`: + +- Destinations: `@` (direct), `#` (group / scope) — defined by `sendRefP`. Local (`*`) is rejected as a parse failure, by construction. +- Sources: groups with a `publicGroup` profile and `groupLink` — defined by the `Just PublicGroupProfile {…}` arm of `APIShareChatMsgContent`. + +The client's job is to offer choices the server will accept. Bug #1 was an offer mismatch (Local in the destination list); bug #2 was an offer mismatch (button rendered on a source with no `publicGroup`). The fix narrows the client's offers to match the server's grammar — without changing the server, and without adding state that has to be kept in sync. + +Two booleans, two single-line changes. The picker filter clause is false for every `SharedContent` flavour that is not `ChatLink`, so no other share path is affected. The button gate reuses `isChannel`, the existing parameter that the rest of the file already uses for channel-vs-group dispatch. + +## 6. Behaviour changes — full inventory + +1. **Picking Saved Messages in the share-channel-link picker is no longer possible.** This is the bug fix. The destination simply isn't listed. +2. **"Share via chat" in `GroupLinkView` is hidden on plain groups.** Previously rendered but unusable; now correctly hidden. +3. **Forward picker, Media picker, File picker, Text picker — unchanged.** New filter clause is false for every non-`ChatLink` `SharedContent`. +4. **`ShareViaChatButton` in `GroupChatInfoView` — unchanged.** Already gated correctly. + +Nothing else changes. Verified by reading the diff against master line-by-line. + +## 7. Verification + +1. **Linux desktop build** succeeded end-to-end against the current branch tip (`312072a5e`), producing `SimpleX_Chat-x86_64-fix-group-link-share.AppImage` via `bash /home/user/build/linux.sh`. +2. **Diff is exactly two single-line hunks** in two files: + - `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt | 2 +-` + - `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt | 2 +-` +3. **Manual on desktop:** + - Open a public channel that is not yours → profile → "Share via chat" → picker shows direct + group destinations only, no "Saved Messages" row → pick a contact → channel-link card appears in compose. + - Open a plain group → group-link management screen → no "Share via chat" button. + - Open a channel's group-link management screen → "Share via chat" button still appears. + - Forward an existing message → picker still shows Saved Messages at the top (regression check). + +## 8. Trade-offs and follow-ups + +1. **iOS retains the bug-#1 path latent.** `filterChatsToForwardTo` inserts `.local` for the channel-link picker on iOS. The same fix as the Kotlin one — pass `includeLocal: false` from `shareChannelPicker` — is a separate, scoped change for an iOS PR. Out of scope here. +2. **`chatModel.sharedContent.value` read inside the filter lambda** evaluates per chat in the predicate, rather than hoisted to a `val` once. Diff minimality wins: the original line was one line, the new line is one line. If profiling ever showed this on a hot path (it does not — `derivedStateOf` and chat-list sizes), hoisting is a trivial follow-up. +3. **The `sortedByDescending { it.chatInfo is ChatInfo.Local }` call** remains in the channel-link path even though there are no Locals to sort. Removing it for that path only would require splitting the chain. Diff minimality: leave it. diff --git a/plans/2026-05-13-fix-privacy-links-import.md b/plans/2026-05-13-fix-privacy-links-import.md new file mode 100644 index 0000000000..2596704c24 --- /dev/null +++ b/plans/2026-05-13-fix-privacy-links-import.md @@ -0,0 +1,148 @@ +# "Remove link tracking" setting does not persist across database import + +PR: [#6977](https://github.com/simplex-chat/simplex-chat/pull/6977) · branch `nd/fix-privacy-links-import` → `master` + +## 1. Problem statement + +The **Settings → Privacy & security → Remove link tracking** toggle (`privacySanitizeLinks`) is silently dropped when a user moves their chat database to another device or reinstalls. Reproduction: + +1. Device A: enable "Remove link tracking", export chat database. +2. Device B (fresh install) or same device after re-install: import the database. +3. Open Settings → Privacy & security on B: the toggle is **off**. + +All three platforms are affected (Android, desktop, iOS) and any combination of source/target. Every other v6.5 "Safe web links" privacy guarantee survives the import; only "Remove link tracking" reverts. + +## 2. Solution summary + +The preference is stored locally only (Android `SharedPreferences`, iOS `UserDefaults` group). The cross-device transport for app settings is the `AppSettings` JSON record that travels with the database via `apiGetAppSettings` / `apiSaveAppSettings`. `privacySanitizeLinks` was absent from this record in all three layers (Haskell core, Kotlin multiplatform, Swift iOS), so it had nothing to ride on. + +Fix: add `privacySanitizeLinks :: Maybe Bool` to the `AppSettings` record in each of the three layers, wired identically to the reference field `privacyAskToApproveRelays`. Default in all three layers is `false`, matching today's local default. The fix is strictly additive (`+18` lines, 5 files, no deletions); no schema change, no command/API change, no UI change. + +## 3. Detailed tech design + +### 3.1 The round-trip the fix plugs into + +``` +Device A Device B +───────── ───────── +local pref store local pref store + ↑ ↑ importIntoApp() + │ user toggles UI │ + │ AppSettings ← apiGetAppSettings(local prepareForExport) + │ ↑ + │ archive (.zip with │ +local pref → AppSettings.current chat.db) ─────┐ + → prepareForExport │ │ + → apiSaveAppSettings │ │ + → app_settings DB row ─────────┘ │ + │ + core: combineAppSettings + stored <|> platformDefaults <|> defaults +``` + +`AppSettings.current` reads every local pref; `prepareForExport` strips fields equal to their default (space optimisation); `apiSaveAppSettings` writes the JSON into the `app_settings` table of the chat DB, which travels inside the archive. On import, the receiving client runs `apiGetAppSettings(local.prepareForExport())`; the core merges stored ⟶ local-platform ⟶ hardcoded-defaults with `Alternative` (`<|>`) and returns the result; the client's `importIntoApp` applies any non-null fields to its local store. + +A field that is **absent from `AppSettings`** at any of the three layers never enters this pipeline and is therefore lost on import. `privacySanitizeLinks` was such a field. + +### 3.2 Three-layer parity + +The three `AppSettings` definitions must agree on every field name, default value, and the four operations: + +| Operation | Haskell | Kotlin | Swift | +|---|---|---|---| +| field declaration | `data AppSettings` (`src/Simplex/Chat/AppSettings.hs:28`) | `data class AppSettings` (`SimpleXAPI.kt:8038`) | `struct AppSettings` (`AppAPITypes.swift:2118`) | +| default | `defaultAppSettings` (`AppSettings.hs:79`) | `defaults` (`SimpleXAPI.kt:8157`) | `defaults` (`AppAPITypes.swift:2188`) | +| "missing key" parse default | `defaultParseAppSettings` (`AppSettings.hs:116`) | implicit `null` | implicit `nil` | +| merge fallback | `combineAppSettings` (`AppSettings.hs:153`) | n/a (only one source) | n/a | +| JSON parser | hand-written `parseJSON` (`AppSettings.hs:207`) | `@Serializable` derived | `Codable` derived | +| read-from-local | n/a (clients send it) | `AppSettings.current` (`SimpleXAPI.kt:8193`) | `AppSettings.current` (`AppSettings.swift:71`) | +| write-to-local | n/a (clients apply it) | `importIntoApp` (`SimpleXAPI.kt:8110`) | `updateIosGroupDefaults` / `init from cfg` (`AppSettings.swift:13`) | +| serialize-only-non-default | n/a | `prepareForExport` (`SimpleXAPI.kt:8072`) | `prepareForExport` (`AppAPITypes.swift:2151`) | + +The fix adds one line to every cell that exists for `privacyAskToApproveRelays`. Default value is `false` (matches `mkBoolPreference(..., false)` and the `registerGroupDefaults` entry). + +### 3.3 Round-trip correctness — case analysis + +The core's `combineAppSettings = stored <|> platformDefaults <|> defaultAppSettings` (with `Alternative` on `Maybe`) means: take the stored value if present, else what the client said its default is, else the hardcoded default. The client's `prepareForExport` only includes a field when it differs from the client's `defaults`. With both `defaults` set to `false`: + +| Case | Archive carries | Local pref before | platformDefaults sent | Merged | Result | +|---|---|---|---|---|---| +| New archive, source had on | `Just true` | false | `Nothing` (default) | `Just true` | **on** ✓ | +| New archive, source had off (default) | `Nothing` (stripped) | false | `Nothing` | `Just false` (from defaults) | **off** ✓ | +| New archive, source had off | `Nothing` | true (local toggled) | `Just true` | `Just true` | **on** (local wins, archive silent) ✓ | +| Old archive (pre-fix) | field unknown | false | `Nothing` | `Just false` | **off** (unchanged from before fix) | +| Old archive | field unknown | true | `Just true` | `Just true` | **on** (local preserved) ✓ | +| Cross-platform | `Just true` | false | `Nothing` | `Just true` | **on** ✓ | + +The only "interesting" semantic — *archive silent on the field while local has it on* — preserves local. This matches how every other field in `AppSettings` behaves and matches user intent ("I toggled it on this device, then imported some old archive — keep it on"). + +### 3.4 Edge cases verified + +- **Downgrade then upgrade.** New code → toggle on → export. Imported on *old* code: `parseJSON` ignores unknown keys, DB row is rewritten without the field. Re-upgrade: field absent, falls through to `Just false`. This is the standard "old client drops new fields" semantics for every prior AppSettings addition; not introduced by this PR. + +- **iOS `BoolDefault` before `set` is ever called.** `apps/ios/SimpleXChat/AppGroup.swift:100` already registers `GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: false` in `registerGroupDefaults`. So `privacySanitizeLinksGroupDefault.get()` returns `false` on first read — no NaN/nil sentinel risk. + +- **JSON field ordering.** `deriveToJSON defaultJSON` uses record-field order; new field is inserted between `privacyLinkPreviews` and `privacyShowChatPreviews`, shifting subsequent keys. No external consumer compares the JSON byte-for-byte; the existing `testAppSettings` test compares `J.encode defaultAppSettings` on both sides of the wire and so is self-consistent under the addition. + +- **`omitNothingFields = True`.** The Haskell `defaultJSON` config (`Simplex.Messaging.Parsers`) strips `Nothing` fields from JSON output, so `defaultParseAppSettings` (every field `Nothing`) does not pollute archives or wire payloads when used as a fallback. + +- **iOS NSE / SE extensions.** Neither references `privacySanitizeLinks`. No additional wiring required. + +### 3.5 What was deliberately not done + +- **Flipping the *user-facing* default to `true`.** Other privacy fields in `defaultAppSettings` are `Just True` (encrypt local files, ask to approve relays). "Remove link tracking" remains `Just False` because the local pref default (`mkBoolPreference(..., false)`, iOS `registerGroupDefaults: false`) is `false`. Aligning the `AppSettings` default with the local default keeps the `prepareForExport` "differs-from-default" comparison consistent — otherwise off-by-default users would suddenly serialise `false` everywhere and on-by-default users would serialise nothing, inverting the wire shape. Whether the *product* default should be flipped to on is a separate question for a separate change. + +- **Adding `apiSaveAppSettings` on toggle.** Toggling the pref in `PrivacySettings.kt` writes only to shared prefs; the DB's `app_settings` row stays stale until a separate trigger (theme change, export, migration) syncs. The export and migration paths already call `apiSaveAppSettings(AppSettings.current.prepareForExport())` immediately before producing the archive, so every UI-initiated export captures the current value. Plugging the sync into every toggle is a broader change affecting every AppSettings field equally — out of scope. + +- **Fixing `privacyChatListOpenLinks`.** The Kotlin `AppSettings` declares it (`SimpleXAPI.kt:8046`); the Haskell record and the Swift struct do not. Same failure mode as the bug being fixed here — almost certainly does not persist across Android-to-Android imports. Out of scope; should be tracked separately. + +- **Adding a targeted test.** The existing `testAppSettings` exercises a JSON round-trip with `defaultAppSettings`, so the new field rides through implicitly. A field-specific test (`defaultAppSettings { privacySanitizeLinks = Just True }`) would tighten coverage against a future client dropping the field; recommended as a small follow-up. + +## 4. Detailed implementation plan + +### 4.1 Files touched + +| File | Δ | Purpose | +|---|---|---| +| `src/Simplex/Chat/AppSettings.hs` | +6 / 0 | record field, `defaultAppSettings`, `defaultParseAppSettings`, `combineAppSettings`, JSON parser line, record reassembly | +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +5 / 0 | data class field, `prepareForExport`, `importIntoApp`, `defaults`, `current` | +| `apps/ios/Shared/Model/AppAPITypes.swift` | +3 / 0 | struct field, `prepareForExport`, `defaults` | +| `apps/ios/SimpleXChat/AppGroup.swift` | +2 / 0 | new `privacySanitizeLinksGroupDefault: BoolDefault` next to existing privacy defaults | +| `apps/ios/Shared/Views/UserSettings/AppSettings.swift` | +2 / 0 | import side (`set`), export side (`get`) | + +Total: 5 files, +18 / 0. No deletions. + +### 4.2 Step-by-step (commit `15457a903`) + +1. **`AppSettings.hs`** — add `privacySanitizeLinks :: Maybe Bool` to the record (between `privacyLinkPreviews` and `privacyShowChatPreviews`); set `Just False` in `defaultAppSettings`; `Nothing` in `defaultParseAppSettings`; `p privacySanitizeLinks` in `combineAppSettings`; `privacySanitizeLinks <- p "privacySanitizeLinks"` in `parseJSON`; add to record reassembly. Field position consistent with name groupings. + +2. **`SimpleXAPI.kt`** — same insertions in `data class AppSettings`, `prepareForExport`, `importIntoApp`, `defaults`, `current`. Local pref already exists (`SimpleXAPI.kt:126`). + +3. **`AppAPITypes.swift`** — same insertions in `struct AppSettings`, `prepareForExport`, `defaults`. + +4. **`AppGroup.swift`** — add `public let privacySanitizeLinksGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS)`. The key constant (line 31) and registered default `false` (line 100) already exist; only the typed wrapper for non-`@AppStorage` access was missing. + +5. **`AppSettings.swift` (iOS view extension)** — import side: `if let val = privacySanitizeLinks { privacySanitizeLinksGroupDefault.set(val) }`. Export side: `c.privacySanitizeLinks = privacySanitizeLinksGroupDefault.get()`. + +### 4.3 Verification + +- Haskell `testAppSettings` (`tests/ChatTests/Direct.hs:2768`) covers the JSON round-trip through `defaultAppSettings`; the new field flows through both sides of the equality, so existing assertions hold. +- Manual test plan (in PR description): + 1. Enable on Android, export DB, import on a second Android device — toggle stays on. + 2. Enable on iOS, export, import on a second iOS device — toggle stays on. + 3. Enable on desktop, export, fresh-install + import — toggle stays on. + 4. Cross-platform: export from Android, import on iOS, and vice versa — toggle preserved. + 5. Fresh install with no archive — toggle defaults to off (unchanged). + +### 4.4 Risk and rollback + +- **Blast radius**: the `AppSettings` JSON payload. Every other field is untouched (positional inserts, no reordering of existing fields beyond the natural shift). +- **Backwards compatibility**: old clients (no field) parsing new JSON ignore the key. New clients (with field) parsing old JSON see `Nothing`, fall through to `defaultAppSettings` and the local pref is set to its default. Either direction is safe. +- **Rollback**: `git revert 15457a903`. Restores pre-fix behaviour (the field-loss bug returns). + +## 5. Why this specific shape + +- The bug has exactly one cause: a missing field in the round-trip payload. The smallest fix is to add the field. Anything larger (e.g. broadening `importIntoApp` to scan all shared prefs, or pinning the value in a side channel) would be a structural change that does not improve correctness. +- The `<|>` merge in `combineAppSettings` already gives the right behaviour for every edge case (archive-silent local-set, fresh install, downgrade) once the field exists. No new merge logic needed. +- The default `false` is forced: any other choice would either contradict the local pref default (`mkBoolPreference(..., false)`, iOS `registerGroupDefaults: false`) or invert the wire shape of `prepareForExport`. +- Final PR is 5 files, +18 / 0. Three of those files are the three `AppSettings` records; the other two are the iOS wiring the new field needs in order to read and write its group default. No other file in the codebase needed touching. diff --git a/plans/2026-05-13-relay-refuse-rejoin.md b/plans/2026-05-13-relay-refuse-rejoin.md new file mode 100644 index 0000000000..e33a525c03 --- /dev/null +++ b/plans/2026-05-13-relay-refuse-rejoin.md @@ -0,0 +1,347 @@ +Plan rewritten for conciseness with fresh-context re-evaluation; supersedes earlier revisions. + +# Plan: relay refuses to rejoin a channel it left + +## 1. Identifier + +Gating key: `GroupRelayInvitation.groupLink :: ShortLinkContact` (Types.hs:884-889). Available at `xGrpRelayInv` (Subscriber.hs:1524-1528) before any DB write or network call. The relay already stores this value on every `groups` row it processes (column `relay_request_group_link`, M20260222:38), and the existing `relay_own_status` column already carries the relay's lifecycle for the channel — refusal slots into that state machine as a new `RSRejected` variant. Lookup is a single SELECT against `groups`. Link rotation by the owner bypasses refusal; `publicGroupId` (Types.hs:790) would resist that but is only known after `getShortLinkConnReq'` — defer that gating to a follow-up. + +## 2. Storage + +No new column, no new type, no new field on `GroupInfo`. The existing `relay_own_status TEXT` (M20260222:37) is the carrier. + +`RelayStatus` (`src/Simplex/Chat/Types/Shared.hs:81-114`) gains an `RSRejected` constructor (encoded as `"rejected"`). It is reused on both sides: on the relay it is the row's own state after `APILeaveGroup`; on the owner it is the `GroupRelay.relayStatus` after `XGrpRelayReject` arrives in §5. + +State-machine slot for `RSRejected` on the relay: + +- `updateRelayOwnStatus_` (Store/Groups.hs:1593-1597) writes `relay_inactive_at = Just currentTs` only when the new status is `RSInactive`. `RSRejected` therefore correctly leaves `relay_inactive_at = NULL`, so the row is NOT eligible for `checkRelayInactiveGroups` cleanup (Commands.hs:4812-4817). +- `checkRelayServedGroups` (Commands.hs:4795-4810) iterates only `getRelayServedGroups` rows — `relay_own_status IN (RSAccepted, RSActive)` (Store/Groups.hs:1607). RSRejected rows are not iterated. +- `xGrpMemDel` writer at Subscriber.hs:3132 currently flips any non-NULL `relay_own_status` to `RSInactive` when the owner removes the relay member. That would silently regress `RSRejected → RSInactive` and let a subsequent `XGrpRelayInv` slip through (the lookup checks `'rejected'`). The write at line 3132 is tightened to skip when the row is already `RSRejected`: + +```haskell +when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ + updateRelayOwnStatus_ db gInfo RSInactive +``` + +New migration `M20260514_relay_request_group_link_index` adds a partial index — the column is unindexed today and the new gate SELECTs on it. SQLite: + +```sql +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +``` + +Postgres mirror. Partial-on-`IS NOT NULL` because most rows on owner-only or p2p installs leave the column NULL. Both engines support partial indexes. Down: `DROP INDEX idx_groups_relay_request_group_link`. + +One helper, added next to the existing `relay_*` helpers in `src/Simplex/Chat/Store/Groups.hs`: + +```haskell +isRelayGroupRefused :: DB.Connection -> User -> ShortLinkContact -> IO Bool +isRelayGroupRefused db User {userId} groupLink = + fromOnly . head <$> DB.query db + [sql| + SELECT EXISTS ( + SELECT 1 FROM groups + WHERE user_id = ? + AND relay_request_group_link = ? + AND relay_own_status = ? + LIMIT 1 + ) + |] + (userId, groupLink, RSRejected) +``` + +`EXISTS … LIMIT 1` because more than one `groups` row may share `relay_request_group_link` (`createRelayRequestGroup` at Store/Groups.hs:1526 INSERTs unconditionally). If any matching row has `relay_own_status = 'rejected'`, the channel is refused. The equality check naturally excludes other states (NULL, RSInvited, RSAccepted, RSActive, RSInactive). + +All other operator-allow and leave writes reuse existing helpers `updateRelayOwnStatus_` and `updateRelayOwnStatusFromTo` (Store/Groups.hs:1587-1597). No new write helpers. + +## 3. Rejection point — `xGrpRelayInv` (Subscriber.hs:1524) + +```haskell +xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () +xGrpRelayInv invId chatVRange groupRelayInv@GroupRelayInvitation {groupLink} = do + refused <- withStore' $ \db -> isRelayGroupRefused db user groupLink + if refused + then sendRelayRejection `catchAllErrors` eToView + else do + initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config + (_gInfo, _ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay + lift $ void $ getRelayRequestWorker True + where + sendRelayRejection = do + let pqSup = PQSupportOff + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` chatVRange + connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) False invId pqSup + dm <- encodeConnInfoPQ pqSup chatV XGrpRelayReject + void $ withAgent $ \a -> + acceptContact a NRMBackground (aUserId user) connId False invId dm pqSup subMode + deleteAgentConnectionAsync' connId False +``` + +**Why synchronous `acceptContact` (not `acceptContactAsync`).** `acceptContactAsync` enqueues a JOIN agent command; the CONF send and the snd-queue creation happen later inside the agent's command worker (Agent.hs:1826-1830). If we immediately call `deleteAgentConnectionAsync' acId True`, `setConnDeleted` runs, `prepareDeleteConnections_` finds zero rcv queues (no JOIN yet), `deleteConn db (Just timeout) connId` finds zero `snd_message_deliveries` and calls `deleteConnRecord`. The connection record is gone before the JOIN worker can send the CONF — the rejection signal is silently dropped. + +`acceptContact` (Internal.hs:881-912 precedent; Agent.hs:1437-1442 → `joinConn` 1263 → `joinConnSrv` 1358-1369 for CRContactUri → `sendInvitation` Agent/Client.hs:1796-1799 → `sendOrProxySMPMessage` 1084-1094 → `sendSMPMessage`/`proxySMPMessage`) hands the CONF to the SMP server via a direct SMP client call. The CONF does NOT go through `snd_message_deliveries` — it is transmitted inline. Subsequent `deleteAgentConnectionAsync' connId False` is therefore safe. The cost is one SMP round-trip blocking the receive loop, which the refusal path can absorb. + +No chat-layer `Connection` row is persisted for the refused contact — the agent owns the connection state, and `deleteAgentConnectionAsync'` cleans it up. + +If `sendInvitation` throws (SMP server unreachable), `acceptContact` throws before reaching its internal `acceptInvitation` step and the agent-allocated rcv queue from `newRcvConnSrv` is left for the agent's eventual cleanup. The owner receives no rejection and falls back to the silent-degradation path (GroupRelay stuck at `RSInvited`). The outer `catchAllErrors eToView` prevents the receive loop from being held by the bubbled-up exception. + +## 4. Wire format — `XGrpRelayReject` + +Empty-payload event, owner-relay direct contact channel only. Not group-signed. Naming matches the existing `XGrpLinkReject` precedent (Protocol.hs:440, tag:985, string:1043). + +`src/Simplex/Chat/Protocol.hs`: + +- GADT constructor (after `XGrpRelayNew`, line 446): `XGrpRelayReject :: ChatMsgEvent 'Json` +- Tag GADT (after `XGrpRelayNew_`, line 991): `XGrpRelayReject_ :: CMEventTag 'Json` +- `strEncode` (line 1049): `XGrpRelayReject_ -> "x.grp.relay.reject"` +- `strDecode` (line 1108): `"x.grp.relay.reject" -> XGrpRelayReject_` +- `toCMEventTag` (line 1163): `XGrpRelayReject -> XGrpRelayReject_` +- JSON parse (line 1321): `XGrpRelayReject_ -> pure XGrpRelayReject` +- JSON encode (line 1391): `XGrpRelayReject -> JM.empty` — matches `XGrpLeave -> JM.empty` (1402) and `XDirectDel -> JM.empty` (1379). +- **No** entry in `isForwardedGroupMsg` (485-505) or `requiresSignature` (1227-1238). + +Older owner clients parse the unknown tag as `XUnknown` (default branch at 1134) and hit the CONF handler's catch-all `_ -> messageError "CONF from invited member must have x.grp.acpt"`. No state change, no crash; the GroupRelay stays at `RSInvited` — the same end state as today's "relay never responds" mode. The owner UI shows the relay as permanently "invited" with no progress; documented degradation. + +`docs/protocol/channels-protocol.md`: insert a `### Relay refusal` subsection between `### Relay addition` (61-73) and `### Subscriber connection` (75). Paragraphs: + +1. **Trigger** — relay's `APILeaveGroup` sets `relay_own_status = 'rejected'` on the relay's local `groups` row for the channel. +2. **Signal** — empty-payload `x.grp.relay.reject` over the owner-relay direct contact channel. +3. **Owner handling** — `GroupRelay` transitions `RSInvited → RSRejected`; final. Cleared only by the relay operator running `/group allow `. +4. **Limitations** — (a) older owner clients log a CONF parse error and leave their `GroupRelay` at `RSInvited` indefinitely (same UX as a relay that doesn't respond); (b) older relay binaries do not enforce refusal — mixed-version deployments where some relays are old behave asymmetrically. + +## 5. Owner-side state + +`RelayStatus` gains `RSRejected` (§2). Add to `relayStatusText`, `textEncode`, `textDecode`. + +CONF handler arm in `src/Simplex/Chat/Library/Subscriber.hs:760-773` (immediately after the existing `XGrpRelayAcpt` clause): + +```haskell +XGrpRelayReject + | memberRole' membership == GROwner && isRelay m -> do + relay <- withStore $ \db -> do + liftIO $ updateGroupMemberStatus db userId m GSMemRejected + relay <- getGroupRelayByGMId db (groupMemberId' m) + liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected + let m' = m {memberStatus = GSMemRejected} + deleteMemberConnection m' + toView $ CEvtGroupRelayUpdated user gInfo m' relay + | otherwise -> messageError "x.grp.relay.reject: only owner can receive relay rejection" +``` + +`getGroupRelayByGMId` (Store/Groups.hs:1307) and `updateRelayStatusFromTo` (1438-1442) are already exported. `updateRelayStatusFromTo` is conditional on the current status equalling `RSInvited` — racing CONFs cannot regress an already-rejected or already-active row. `deleteMemberConnection` (Internal.hs:1807-1808) safely no-ops when `activeConn` is `Nothing`. `CEvtGroupRelayUpdated` (Controller.hs:900) carries exactly the iOS payload. + +`addRelays` (Commands.hs:3942-3976) persists `GroupRelay` with `RSNew → RSInvited` before sending `XGrpRelayInv`, so the row exists when the CONF arrives. A second user-initiated `addRelays` after rejection creates a fresh row, independent of the rejected one — no automatic retry. + +## 6. Refusal write — `APILeaveGroup` (Commands.hs:2919-2935) + +Currently `leaveChannelRelay` does NOT touch `relay_own_status` — verified at Commands.hs:2938-2947. The new write is added to the existing leave path, unconditionally on the relay-leave branch: + +```haskell +APILeaveGroup groupId -> withUser $ \user@User {userId} -> do + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user groupId + filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo + withGroupLock "leaveGroup" groupId $ do + cancelFilesInProgress user filesInfo + msg <- if useRelays' gInfo && isRelay membership + then leaveChannelRelay gInfo + else leaveGroupSendMsg user gInfo + (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo + ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] + deleteGroupLinkIfExists user gInfo' + withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft + -- NEW: mark the relay's local groups row as refused + when (useRelays' gInfo && isRelay membership) $ + withFastStore' $ \db -> updateRelayOwnStatus_ db gInfo RSRejected + pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} +``` + +`updateRelayOwnStatus_` (Store/Groups.hs:1593) writes unconditionally. The prior status can legitimately be any of `RSInvited` (operator leaves mid-request, placeholder profile still in place — verified at Store/Groups.hs:1531-1541), `RSAccepted` (waiting for health-check), `RSActive` (steady state), or `RSInactive` (already inactive — re-leaving). The earlier rev's `publicGroup == Nothing` throw was wrong: `RSInvited` is a real lifecycle state with `publicGroup = Nothing` (`createRelayRequestGroup` at Store/Groups.hs:1531 uses a placeholder profile until `updateGroupProfile` at Subscriber.hs:3847 runs inside the relay-request worker). Writing `RSRejected` unconditionally on the relay-leave path correctly cancels an in-progress invitation: `getNextPendingRelayRequest` (Store/RelayRequests.hs:60-72) selects only rows where `relay_own_status = 'invited'`, so the flip to `RSRejected` removes the row from the worker queue. + +## 7. Operator command — relay side + +One API command. Operator discovers rejected channels through `/gs` (see §7.2). + +`src/Simplex/Chat/Controller.hs` (after `APITestChatRelay` at ~408): + +```haskell +| APIAllowRelayGroup {groupId :: GroupId} +-- response (after CRGroupRelays at ~737): +| CRRelayGroupAllowed {user :: User, groupInfo :: GroupInfo} +``` + +Parser entries (`src/Simplex/Chat/Library/Commands.hs:5033+`). `GroupId = Int64` is a type alias (Types.hs:449), so `A.decimal` decodes directly — matches `APILeaveGroup <$> A.decimal` at 5021: + +```haskell +"/_relay allow " *> (APIAllowRelayGroup <$> A.decimal), +"/group allow " *> (APIAllowRelayGroup <$> A.decimal), +``` + +Handler: + +```haskell +APIAllowRelayGroup groupId -> withUser $ \user -> do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo' <- withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSRejected RSInactive + pure $ CRRelayGroupAllowed user gInfo' +``` + +`updateRelayOwnStatusFromTo` (Store/Groups.hs:1587-1591) atomically transitions only if the current status equals the from-state — a non-rejected row stays unchanged and the response reports the unchanged `gInfo`. The transition to `RSInactive` writes `relay_inactive_at = currentTs` via `updateRelayOwnStatus_` (1593-1597), so the row becomes eligible for `checkRelayInactiveGroups` connection cleanup on TTL — the correct hygiene state for a previously-rejected, now-cleared row. + +No event to the owner. The owner's next user-initiated `addRelays` succeeds normally (the relay's `xGrpRelayInv` finds no `'rejected'` row for the link). Operator authorization is the chat-relay binary's process-level access. + +### 7.1 Guard against deleting a rejected group + +`APIDeleteChat CTGroup` at Commands.hs:1242-1246 lets the operator delete the group once `memberCurrent membership` is false (post-leave). That path would silently clear the refusal — an accidental `/d` should not undo a moderation decision. Add a guard immediately after the existing `unless canDelete` check: + +```haskell +when (relayOwnStatus gInfo == Just RSRejected) $ + throwChatError $ CECommandError "cannot delete a rejected channel; run /_relay allow first" +``` + +`checkRelayInactiveGroups` (Commands.hs:4812-4817) only deletes connections via `deleteGroupConnections`, not group rows, so no guard is needed there. + +### 7.2 Surface `[rejected]` in `/gs` + +`viewGroupsList` in `src/Simplex/Chat/View.hs:1432-1459`. Extend `groupSS`'s destructure to pull `relayOwnStatus` while keeping the existing `GroupSummary {currentMembers}` pattern (used at line 1456 by `memberCount`), and append `[rejected]` between status and alias: + +```haskell +groupSS g@GroupInfo { membership + , chatSettings = ChatSettings {enableNtfs} + , groupSummary = GroupSummary {currentMembers} + , relayOwnStatus + } = + case memberStatus membership of + GSMemInvited -> groupInvitation' g + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> rejectionSuffix <> alias g + where + rejectionSuffix = case relayOwnStatus of + Just RSRejected -> " [rejected]" + _ -> "" + … +``` + +## 8. iOS + +No iOS storage-side change. The owner-side `RSRejected` rendering is the same as the rev-4 plan. + +`apps/ios/SimpleXChat/ChatTypes.swift:2637-2643 + 2708-2718`: + +```swift +public enum RelayStatus: String, Decodable, Equatable, Hashable { + … + case rsRejected = "rejected" +} +extension RelayStatus { public var text: LocalizedStringKey { + switch self { … case .rsRejected: "rejected" } +}} +``` + +`apps/ios/Shared/Views/NewChat/AddChannelView.swift:487-504` (`relayStatusIndicator`): + +```swift +let isRejected = status == .rsRejected +let color: Color = + connFailed || removed || isRejected ? .red + : (status == .rsActive ? .green : .yellow) +let text: LocalizedStringKey = + connFailed ? "failed" + : memberStatus == .memLeft ? "removed by operator" + : isRejected ? "rejected" + : removed ? "removed" + : status.text +``` + +`apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift`, inside the existing `Section` after the `Relay address` block at line 195: + +```swift +if groupRelay?.relayStatus == .rsRejected { + infoRow("Status", "rejected by relay operator") +} +``` + +`ChannelRelaysView.swift` requires no change — the existing fall-through in `ownerRelayStatusText` (line 114-127) to `groupRelays.first(…)?.relayStatus.text` already renders `"rejected"`. + +`GroupMemberStatus.memRejected` already exists at ChatTypes.swift:3002. No iOS enum change; cited here so an iOS-only reviewer doesn't drop the case. + +Per `apps/ios/CODE.md` Change Protocol, the implementer updates `apps/ios/spec/state.md`, `apps/ios/spec/api.md`, `apps/ios/spec/client/chat-view.md`, `apps/ios/product/views/group-info.md`, `apps/ios/spec/impact.md`, and `apps/ios/product/concepts.md`. + +Kotlin/Android/desktop port is a separate PR. + +## 9. Tests — `tests/ChatTests/RelayRefused.hs` + +All tests use the existing channel harness and block on chat events, not `threadDelay`. + +- **`testRelayRefuseAfterLeave`** — relay1 leaves; owner re-adds; owner blocks on `CEvtGroupRelayUpdated`; assert owner `relayStatus == RSRejected`, member `GSMemRejected`, channel link data excludes relay1. Also assert relay's `groups.relay_own_status = 'rejected'`. Deterministic delivery check for the sync-accept-then-delete path. +- **`testRelayAllowAcceptsAgain`** — operator runs `/group allow `; relay's `groups.relay_own_status` becomes `'inactive'`; owner re-adds; relay reaches `RSActive` on a fresh `GroupRelay` row. +- **`testRelayDoesNotRefuseUnrelatedChannel`** — relay1 leaves channel A; owner of unrelated channel B issues `XGrpRelayInv`; relay1 accepts B; only A's `groups` row has `relay_own_status = 'rejected'`. +- **`testRelayRefuseRaceConcurrentInvitations`** — owner sends two `XGrpRelayInv` for the same channel concurrently after the relay has left; both refuse; relay's `groups` table acquires no placeholder row for the second invitation (both lookups match the same rejected row). +- **`testRelayForwardCompatOldOwner`** — owner's `chatVersionRange` excludes `x.grp.relay.reject`; relay refuses; owner emits `messageError` and the GroupRelay row stays at `RSInvited`; no crash. +- **`testRelayDeleteRejectedBlocked`** — relay1 leaves channel A; operator runs `/d #A`; deletion fails with the guard error from §7.1; channel still exists; operator runs `/group allow ` then `/d #A`; deletion succeeds. +- **`testRelayRejectSurvivesOwnerRemoveRelayMember`** — relay1 leaves channel A (sets `RSRejected`); owner sends `XGrpMemDel` removing relay1; assert relay's `groups.relay_own_status` is still `'rejected'`, not flipped to `'inactive'`. Covers the §2 tightening of `xGrpMemDel` at Subscriber.hs:3132. +- **`testNonOwnerXGrpRelayRejectIgnored`** — owner-side negative case: deliver an `XGrpRelayReject` CONF on a connection where either `memberRole' membership /= GROwner` or the sender member is not `isRelay`; assert the owner emits `messageError` and neither the GroupRelay row nor the member status changes. + +## 10. Adversarial review + +- **Existing `RSInactive` consumers.** Three call sites filter on `Just RSInactive` to mean "relay not serving — drop normal delivery": + - Subscriber.hs:936 (`MSG` handler filters delivery tasks). + - Subscriber.hs:3571 (delivery-task worker rejects `DJDeliveryJob`). + - Subscriber.hs:3641 (delivery-job worker errors `DJDeliveryJob`). + All three must broaden to also match `Just RSRejected` — both states share the "not serving" semantic. `DJRelayRemoved` is handled in a separate branch and remains status-independent. Add a small predicate (e.g., `relayNotServing :: Maybe RelayStatus -> Bool`) near the existing `relayOwnStatus` accessors. +- **`xGrpMemDel` writer at Subscriber.hs:3132** — this is also a writer of `relay_own_status`, not a filter. It flips any non-NULL status to `RSInactive` when the owner removes the relay member. Tightened in §2 to skip when the row is already `RSRejected`; otherwise the refusal would be silently undone by a normal protocol event. +- **Health-check loop never touches RSRejected.** `getRelayServedGroups` filters `relay_own_status IN (RSAccepted, RSActive)` (Store/Groups.hs:1607); RSRejected rows are not iterated. `updateRelayOwnStatusFromTo` calls in Commands.hs:4808-4809 only transition RSAccepted↔RSActive↔RSInactive. With the §2 tightening of `xGrpMemDel` line 3132, no path can silently undo a refusal. +- **Operator deletes a rejected group** — blocked at `APIDeleteChat CTGroup` per §7.1. +- **Timing side channel** — refusal path is one synchronous SMP round-trip; accepted path is much longer. Passive SMP-server observation can distinguish, though relay load adds variance to both paths. SMP server already infers relay-channel relationships from connection patterns; marginal additional leak. +- **Information leakage in `XGrpRelayReject`** — empty payload. +- **Concurrent leave-then-rejoin** — operator-facing contract: invitations arriving before the leave commits locally are processed normally; invitations after are refused. Note that `xGrpRelayInv` does NOT take `withGroupLock "leaveGroup" groupId` (no group ID is known at REQ time); the bound is the SQL commit of the `relay_own_status = 'rejected'` write, not a lock. Sibling rows already at `RSInvited` from before the leave are not retroactively rejected — they are processed normally by the worker. See §12 for follow-up scope. +- **Two concurrent `XGrpRelayInv` for the same rejected channel** — both lookups hit the same indexed row, both refuse. No race. +- **Duplicate `groups` rows for the same `relay_request_group_link`** — pre-existing (`createRelayRequestGroup` INSERTs unconditionally; no uniqueness on `relay_request_inv_id` or `relay_request_group_link`). Any `RSRejected` row blocks *future invitations from creating new rows that progress to acceptance* (the lookup uses `EXISTS … LIMIT 1`). Sibling rows already in `RSInvited` continue to be processed by the worker — see §12. +- **Operator-allow vs. concurrent invitation** — UPDATE-SELECT race resolves to either "still refused" or "slipped through with accept"; both match operator intent. +- **`getGroupRelayByGMId` failure on owner side** — propagates as `ChatErrorStore`; cannot happen in normal operation. +- **Multi-user relay binary** — `groups.user_id` scopes both lookup and write. `withUser` for the CLI. No cross-user pollution. +- **`sendRelayRejection` SMP failure** — wrapped in `catchAllErrors eToView` per §3 so a single SMP failure during refusal does not propagate to the agent receive loop. The owner falls back to silent-degradation (GroupRelay stuck at RSInvited), matching today's "relay unresponsive" mode. +- **Forward compat — mixed-version relays.** An old relay binary leaves a channel by writing `RSInactive`, not `RSRejected`, and does not enforce refusal at `xGrpRelayInv`. Mixed-version deployments (some relays new, some old) have asymmetric behavior: new relays refuse, old relays accept. Acceptable v1 limitation; document in `docs/protocol/channels-protocol.md`. Operator on an upgraded relay can `/leave` again under the new binary to re-establish refusal. +- **Forward compat (old owner)** — old owner's CONF handler lands in the `_ -> messageError "CONF from invited member must have x.grp.acpt"` catch-all (Subscriber.hs:773). GroupRelay stays at `RSInvited`; same end state as today's "relay never responds" mode. Documented in the protocol doc. + +## 11. Files changed + +| File | Change | +|---|---| +| `src/Simplex/Chat/Types/Shared.hs` | `RSRejected` variant + text encodings | +| `src/Simplex/Chat/Protocol.hs` | `XGrpRelayReject` constructor, tag, str enc/dec, JSON enc/dec | +| `src/Simplex/Chat/Store/Groups.hs` | `isRelayGroupRefused` helper | +| `src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs` | NEW. Partial index | +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | Register migration | +| `src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs` | NEW | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | Register migration | +| `src/Simplex/Chat/Controller.hs` | `APIAllowRelayGroup` command; `CRRelayGroupAllowed` response | +| `src/Simplex/Chat/Library/Commands.hs` | Parser; handler; refusal write in `APILeaveGroup`; delete guard in `APIDeleteChat CTGroup` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Gate in `xGrpRelayInv`; `XGrpRelayReject` arm in CONF handler; broaden three RSInactive filters to also match RSRejected (lines 936, 3571, 3641); tighten `xGrpMemDel` writer at 3132 to skip when row is `RSRejected` | +| `src/Simplex/Chat/View.hs` | `[rejected]` suffix in `viewGroupsList` | +| `simplex-chat.cabal` | Register new migration modules | +| `docs/protocol/channels-protocol.md` | Insert "Relay refusal" subsection | +| `apps/ios/SimpleXChat/ChatTypes.swift` | `rsRejected` case + text | +| `apps/ios/Shared/Views/NewChat/AddChannelView.swift` | Red dot + "rejected" in `relayStatusIndicator` | +| `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift` | "Status: rejected by relay operator" row | +| `tests/ChatTests/RelayRefused.hs` | NEW. Eight tests | +| Test list registration | Add the new module | + +`chat_schema.sql` is auto-regenerated by tests. + +## 12. Out of scope + +- Kotlin/Android/desktop UI port. +- New alerts, modals, banners, compose-bar changes. +- Refusal triggered by `xGrpMemDel` (owner removing relay). +- Pre-emptive blocking of unseen channels. +- Owner-side independent clear of `RSRejected`. +- `publicGroupId`-keyed refusal. +- Timing-uniform refusal. +- **Sibling-row worker race.** When a relay leaves a channel for which it has a sibling `groups` row in `RSInvited` (e.g., the owner re-sent `XGrpRelayInv` and `createRelayRequestGroup` created a second row), only the row whose ID `APILeaveGroup` targets is flipped to `RSRejected`; sibling `RSInvited` rows continue through the worker. Pre-existing behavior — `leaveChannelRelay` doesn't touch sibling rows today either. Cheapest future closure: in §6, also `UPDATE groups SET relay_request_failed = 1 WHERE user_id = ? AND relay_request_group_link = ? AND relay_own_status = 'invited'` in the same transaction (the worker filters on `relay_request_failed = 0` at Store/RelayRequests.hs:67). Deferred to a follow-up. +- **`XGrpRelayInv` re-delivery duplicates.** `createRelayRequestGroup` has no uniqueness on `relay_request_inv_id` or `relay_request_group_link`; an owner retry of `XGrpRelayInv` creates duplicate rows. Pre-existing; closure ties to the sibling-row item above. + +The mixed-version-relay asymmetry and the old-owner stuck-RSInvited UI degradation are documented in `docs/protocol/channels-protocol.md` alongside the new `### Relay refusal` subsection. diff --git a/plans/2026-05-14-fix-group-link-share-ios.md b/plans/2026-05-14-fix-group-link-share-ios.md new file mode 100644 index 0000000000..ba22b04dd6 --- /dev/null +++ b/plans/2026-05-14-fix-group-link-share-ios.md @@ -0,0 +1,141 @@ +# Share Channel Link — Filter Saved Messages (iOS) + +Companion to [#6958](https://github.com/simplex-chat/simplex-chat/pull/6958) (`nd/fix-group-link-share`, Android/Desktop). +Branch `nd/fix-group-link-share-ios`, base `master`. + +## 1. The bug + +On the iOS channel-link "Share via chat" picker, **Saved Messages** is offered as a destination. Tapping it produces `chat commandError Failed reading: empty` from the server. + +This is the iOS counterpart of bug #1 from PR #6958. Bug #2 from that PR (the "Share via chat" button rendering on plain groups) does **not** exist on iOS — `GroupLinkView.swift:110` already gates the button with `if groupInfo?.groupProfile.publicGroup != nil`, and plain groups have `publicGroup == nil`. + +## 2. Root cause + +`APIShareChatMsgContent` is parsed with `sendRefP` in `src/Simplex/Chat/Library/Commands.hs:5426`: + +```haskell +sendRefP = + (A.char '@' $> SRDirect <*> A.decimal) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP <*> asGroupP) +``` + +The iOS client emits `*` for `ChatType.local` (Saved Messages) via the standard chat-type prefix. `sendRefP` has no `*` branch, attoparsec returns `Failed reading: empty`, the handler never runs. + +This is the correct server behaviour — sharing a channel link to one's own note folder is not a meaningful operation. The picker offered the destination by accident: `filterChatsToForwardTo` in `apps/ios/SimpleXChat/ChatUtils.swift:56` unconditionally inserts `ChatInfo.local` at index 0: + +```swift +public func filterChatsToForwardTo(chats: [C]) -> [C] { + var filteredChats = chats.filter { c in + c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) + } + if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats +} +``` + +`shareChannelPicker` (`apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift:1103`) builds a `ChatItemForwardingView`, which calls `filterChatsToForwardTo`. So the channel-link picker inherits the Saved-Messages-at-index-0 behaviour that the forward picker wants. + +## 3. Approaches considered + +| # | Approach | Note | +|---|----------|------| +| A | **Final** — parameterize the filter: add `includeLocal: Bool = true` to `filterChatsToForwardTo` and to `ChatItemForwardingView`; pass `includeLocal: false` from `shareChannelPicker`. | Default keeps existing call-sites untouched. Mirrors PR #6958's pattern — the filter decides, callers express intent. | +| B | Post-filter `.local` inside `ChatItemForwardingView` after the call to `filterChatsToForwardTo`. | Same line count, but duplicates the `.local` predicate at the consumer instead of expressing it at the producer. | +| C | Pass a closure filter to `ChatItemForwardingView`. | A closure encodes one bit as a function — strictly more machinery for the same outcome. | +| D | Mirror Kotlin literally: read a global `SharedContent.ChatLink` discriminator inside the filter. | iOS's `SharedContent` lives in the Share Extension target, not the main app — the Kotlin-style predicate doesn't translate. | + +Approach A wins on minimality (5 lines, three files), preserves all default behaviour, and matches the architectural pattern of PR #6958 (decision lives where the data is produced). + +## 4. Final implementation + +### 4.1 `apps/ios/SimpleXChat/ChatUtils.swift` — add `includeLocal` parameter + +```diff +-public func filterChatsToForwardTo(chats: [C]) -> [C] { ++public func filterChatsToForwardTo(chats: [C], includeLocal: Bool = true) -> [C] { + var filteredChats = chats.filter { c in + c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) + } +- if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { ++ if includeLocal, let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats + } +``` + +Default value preserves the contract for every existing caller (`ChatItemForwardingView.swift:26`, `SimpleX SE/ShareModel.swift:71-72`). The `if includeLocal, let ...` form Swift-natively short-circuits — no nested block needed. + +### 4.2 `apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift` — thread the flag + +```diff + var isProhibited: ((Chat) -> Bool)? = nil + var onSelectChat: ((Chat) -> Void)? = nil ++ var includeLocal: Bool = true + + @State private var searchText: String = "" + @State private var alert: SomeAlert? +- private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) ++ private var chatsToForwardTo: [Chat] { filterChatsToForwardTo(chats: ChatModel.shared.chats, includeLocal: includeLocal) } +``` + +`private let → private var` (computed) is required because Swift property initializers cannot read sibling instance properties. The computed form re-evaluates when `body` runs — in this view that is twice per render (lines 49 and 52), against a list of size `chats.count`. No meaningful cost; if a profile ever flagged it, switching to a custom `init(...)` that captures `includeLocal` once is a trivial follow-up. + +### 4.3 `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift` — opt out from the channel-link picker + +```diff + let v = ChatItemForwardingView( + title: "Share channel", + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, +- onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) } ++ onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) }, ++ includeLocal: false + ) +``` + +One-line opt-out from the only iOS site that uses the channel-link share flow. + +### 4.4 What is *not* changed + +- **`GroupLinkView.swift:110`** — already gates "Share via chat" with `groupInfo?.groupProfile.publicGroup != nil`. Bug #2 from PR #6958 has no iOS analog. +- **Forward picker** (`ChatView.swift:279, 282`) — uses `ChatItemForwardingView`'s default `includeLocal: true`. Saved Messages still appears at index 0. +- **Share extension** (`SimpleX SE/ShareModel.swift:71-72`) — calls `filterChatsToForwardTo` directly with the default. Unchanged. +- **Haskell.** `sendRefP` and `APIShareChatMsgContent` stay at master. The client just stops offering destinations the server refuses. +- **Android/Desktop, all other share/forward paths.** + +## 5. Why this works + +The server is the source of truth for which destinations are valid for `APIShareChatMsgContent`: + +- Destinations: `@` (direct), `#` (group / scope). Local (`*`) is rejected as a parse failure, by construction. +- Sources: groups with `publicGroup` and `groupLink`. iOS already gates the source side correctly. + +The client's job is to offer choices the server will accept. The picker offered Local in error; this PR narrows the offer to match the server's grammar. The default-`true` parameter means every other caller keeps its current behaviour without modification. + +## 6. Behaviour changes — full inventory + +1. **Picking Saved Messages in the iOS share-channel-link picker is no longer possible.** This is the bug fix. +2. **Forward picker — unchanged.** Default `includeLocal: true`. Forward-to-Saved-Messages still works. +3. **Share extension picker — unchanged.** Default `includeLocal: true`. +4. **`GroupLinkView` button gate — unchanged.** Already correct on iOS. + +Nothing else changes. Verified by reading the diff against master line-by-line. + +## 7. Verification + +1. **Diff is six insertions, four deletions across three files** (`git diff --stat`): + - `apps/ios/SimpleXChat/ChatUtils.swift | 4 ++--` + - `apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift | 3 ++-` + - `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift | 3 ++-` +2. **iOS build** requires Xcode on macOS — not run in this environment. To run by reviewer. +3. **Manual on iOS once built:** + - Open a public channel → profile → "Share via chat" → picker shows direct + group destinations only, **no "Saved Messages" row**. + - Long-press a message → Forward → picker still shows Saved Messages at the top (regression check). + - Open a plain group → group-link management → no "Share via chat" button (already correct, regression check). + +## 8. Trade-offs and follow-ups + +1. **Computed `chatsToForwardTo` re-evaluates on body refresh** rather than caching at struct init. In practice, twice per render against a small list, with `ChatModel.shared.chats` already SwiftUI-observed. Switching to a custom `init(...)` that captures `includeLocal` and assigns `chatsToForwardTo` once is a one-step refactor if ever needed. +2. **The flag is binary, not content-typed.** Kotlin discriminates on `SharedContent` variant; iOS uses an explicit caller intent. If a future iOS site needed to skip Local for a non-share-channel reason, the same flag applies — no further changes needed. diff --git a/plans/audio-captcha-improvements.md b/plans/audio-captcha-improvements.md new file mode 100644 index 0000000000..6797115396 --- /dev/null +++ b/plans/audio-captcha-improvements.md @@ -0,0 +1,520 @@ +# Audio Captcha Improvements Plan + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [High-Level Design](#high-level-design) +3. [Detailed Implementation Plan](#detailed-implementation-plan) +4. [Test Updates](#test-updates) +5. [Files Changed](#files-changed) + +--- + +## Executive Summary + +Improve the audio captcha feature by: + +1. **Proper command parsing** — add `DCCaptchaMode CaptchaMode` constructor to `DirectoryCmd` GADT, using existing Attoparsec parsing infrastructure +2. **Audio captcha retry** — when user switches to audio mode, subsequent retries send voice captcha (not image) +3. **Make `/audio` clickable** — use `/'audio'` format for clickable command in chat UI + +--- + +## High-Level Design + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ CaptchaMode (Events.hs) │ +├──────────────────────────────────────────────────────────────────┤ +│ CMText -- default image/text captcha │ +│ CMAudio -- voice captcha mode │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ PendingCaptcha State │ +├──────────────────────────────────────────────────────────────────┤ +│ captchaText :: Text -- the captcha answer │ +│ sentAt :: UTCTime -- when captcha was sent │ +│ attempts :: Int -- number of attempts │ +│ captchaMode :: CaptchaMode -- current mode (CMText/CMAudio) │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ DirectoryCmd (Events.hs) │ +├──────────────────────────────────────────────────────────────────┤ +│ DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser │ +│ (integrated into existing GADT, parsed via directoryCmdP) │ +└──────────────────────────────────────────────────────────────────┘ + +Flow: +1. User joins group → sendMemberCaptcha (image) + captchaNotice with /'audio' +2. User sends /audio → parsed as DCCaptchaMode CMAudio → set captchaMode=CMAudio, sendVoiceCaptcha +3. User sends wrong answer: + - captchaMode=CMText → send new IMAGE captcha + - captchaMode=CMAudio → send new VOICE captcha ← NEW BEHAVIOR +4. User sends correct answer → approve member + +Message parsing flow (in Service.hs dePendingMemberMsg): +┌─────────────────────────────────────────────────────────────────┐ +│ 1. Parse msgText with directoryCmdP (existing infrastructure) │ +│ ↓ │ +│ 2. TM.lookup pendingCaptcha (ONCE, not per-branch) │ +│ ↓ │ +│ ├─ Nothing → sendMemberCaptcha with mode from parsed cmd │ +│ └─ Just pc → case on parsed cmd: │ +│ ├─ DCCaptchaMode CMAudio → set mode, send voice captcha │ +│ ├─ DCSearchGroup _ → captcha answer (verify/retry) │ +│ └─ _ → unknown command (error message) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Detailed Implementation Plan + +### 3.1 Add `CaptchaMode` type in Events.hs + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** After `DirectoryHelpSection` (line 146) + +**Add:** +```haskell +data CaptchaMode = CMText | CMAudio + deriving (Show) +``` + +**Update exports (line 10-19):** +```haskell +module Directory.Events + ( DirectoryEvent (..), + DirectoryCmd (..), + ADirectoryCmd (..), + DirectoryHelpSection (..), + CaptchaMode (..), + DirectoryRole (..), + SDirectoryRole (..), + crDirectoryEvent, + directoryCmdP, + directoryCmdTag, + ) +where +``` + +--- + +### 3.2 Add `DCCaptchaMode_` tag in Events.hs + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `DirectoryCmdTag` GADT (after line 127, before admin commands) + +**Add:** +```haskell + DCCaptchaMode_ :: DirectoryCmdTag 'DRUser +``` + +--- + +### 3.3 Add `DCCaptchaMode` constructor in Events.hs + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `DirectoryCmd` GADT (after line 160, with other user commands) + +**Add:** +```haskell + DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser +``` + +--- + +### 3.4 Add "audio" tag parsing in Events.hs + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `tagP` function (after line 205, in user commands section) + +**Add:** +```haskell + "audio" -> u DCCaptchaMode_ +``` + +--- + +### 3.5 Add `DCCaptchaMode_` case in `cmdP` + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `cmdP` function (after line 237, with other simple commands) + +**Add:** +```haskell + DCCaptchaMode_ -> pure $ DCCaptchaMode CMAudio +``` + +--- + +### 3.6 Add `DCCaptchaMode` case in `directoryCmdTag` + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `directoryCmdTag` function (after line 316) + +**Add:** +```haskell + DCCaptchaMode _ -> "audio" +``` + +--- + +### 3.7 Update `PendingCaptcha` with `captchaMode` field + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** Lines 103-107 + +**Before:** +```haskell +data PendingCaptcha = PendingCaptcha + { captchaText :: Text, + sentAt :: UTCTime, + attempts :: Int + } +``` + +**After:** +```haskell +data PendingCaptcha = PendingCaptcha + { captchaText :: Text, + sentAt :: UTCTime, + attempts :: Int, + captchaMode :: CaptchaMode + } +``` + +--- + +### 3.8 Update import in Service.hs + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** Line 41 + +**Before:** +```haskell +import Directory.Events +``` + +**After (no change needed):** The implicit import already imports all exports including the new `CaptchaMode`. + +--- + +### 3.9 Update `sendMemberCaptcha` signature and implementation + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** Function `sendMemberCaptcha` (lines 569-589) + +**Before:** +```haskell + sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> IO () + sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts = do + s <- getCaptchaStr captchaLength "" + mc <- getCaptcha s + sentAt <- getCurrentTime + let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1} + atomically $ TM.insert gmId captcha $ pendingCaptchas env + sendCaptcha mc + where + getCaptcha s = case captchaGenerator opts of + Nothing -> pure textMsg + Just script -> content <$> readProcess script [s] "" + where + textMsg = MCText $ T.pack s + content r = case T.lines $ T.pack r of + [] -> textMsg + "" : _ -> textMsg + img : _ -> MCImage "" $ ImageData img + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + sendCaptcha mc = sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] + gmId = groupMemberId' m +``` + +**After:** +```haskell + sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO () + sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do + s <- getCaptchaStr captchaLength "" + sentAt <- getCurrentTime + let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1, captchaMode = mode} + atomically $ TM.insert gmId captcha $ pendingCaptchas env + case mode of + CMAudio -> do + sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText)] + sendVoiceCaptcha sendRef s + CMText -> do + mc <- getCaptcha s + sendCaptcha mc + where + getCaptcha s = case captchaGenerator opts of + Nothing -> pure textMsg + Just script -> content <$> readProcess script [s] "" + where + textMsg = MCText $ T.pack s + content r = case T.lines $ T.pack r of + [] -> textMsg + "" : _ -> textMsg + img : _ -> MCImage "" $ ImageData img + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + sendCaptcha mc = sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] + gmId = groupMemberId' m +``` + +--- + +### 3.10 Update `dePendingMember` call site + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** Line 561 + +**Before:** +```haskell + | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 +``` + +**After:** +```haskell + | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 CMText +``` + +--- + +### 3.11 Make `/audio` clickable in `captchaNotice` + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** `dePendingMember` function, `captchaNotice` definition (lines 565-567) + +**Before:** +```haskell + captchaNotice = + "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + <> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" +``` + +**After:** +```haskell + captchaNotice = + "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + <> if isJust (voiceCaptchaGenerator opts) then "\nSend /'audio' to receive a voice captcha." else "" +``` + +--- + +### 3.12 Refactor `dePendingMemberMsg` with inverted structure + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** `dePendingMemberMsg` function (lines 618-656) + +**Key changes:** +1. Parse command FIRST using existing `directoryCmdP` +2. Do TM.lookup ONCE (not per-branch) +3. Case on lookup result, then on command inside + +**Before:** +```haskell + dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () + dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText + | memberRequiresCaptcha a m = do + let gmId = groupMemberId' m + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + if T.toLower (T.strip msgText) == "/audio" + then + atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case + Just PendingCaptcha {captchaText} -> + sendVoiceCaptcha sendRef (T.unpack captchaText) + Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 + else do + ts <- getCurrentTime + atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case + Just PendingCaptcha {captchaText, sentAt, attempts} + | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 + | matchCaptchaStr captchaText msgText -> do + sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + approvePendingMember a g m + | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts + | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts + Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 + | otherwise = approvePendingMember a g m + where + a = groupMemberAcceptance g + rejectPendingMember rjctNotice = do + let gmId = groupMemberId' m + sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case + Right (CRUserDeletedMembers _ _ (_ : _) _) -> do + atomically $ TM.delete gmId $ pendingCaptchas env + logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g + r -> logError $ "unexpected remove member response: " <> tshow r + captchaExpired = "Captcha expired, please try again." + wrongCaptcha attempts + | attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt." + | otherwise = "Incorrect text, please try again." + noCaptcha = "Unexpected message, please try again." + tooManyAttempts = "Too many failed attempts, you can't join group." +``` + +**After:** +```haskell + dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () + dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText + | memberRequiresCaptcha a m = do + let gmId = groupMemberId' m + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText + atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case + Nothing -> + let mode = case cmd of ADC SDRUser (DCCaptchaMode CMAudio) -> CMAudio; _ -> CMText + in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode + Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} -> case cmd of + ADC SDRUser (DCCaptchaMode CMAudio) -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + ADC SDRUser (DCSearchGroup _) -> do + ts <- getCurrentTime + if + | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired (attempts - 1) captchaMode + | matchCaptchaStr captchaText msgText -> do + sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + approvePendingMember a g m + | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts + | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts captchaMode + _ -> sendComposedMessages_ cc sendRef [(Just ciId, MCText unknownCommand)] + | otherwise = approvePendingMember a g m + where + a = groupMemberAcceptance g + rejectPendingMember rjctNotice = do + let gmId = groupMemberId' m + sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case + Right (CRUserDeletedMembers _ _ (_ : _) _) -> do + atomically $ TM.delete gmId $ pendingCaptchas env + logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g + r -> logError $ "unexpected remove member response: " <> tshow r + captchaExpired = "Captcha expired, please try again." + wrongCaptcha attempts + | attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt." + | otherwise = "Incorrect text, please try again." + noCaptcha = "Unexpected message, please try again." + unknownCommand = "Unknown command, please enter captcha text." + tooManyAttempts = "Too many failed attempts, you can't join group." +``` + +--- + +### 3.13 Add imports in Service.hs + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** After existing imports (around line 28) + +**Add:** +```haskell +import qualified Data.Attoparsec.Text as A +import Data.Either (fromRight) +``` + +**Note:** `T.strip` is already available via the existing `import qualified Data.Text as T`. + +--- + +## Test Updates + +**File:** `tests/Bots/DirectoryTests.hs` + +### 4.1 Update expected output for clickable command + +**Location:** Line 1278 (or wherever `"Send /audio"` appears) + +**Before:** +```haskell +cath <## "Send /audio to receive a voice captcha." +``` + +**After:** +```haskell +cath <## "Send /'audio' to receive a voice captcha." +``` + +### 4.2 Add test for audio captcha retry behavior + +**Location:** New test function `testVoiceCaptchaRetry` after `testVoiceCaptchaScreening` + +**Strategy:** Add test that verifies wrong answer after `/audio` sends voice retry (not image). + +```haskell +testVoiceCaptchaRetry :: HasCallStack => TestParams -> IO () +testVoiceCaptchaRetry ps = do + -- Setup similar to testVoiceCaptchaScreening... + -- After receiving initial image captcha and switching to audio: + -- cath requests audio captcha + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 1" + -- cath sends WRONG answer after switching to audio mode + cath #> "#privacy (support) wrong_answer" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath wrong_answer" + cath <## " Incorrect text, please try again." + -- KEY ASSERTION: retry sends VOICE captcha (not image) because captchaMode=CMAudio + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 2" +``` + +--- + +## Files Changed + +| File | Changes | +|------|---------| +| `apps/simplex-directory-service/src/Directory/Events.hs` | Add `CaptchaMode` type; add `DCCaptchaMode_` tag; add `DCCaptchaMode` constructor; add "audio" tag parsing; add `cmdP` case; add `directoryCmdTag` case; export `directoryCmdP`; update exports | +| `apps/simplex-directory-service/src/Directory/Service.hs` | Add imports (`Data.Attoparsec.Text`, `Data.Either.fromRight`); update `PendingCaptcha` with `captchaMode :: CaptchaMode`; update `sendMemberCaptcha` signature; refactor `dePendingMemberMsg` with inverted structure; make `/audio` clickable | +| `tests/Bots/DirectoryTests.hs` | Update expected output (`/'audio'`); add `testVoiceCaptchaRetry` | + +--- + +## Summary of Changes + +1. **New type in Events.hs:** + - `data CaptchaMode = CMText | CMAudio` + +2. **New constructor in DirectoryCmd GADT:** + - `DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser` + - Uses existing Attoparsec parsing infrastructure via `directoryCmdP` + +3. **State tracking (Service.hs):** + - `PendingCaptcha { ..., captchaMode :: CaptchaMode }` + +4. **Refactored `dePendingMemberMsg` (Service.hs):** + - Parses command FIRST using `directoryCmdP` + - Does `TM.lookup` ONCE (inverted structure, no duplication) + - `Nothing` case: send new captcha in mode derived from command + - `Just pc` case: switch on command type + - `DCCaptchaMode CMAudio` → set mode, send voice captcha + - `DCSearchGroup _` → captcha answer (verify/retry) + - `_` → unknown command (error message) + +5. **Updated `sendMemberCaptcha` (Service.hs):** + - Takes `CaptchaMode` parameter instead of `Bool` + - Sends voice or image captcha based on mode + +6. **Clickable command:** + - `"Send /'audio'"` instead of `"Send /audio"` + +7. **Test coverage:** + - `testVoiceCaptchaScreening` (updated): verify clickable command format + - `testVoiceCaptchaRetry` (new): verify retry behavior with `captchaMode` persistence diff --git a/plans/channel_message_bugs_fix_plan.md b/plans/channel_message_bugs_fix_plan.md new file mode 100644 index 0000000000..c50b5ed7ff --- /dev/null +++ b/plans/channel_message_bugs_fix_plan.md @@ -0,0 +1,321 @@ +# Plan: Channel Message Bugs Fix + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Bug 1: Delivery Context Flag](#bug-1-delivery-context-flag) +3. [Bug 2: Reaction Attribution](#bug-2-reaction-attribution) +4. [Bug 3: Update Fallback Default](#bug-3-update-fallback-default) +5. [Bug 4: Forward API Parameter](#bug-4-forward-api-parameter) +6. [Bug 5: CLI Forward Hardcode](#bug-5-cli-forward-hardcode) +7. [Test Plan](#test-plan) +8. [Implementation Order](#implementation-order) + +--- + +## Executive Summary + +**5 bugs identified** in channel message handling: + +| # | Location | Bug | Severity | +|---|----------|-----|----------| +| 1 | Subscriber.hs:935-945 | Events use `isChannelOwner` instead of item's `showGroupAsSender` | Critical | +| 2 | Subscriber.hs:1818-1842 | Reactions allow `m_=Nothing` and fall back to membership | High | +| 3 | Subscriber.hs:1950-1969 | Update fallback creates item without correct sendAsGroup flag | Medium | +| 4 | Commands.hs:930,944 | Forward API ignores `_sendAsGroup` parameter | High | +| 5 | Commands.hs:2191,2196,2201,4633 | CLI forward hardcodes False | Medium | + +--- + +## Bug 1: Delivery Context Flag + +### Current Code (Subscriber.hs:935-945) +```haskell +let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner + showGroupAsSender' = case event of + XMsgNew mc -> fromMaybe False (asGroup (mcExtMsgContent mc)) + XMsgUpdate {} -> isChannelOwner -- BUG: should use item's flag + XMsgDel {} -> isChannelOwner -- BUG + XMsgReact {} -> isChannelOwner -- BUG + XMsgFileDescr {} -> isChannelOwner -- BUG + XFileCancel {} -> isChannelOwner -- BUG + _ -> False +``` + +### Problem +Events referencing existing items (update, delete, react, file) compute `showGroupAsSender'` from **current sender role** (`isChannelOwner`) instead of **item's stored `showGroupAsSender` flag**. + +### Fix +Extract `showGroupAsSender` from the chat item being referenced: + +```haskell +showGroupAsSender' = case event of + XMsgNew mc -> fromMaybe False (asGroup (mcExtMsgContent mc)) + XMsgUpdate {} -> itemShowGroupAsSender ci -- from item lookup + XMsgDel {} -> itemShowGroupAsSender ci + XMsgReact {} -> itemShowGroupAsSender ci + XMsgFileDescr {} -> itemShowGroupAsSender ci + XFileCancel {} -> itemShowGroupAsSender ci + _ -> False +``` + +**Note:** Use `chatDir` from ChatItem and pattern match on `CIChannelRcv` to determine sendAsGroup flag. + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 935-945 + +--- + +## Bug 2: Reaction Attribution + +### Current Code (Subscriber.hs:1818-1842) +```haskell +groupMsgReaction :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) +groupMsgReaction g m_ sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs + ... + where + GroupInfo {membership} = g + reactor = fromMaybe membership m_ -- BUG (line 1842): uses membership when m_ is Nothing + ciDir = maybe CIChannelRcv CIGroupRcv m_ +``` + +### Problem +When `m_` is `Nothing`, reactor incorrectly falls back to `membership` (user's own member record). However, reactions should always come from an identifiable member - the `m_` parameter should never be `Nothing` for reactions. + +### Fix +Reactions can only come from members (including owners), never from channels. XMsgReact handler must be reworked to require `GroupMember` instead of `Maybe GroupMember`. The `m_` parameter should not be optional for reactions. + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 1818-1842 + +--- + +## Bug 3: Update Fallback Default + +### Current Code (Subscriber.hs:1950-1969) +```haskell +updateRcvChatItem `catchCINotFound` \_ -> do + (chatDir, mentions', scopeInfo) <- case m_ of + Just m -> ... + Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing) -- BUG: no sendAsGroup info + (ci, cInfo) <- saveRcvChatItem' user chatDir msg ... +``` + +### Problem +When `x.msg.update` arrives for a locally-deleted item in a channel (`m_` is `Nothing`), the fallback creates a new item with `CDChannelRcv gInfo Nothing` but doesn't know the original item's `sendAsGroup` flag. + +### Fix (Option B: Require sender to include flag in the event) +Add `asGroup` field to `XMsgUpdate` message format. + +**Rationale:** We don't know what owner wants otherwise - it may send as channel or it may send as owner, and different members must have the same view (e.g. when multiple relays are used, it would be random). + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 1950-1969 +- Protocol message format (XMsgUpdate) + +--- + +## Bug 4: Forward API Parameter + +### Current Code (Commands.hs:930,944) +```haskell +APIForwardChatItems ... _sendAsGroup -> withUser $ \user -> case toCType of + CTGroup -> do + ... + sendGroupContentMessages user gInfo toScope (sendAsGroup' gInfo) False itemTTL cmrs' + -- ^^^^^^^^^^^^^^^^^^^ BUG: ignores _sendAsGroup +``` + +### Problem +The `_sendAsGroup` parameter is received but ignored. The function computes its own `sendAsGroup' gInfo` instead. + +### Fix +```haskell +APIForwardChatItems ... sendAsGroup -> withUser $ \user -> case toCType of + CTGroup -> do + ... + sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' +``` + +### Files Modified +- `src/Simplex/Chat/Library/Commands.hs`: Line 930 (rename parameter), Line 944 (use parameter) + +--- + +## Bug 5: CLI Forward Hardcode + +### Current Code (Commands.hs) +```haskell +-- Line 2191 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 2196 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 2201 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 4633 +"/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP <*> pure False), +``` + +### Problem +All CLI forward commands hardcode `False` for `sendAsGroup` instead of computing based on destination. + +### Fix +Compute `sendAsGroup` before calling API based on destination group's channel status: + +```haskell +-- Lines 2191, 2196, 2201: Need to determine sendAsGroup based on toChatRef +-- If toChatRef is a channel and user is owner, sendAsGroup should default to True + +-- Line 4633: Parser should accept optional flag (parser cannot know context) +``` + +### Files Modified +- `src/Simplex/Chat/Library/Commands.hs`: Lines 2191, 2196, 2201, 4633 + +--- + +## Test Plan + +### New Tests (8 total) + +Tests 1-4 cover Bug 1 (delivery context flag). Each tests a specific event type where the owner sends as member (sendAsGroup=False). Existing tests already cover the "sends as channel" (sendAsGroup=True) case; these tests verify that the delivery context correctly uses the item's stored sendAsGroup=False flag rather than recomputing from the owner's current role. + +#### Test 1: `testChannelOwnerUpdateAsMember` +**Objective:** Verify x.msg.update uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends message as member (sendAsGroup=False) +2. Member receives message, verify it shows as from member (not channel) +3. Owner updates message +4. Verify update delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 2: `testChannelOwnerDeleteAsMember` +**Objective:** Verify x.msg.del uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends message as member (sendAsGroup=False) +2. Member receives message, verify it shows as from member (not channel) +3. Owner deletes message +4. Verify delete delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 3: `testChannelOwnerFileTransferAsMember` +**Objective:** Verify file delivery (including x.msg.file.descr) uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends file as member (sendAsGroup=False) +2. Member receives file, verify it shows as from member (not channel) +3. Verify file delivery uses sendAsGroup=False from the item, not recomputed from owner role + +**Note:** x.msg.file.descr is part of file delivery, not a separate event to test independently. + +**Coverage:** Bug 1 + +--- + +#### Test 4: `testChannelOwnerFileCancelAsMember` +**Objective:** Verify x.file.cancel uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends file as member (sendAsGroup=False) +2. Member receives file, verify it shows as from member (not channel) +3. Owner cancels file +4. Verify cancel delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 5: `testChannelReactionAttribution` +**Objective:** Verify reactions require a member sender (not optional). + +**Scenario:** +1. Owner sends channel message +2. Owner adds reaction (as member, not as channel) +3. Verify reaction is attributed to owner's member record +4. Member adds reaction to channel message +5. Verify member reaction is attributed correctly +6. Verify channel cannot send reactions (m_ must be Just) + +**Coverage:** Bug 2 + +--- + +#### Test 6: `testChannelUpdateFallbackSendAsGroup` +**Objective:** Verify update on deleted item creates correct sendAsGroup from protocol field. + +**Scenario:** +1. Owner sends channel message (sendAsGroup=True) +2. Member receives and locally deletes +3. Owner updates message (XMsgUpdate includes asGroup=True) +4. Verify member's recreated item has sendAsGroup=True +5. Owner sends message as member (sendAsGroup=False) +6. Member receives and locally deletes +7. Owner updates message (XMsgUpdate includes asGroup=False) +8. Verify member's recreated item has sendAsGroup=False + +**Coverage:** Bug 3 + +--- + +#### Test 7: `testForwardAPIUsesParameter` +**Objective:** Verify Forward API respects sendAsGroup parameter. + +**Scenario:** +1. Create channel with owner +2. Forward message to channel with sendAsGroup=True +3. Verify message sent as channel +4. Forward message with sendAsGroup=False +5. Verify message sent as member + +**Coverage:** Bug 4 + +--- + +#### Test 8: `testForwardCLISendAsGroup` +**Objective:** Verify CLI forward commands compute sendAsGroup correctly. + +**Scenario:** +1. Create channel with owner +2. Use `/forward` to forward to channel +3. Verify sendAsGroup computed correctly (True for owner in channel) + +**Coverage:** Bug 5 + +--- + +## Implementation Order + +### Phase 1: Critical Fix (Bug 1) +1. Fix delivery context in Subscriber.hs +2. Add Tests 1-4 (`testChannelOwnerUpdateAsMember`, `testChannelOwnerDeleteAsMember`, `testChannelOwnerFileTransferAsMember`, `testChannelOwnerFileCancelAsMember`) + +### Phase 2: API Fixes (Bugs 4, 5) +1. Fix Forward API parameter usage +2. Fix CLI forward hardcodes +3. Add Tests 7 and 8 (`testForwardAPIUsesParameter`, `testForwardCLISendAsGroup`) + +### Phase 3: Behavior Fixes (Bugs 2, 3) +1. Rework XMsgReact handler to require GroupMember (not Maybe GroupMember) +2. Add asGroup field to XMsgUpdate protocol message +3. Add Tests 5 and 6 (`testChannelReactionAttribution`, `testChannelUpdateFallbackSendAsGroup`) + +--- + +## Files Summary + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Library/Subscriber.hs` | Lines 935-945 (Bug 1), 1818-1842 (Bug 2), 1950-1969 (Bug 3) | +| `src/Simplex/Chat/Library/Commands.hs` | Lines 930,944 (Bug 4), 2191,2196,2201,4633 (Bug 5) | +| Protocol message types | Add asGroup field to XMsgUpdate (Bug 3) | +| `tests/ChatTests/Groups.hs` | Add 8 new tests | diff --git a/plans/chat-relays-mvp-launch-plan.md b/plans/chat-relays-mvp-launch-plan.md new file mode 100644 index 0000000000..64af3c7d42 --- /dev/null +++ b/plans/chat-relays-mvp-launch-plan.md @@ -0,0 +1,293 @@ +# Chat Relays MVP — Launch Plan + +## Contents +- [Executive Summary](#executive-summary) +- [What's Done](#whats-done) +- [What's Remaining](#whats-remaining): Protocol & Crypto | Relay Protocol | Member Connection | UI | Testing | Polish | Directory +- [Dependency Summary](#dependency-summary) +- [Risk Register](#risk-register) +- [Decisions Made](#decisions-made) +- [Post-MVP Backlog](#post-mvp-backlog) + +--- + +## Executive Summary + +Chat Relays enable large public channels where messages flow owner → relay → members, replacing N-to-N connections. This plan covers what remains for MVP launch. + +**Current state**: Core backend ~75% done (delivery system, forwarding, deduplication, relay invitation/acceptance, group creation with relays all working). UI ~15%. Key remaining work: member key signatures, relay identity validation, forward envelope protocol, UI on both platforms. + +**MVP delivers**: Owners create channels with preset relays. Relays validate and serve groups. Members join via links, receive relay-forwarded messages signed by owners. UI differentiates channels from groups. + +**Out of scope**: Relay removal/recovery, periodic relay health monitoring, relay-to-relay sync, history navigation, e2e encryption in support chats, multi-owner support, reaction/comment batching. See [Post-MVP](#post-mvp-backlog). + +--- + +## What's Done + +- Single-roundtrip group creation with relays (`APINewPublicGroup` → `prepareConnectionLink` → `createConnectionForLink` — Agent API complete) +- Relay invitation/acceptance protocol (`XGrpRelayInv`, `XGrpRelayAcpt`) and relay request worker +- Async delivery task/job system with cursor-paginated member delivery +- `FwdChannel` / `FwdMember` forwarding modes, `ShowGroupAsSender` through full pipeline +- Message deduplication on member side +- Binary batch encoding (`=` prefix) in `Messages/Batch.hs` and `Protocol.hs` +- DB schema: `chat_relays`, `group_relays`, `group_members.relay_link`, key columns on `groups`/`group_members` +- Preset relay configuration framework (3 placeholder relays in `Presets.hs`) +- `CIChannelRcv` chat item direction in backend +- Observer role UI already works on both platforms (compose bar hidden, reactions only) + +## What's Remaining + +Organized by architecture layer, not work streams. Items within each section are roughly ordered by dependency. + +--- + +### 1. Protocol & Cryptography + +#### 1.1 Binary Forward Envelope (`F` prefix) +New top-level binary format replacing `XGrpMsgForward` for relay groups. Wraps original sender bytes verbatim — preserves signatures through relay forwarding without re-encoding. + +Format: `F` (see member-keys-plan.md §8). + +Old groups keep `XGrpMsgForward` (JSON). New relay groups use `F` envelope. Parser accepts both. + +**Files**: `Protocol.hs` (parse/encode), `Batch.hs` (batching), `Subscriber.hs` (forwarding handler replacement) + +#### 1.2 Key Generation & Storage +Generate Ed25519 key pairs on group creation/join. Populate existing DB columns: `root_priv_key`/`root_pub_key` on `groups`, `member_priv_key` on `groups`, `member_pub_key` on `group_members`. + +Consider adding to current `M20260222_chat_relays` migration (unreleased) rather than creating a new one. + +**Files**: `Store/Groups.hs`, `Store/Profiles.hs`, `Commands.hs` (creation flow) + +#### 1.3 Message Signing +Sign roster-modifying messages (`XGrpRelayInv`, `XGrpMemNew`, `XGrpMemRole`, `XGrpMemDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpDel`) with owner's member key. + +**Files**: `Internal.hs` (signChatMessage), `Commands.hs` (sendGroupMessage integration) + +#### 1.4 Signature Verification +Verify signatures on received roster messages. Hard fail for missing/invalid signatures in new-version groups. + +**Files**: `Internal.hs` (verifyChatMessage), `Subscriber.hs` (reception) + +#### 1.5 OwnerAuth Chain +Owner authorization signed by root key, stored in group link's `UserContactData.owners`. Members verify owner identity via chain. Type exists; integration TODO. + +**Files**: `Protocol.hs`, `Commands.hs`, `Subscriber.hs` + +#### 1.6 Version Gating +Chat relays is a new feature — relay groups only joinable by clients of the new version. Add `chatRelaysVersion` to version range. No backward compat needed for relay groups themselves (they don't exist in older versions). + +**Files**: `Types.hs` (version constant), `Commands.hs` (gating) + +--- + +### 2. Relay Protocol + +#### 2.1 Relay Address Link Data +On relay address creation, set link data: relay identity (profile, certificate, relay identity key). Members validate this when connecting. + +**Files**: `Commands.hs` (relay address creation), `Protocol.hs` (relay link data structure) + +#### 2.2 Group Profile Validation by Relay +Before accepting to serve group, relay validates group profile, verifies owner's signature, and checks `shared_group_id` in immutable link data (prevents redirect to wrong group). + +**Files**: `Subscriber.hs` (`runRelayRequestWorker` — stub exists, validation logic TODO) + +#### 2.3 Relay Link Data on Acceptance +When accepting, relay sets: relay identity, relay key for group, group ID in immutable part of relay link data. + +**Files**: `Subscriber.hs` (relay link creation) + +#### 2.4 Relay Key/Identity Validation by Members +When member connects to relay, validate relay link data (identity, key, group ID) matches group link data. This is part of the same signature/identity verification work as §1.4. + +**Files**: `Commands.hs` (`connectToRelay`), `Subscriber.hs` + +#### 2.5 Test Chat Relay Command +`APITestChatRelay` / `TestChatRelay` — channel owners need to verify relay connectivity before creating channels. + +**Files**: `Commands.hs` (new command) + +#### 2.6 Real Relay Addresses in Presets +Replace placeholder URLs in `simplexChatRelays`. Depends on relay server deployment. + +**Files**: `Operators/Presets.hs` + +#### 2.7 Channel-Only Behavior Enforcement +In channel groups (`useRelays = True`), the API supports sending both as channel (`asGroup=True`) and as member. For MVP, UI always passes `asGroup=True`. Backend does not enforce — owners retain the API option to send as member for future use. Non-owner/non-admin members can only send reactions (observer role enforced by existing role system). + +**Files**: UI-only enforcement for MVP (both platforms pass `asGroup=True` in compose) + +--- + +### 3. Member Connection Flow + +#### 3.1 Support `/c` API for Relay Groups +Automate `APIPrepareGroup` → `APIConnectPreparedGroup` flow when using `/c` command with a relay group link. Currently requires manual two-step call. + +**Files**: `Commands.hs` (`connectWithPlan`) + +#### 3.2 Relay Connection State Response Type +New response type/events showing per-relay connection state (connecting, connected, temporary error, permanent error). Needed for both member join and owner creation UX. + +**Files**: `Controller.hs` (new ChatResponse variants), `Commands.hs` (emit events) + +#### 3.3 Member Count for Channels +Existing member count display uses loaded member list — won't work for channels, where members only have records for owners and relays. Relays must communicate real member counts (excluding relays themselves) to members and owners. Needs protocol extension for relay → member count communication. + +**Files**: `Protocol.hs` (new event or extension), `Subscriber.hs` (relay reporting), UI (display) + +--- + +### 4. UI — Both Platforms (iOS + Android/Desktop) + +All UI items must be completed on both platforms for MVP. + +#### 4.1 Channel Visual Distinction +Different icon/badge for channels in chat list. "Channel" label. Key off `useRelays` flag in `GroupInfo`. + +No backend dependency — can start immediately. + +#### 4.2 "Message from Channel" Display +`CIChannelRcv` direction NOT yet handled in either platform's UI. Must add to message rendering pipeline. `showGroupAsSender` message rendering. + +Backend complete. No backend dependency. + +#### 4.3 Channel Creation Flow +"Create Channel" button in new chat menu → name/description → relay selection → creation with relay status feedback (invited → accepted → active). Backend `APINewPublicGroup` exists. + +Depends on: §3.2 (relay connection state type) + +#### 4.4 Relay Management (User Settings) +List of configured relays; add/remove/edit; test connectivity. Follow existing SMP server management pattern. + +Depends on: §2.5 (`APITestChatRelay`) + +#### 4.5 Show Relays in Channel Info +Relay list with status and identity in channel info screen. + +#### 4.6 Relay Connection State During Join +Progress feedback when joining: "Connecting to relays..." → per-relay status → "Connected". + +Depends on: §3.2 (relay connection state type) + +#### 4.7 Owner Posting UI +Compose mode always sends as channel (`asGroup=True`). No toggle for MVP. + +#### 4.8 API Type Updates +- **iOS**: Add `apiNewPublicGroup` to `ChatCommand` enum; add `ChatRelay`, `RelayStatus`, `GroupRelay`, `CIChannelRcv` types +- **Android**: Add corresponding types to Kotlin model layer +- Both: relay connection state event types + +--- + +### 5. Testing + +- Delivery loop restored after restart +- Delivery in support scopes inside channels +- Connect plans for relay groups +- Cancellation on failure to create relay group +- Async retry connecting to relay (members) +- Relay privileges +- Binary forward envelope encode/decode round-trips +- Message signing and verification flow +- Relay signature validation in invitation flow +- Backward compat: old clients cannot join relay groups (version gated) + +--- + +### 6. Polish & Edge Cases + +- Create missing service chat items ("relays updated" for owner, "group invite accepted" for relay) +- Disable link data output in CLI (`View.hs` — currently enabled for manual testing, cleanup) +- When deleting chat relay from user config, check `group_relays` references and mark as deleted instead +- Single file description for all recipients (performance) + +--- + +### 7. Directory Service Verification + +Directory service currently has no channel/relay awareness — it only lists regular groups. Needs verification how channels should appear in directory and what integration work is required. Some adaptation may be needed. + +--- + +## Dependency Summary + +``` +Can start immediately (no dependencies): + §1.2 Key Storage, §1.3-1.5 Signing/Verification, §1.6 Version Gating + §2.1 Relay Address Data, §2.7 Channel Enforcement (UI-only) + §4.1 Channel Visual Distinction, §4.2 "Message from Channel" Display + +Needs §1.3-1.5 (signing): + §2.2 Group Profile Validation, §2.3 Relay Link Data + +Needs §2.1+2.3: + §2.4 Relay Key Validation by Members + +Needs §3.2 (relay state type): + §4.3 Channel Creation UI, §4.6 Join State UI + +Needs §2.5 (test command): + §4.4 Relay Management UI + +Late phase: + §5 Testing (needs most backend complete) + §2.6 Real Relay Addresses (needs server deployment) + §7 Directory Verification +``` + +**Critical path**: §1.1 (Forward Envelope) + §1.2-1.5 (Keys/Signing) → §2.2-2.3 (Relay Validation) → §5 (Testing) → Launch + +**Early UI wins**: §4.1, §4.2 can start in Phase 1. + +--- + +## Risk Register + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Forward envelope (`F`) version mismatch relay↔member | High | Version gating — relay groups require new version on all participants | +| Relay server instability under load | High | Load test early; multi-relay redundancy | +| UI on 2 platforms takes longer than expected | Medium | Both required for MVP; start UI early (§4.1, §4.2 have no backend deps) | +| Member count protocol extension complexity | Medium | Can ship without count initially; add in fast-follow | +| Stale relay "Active" status (no health monitoring) | Low | Multi-relay redundancy; manual `APITestChatRelay`; monitoring post-MVP | + +--- + +## Decisions Made + +- **Single-owner channels**: Allowed without warning (sender identity is clear for "messages from channel"). Single-owner is the main MVP case; "from channel" UX is valuable regardless. Revisit with multi-owner support. +- **Channel-only enforcement**: UI-only for MVP (`asGroup=True` always passed). Backend retains API flexibility for future "send as member" option. +- **Default member role**: Observer by default for channels. No additional owner→relay communication of role/rejection rules for MVP. +- **Contact connection refactoring**: Deferred to post-MVP. Current flow works. +- **Member rejection by relay**: Deferred. MemberId clash unlikely; rejection rules postponed. +- **Relay profiles**: Consider for MVP vs post-MVP. Members and owners see relay profiles in group already; linking to single per-config profile is nice-to-have. +- **Chat relay user filtering**: Post-MVP. Relay user will be visible in client for now. + +--- + +## Post-MVP Backlog + +1. Relay removal and group recovery — owner removes relay, members reconnect via updated link +2. Periodic relay health checks — relay verifies link presence in group link data +3. Relay-to-relay synchronization +4. Managing relays in existing group — add/remove relays post-creation +5. Default member role and rejection rules communication owner→relay +6. Member rejection by relay (duplicate member ID, rule violations) +7. Contact connection flow refactoring (`connectViaContact` simplification) +8. Deduplication highlighting — show differences between relay-forwarded messages +9. History navigation — request older messages from channel +10. E2E encryption in admin/support chats +11. Reaction/comment count batching +12. Priority connections — separate queues for messages vs admin requests +13. Member profile delivery optimization +14. Private relays with password +15. Channel content moderation +16. Indefinite file storage for relays +17. Message revocation from history +18. Channel discovery/directory integration (verify and extend) +19. Advanced forwarding envelope — include channel link in forwarded message metadata for distribution +20. Relay profiles linked to single per-config record +21. Chat relay user filtering/separate UI diff --git a/plans/deduplication-channel-messages.md b/plans/deduplication-channel-messages.md new file mode 100644 index 0000000000..0d09d00528 --- /dev/null +++ b/plans/deduplication-channel-messages.md @@ -0,0 +1,256 @@ +# Deduplication Plan: Channel Message Functions + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Findings by File](#findings-by-file) +3. [Architectural Note: CIChannelRcv Constructor](#architectural-note) +4. [Implementation Order](#implementation-order) + +--- + +## Executive Summary + +The PR introduces channel message support by creating parallel channel-specific functions that duplicate 60-80% of existing group functions. The core pattern: channel messages are group messages without a member sender. Most channel functions are the group function with `Just member` → `Nothing`, `CIGroupRcv m` → `CIChannelRcv`, and moderation/blocking guards removed. + +**High-value deduplication targets** (ordered by impact): + +| # | Candidate | Feasibility | Shared code | +|---|-----------|-------------|-------------| +| 1 | `channelMessageUpdate_` → merge into `groupMessageUpdate` | HIGH | ~36 lines | +| 2 | `fwdChannelReaction` → extract shared helper with `groupMsgReaction` | MEDIUM | ~15 lines inner function | +| 3 | `newChannelContentMessage_` → parameterize `newGroupContentMessage` | MEDIUM | ~12 lines happy path | +| 4 | `processForwardedChannelMsg` → merge into `processForwardedMsg` | MEDIUM | depends on 1-3 | +| 5 | `getGroupCIBySharedMsgId'` → parameterize `getGroupChatItemBySharedMsgId` | HIGH | eliminates function | +| 6 | `channelMessageDelete` → parameterize `groupMessageDelete` | LOW | ~5 lines; group has 60+ lines moderation | +| 7 | `saveRcvChatItem'` CDChannelRcv branches | HIGH | ~14 lines across 3 spots | +| 8 | `processContentItem` CIChannelRcv branch | HIGH | ~3 lines | +| 9 | View.hs/Store/Internal pattern match branches | DEFERRED | ~24 branches; requires constructor change | + +--- + +## Findings by File + +### Subscriber.hs + +**D1: `channelMessageUpdate_` vs `groupMessageUpdate`** + +The `updateRcvChatItem` inner function is nearly line-for-line identical between both (~36 shared lines). Differences: +- Lookup: `getGroupChatItemBySharedMsgId` (by member) vs `getGroupCIBySharedMsgId'` (no member) — parameterizable by `Maybe GroupMemberId` (see D5) +- Pattern match: `CIGroupRcv m'` with `sameMemberId` check vs `CIChannelRcv` — branch on `Maybe GroupMember` +- `getGroupCIReactions`: `Just memberId` vs `Nothing` — already parameterized +- Chat direction in fallback: `CDGroupRcv` vs `CDChannelRcv` — branch on `Maybe GroupMember` +- `channelMessageUpdate_` has explicit `forwarded` param; `groupMessageUpdate` always uses `rcvGroupCITimed gInfo ttl_` — the merged function needs to accept `forwarded :: Bool` (or always `False` from the non-forwarded path) +- `groupMessageUpdate` has `prohibitedSimplexLinks` and `blockedMemberCI` guards — skip when member is `Nothing` +- Mentions handling: `groupMessageUpdate` has `mentions' = if memberBlocked m then [] else mentions`; `channelMessageUpdate_` passes `mentions` directly — when member is `Nothing`, use `mentions` directly (no blocking check needed) + +**Solution:** Extend `groupMessageUpdate` to take `Maybe GroupMember`. When `Nothing`: skip prohibited links check, skip blocked member CI, use `CDChannelRcv`, use `getGroupChatItemBySharedMsgId` with `Nothing`, pass mentions directly. Delete `channelMessageUpdate_`. + +--- + +**D2: `fwdChannelReaction` vs `groupMsgReaction`** + +These functions share the `updateChatItemReaction` inner function shape (~15 lines), but are **structurally different** in their outer logic: + +- **Parameter types**: `groupMsgReaction` takes a concrete `GroupMember` + `Maybe MemberId` (item member) + `Maybe MsgScope`; `fwdChannelReaction` takes `Maybe GroupMember` (reactor) and always passes `Nothing` as item member +- **Return type**: `groupMsgReaction` returns `CM (Maybe DeliveryJobScope)` — used by the main dispatch for delivery job routing; `fwdChannelReaction` returns `CM ()` — forwarded context doesn't need delivery jobs +- **CIReaction constructor**: `groupMsgReaction` always uses `CIGroupRcv m`; `fwdChannelReaction` uses `maybe CIChannelRcv CIGroupRcv reactor_` — semantically different when reactor is `Nothing` +- **catchCINotFound fallback**: `groupMsgReaction` has scope-aware delivery job logic; `fwdChannelReaction` does bare `setGroupReaction` +- **Reactor**: `groupMsgReaction` uses `m` directly; `fwdChannelReaction` computes `fromMaybe membership reactor_` + +`fwdChannelReaction` is NOT a rename of `groupMsgReaction`. Calling `void $ groupMsgReaction` from forwarded contexts would be **semantically wrong**: it would attribute channel reactions to the membership member via `CIGroupRcv` instead of showing them as `CIChannelRcv`, and would trigger unnecessary delivery job scope logic. + +**Solution:** Extract the shared `updateChatItemReaction` body (~15 lines) into a helper parameterized by the `CIReaction` constructor and reactor member. Both `groupMsgReaction` and `fwdChannelReaction` call this helper with their respective parameters. This preserves the distinct outer logic while eliminating the inner body duplication. + +--- + +**D3: `newChannelContentMessage_` vs `newGroupContentMessage`** + +The channel version is the "happy path" of the group version with all member-specific guards removed: +- No `blockedByAdmin` check +- No `prohibitedGroupContent` check +- No `getCIModeration` / moderation logic (~40 lines) +- No scope resolution (`mkGetMessageChatScope`) +- No `blockedMemberCI` +- No member-conditional mentions filtering / autoAcceptFile guard + +The shared "save-view-react-accept" core is ~12 lines. + +**Solution:** Extract a shared `saveGroupContentItem` helper containing: process file invitation, save chat item, get reactions, view, auto-accept, return scope. `newGroupContentMessage` calls it after its checks; `newChannelContentMessage_` calls it directly. This keeps `newGroupContentMessage`'s complex flow intact while eliminating the body duplication. + +Alternatively: extend `newGroupContentMessage` to take `Maybe GroupMember`. When `Nothing`: skip all member-specific guards and use `CDChannelRcv`. This is cleaner but changes the function's signature and control flow significantly. + +--- + +**D4: `processForwardedChannelMsg` vs `processForwardedMsg`** + +These are dispatch tables with identical structure. Each event arm calls the group or channel variant: + +``` +processForwardedMsg author: processForwardedChannelMsg: + XMsgNew → newGroupContentMessage XMsgNew → newChannelContentMessage_ + XMsgFileDescr → groupMessageFileDescription XMsgFileDescr → channelMessageFileDescription + XMsgUpdate → groupMessageUpdate XMsgUpdate → channelMessageUpdate_ + ... ... +``` + +If the underlying functions (D1-D3) are parameterized by `Maybe GroupMember`, this dispatch unifies automatically. The extra group-management events (`XInfo`, `XGrpMemNew`, etc.) are guarded by `Just author`. + +**Subtlety: `XMsgReact` handling.** The `XMsgReact` arm has a three-way split: +- `processForwardedMsg` with `Just memId` → `groupMsgReaction` (member reaction with scope/delivery-job logic) +- `processForwardedMsg` with `Nothing` memId → `fwdChannelReaction gInfo (Just author)` (channel reaction from known author) +- `processForwardedChannelMsg` → `fwdChannelReaction gInfo Nothing` (channel reaction, no author) + +This three-way split needs careful handling in the merged function, since `fwdChannelReaction` differs structurally from `groupMsgReaction` (see D2). + +**Solution:** After D1-D3, merge into `processForwardedMsg` taking `Maybe GroupMember`. When `Nothing`, skip group-management events. The `XMsgReact` arm passes the author to `fwdChannelReaction` when in channel mode. Delete `processForwardedChannelMsg`. + +--- + +**D5: `channelMessageDelete` vs `groupMessageDelete`** + +`groupMessageDelete` has ~60 lines of moderation logic (moderate, checkRole, archiveMessageReports, CIModeration creation) that `channelMessageDelete` does not need. The shared portion is only ~5-7 lines (delete/mark-deleted + view). Additionally, the lookup functions differ: `channelMessageDelete` uses `getGroupCIBySharedMsgId'` (no member); `groupMessageDelete` uses `getGroupMemberCIBySharedMsgId` (JOINs group_members by MemberId). The delete condition also differs: `groupFeatureAllowed` vs `groupFeatureMemberAllowed`. + +**Solution:** LOW priority. The functions are architecturally different enough that forced unification would harm readability. If desired, extend `groupMessageDelete` with a `Maybe GroupMember` parameter where `Nothing` takes the simple "channel delete" path early. But the code clarity cost may exceed the deduplication benefit. + +--- + +### Store/Messages.hs + +**D6: `getGroupCIBySharedMsgId'` vs `getGroupChatItemBySharedMsgId`** + +`getGroupChatItemBySharedMsgId` filters by `group_member_id = ?`. +`getGroupCIBySharedMsgId'` omits the `group_member_id` filter entirely (matches any row regardless of member). + +Channel items store `group_member_id = NULL`. Parameterizing with `Maybe GroupMemberId` and `IS NOT DISTINCT FROM` would: +- `Just gmId` → only that member (existing behavior) +- `Nothing` → only NULL rows (channel items) + +This is **stricter** than `getGroupCIBySharedMsgId'`'s current behavior (which matches any member's items too), but this is actually a correctness improvement — all four callers (Subscriber.hs lines 1846, 1962, 1988, 3233) are channel-specific contexts where items have `group_member_id = NULL`. + +**Solution:** Change `getGroupChatItemBySharedMsgId` to take `Maybe GroupMemberId`. SQL becomes: +```sql +WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? +``` +Delete `getGroupCIBySharedMsgId'`. Update all callers to pass `Just gmId` or `Nothing`. + +**Note:** `getGroupMemberCIBySharedMsgId` is a different function (takes `MemberId`, JOINs `group_members` to resolve). It is NOT a duplicate and should be kept. + +**Additional Store/Messages.hs duplications** (minor, collapse with constructor change): +- `createNewRcvChatItem` quoteRow (lines 560-563): `CDGroupRcv` and `CDChannelRcv` branches are verbatim identical +- `getChatItemQuote_` (lines 649-654): `CDChannelRcv` branch is a subset of `CDGroupRcv` (missing sender-specific case) +- `createNewChatItem_` idsRow/groupScope: `CDChannelRcv` branches repeat `CDGroupSnd`-like tuples + +These are inherent to the separate constructor and collapse automatically with the architectural change (see note below). Not worth addressing independently. + +--- + +### Library/Internal.hs + +**D7: `saveRcvChatItem'` CDChannelRcv branches** + +Three duplicate spots within this function, all verbatim copies of CDGroupRcv branches: + +1. **Mentions/userMention computation** (~7 lines): `getRcvCIMentions`, `userReply` via `cmToQuotedMsg`, `userMention'` via membership check. Verbatim identical between CDGroupRcv and CDChannelRcv. + +2. **createGroupCIMentions** (~2 lines): Both branches call `createGroupCIMentions db g ci mentions'` guarded by `not (null mentions')`. Identical. + +3. **memberChatStats / memberAttentionChange** (~3 lines): Only difference is `Just m` vs `Nothing` passed to `memberAttentionChange`. + +Total: ~14 lines of duplication across 3 spots. + +**Solution:** Extract `GroupInfo` and `Maybe GroupMember` from either constructor at the top: +```haskell +case cd of + CDGroupRcv g _s m -> (g, Just m) + CDChannelRcv g _s -> (g, Nothing) +``` +Then use the extracted values for all three spots. The `memberAttentionChange` call already takes `Maybe GroupMember`. + +--- + +**D8: `processContentItem` CIChannelRcv branch** + +Near-duplicate of `CIGroupRcv` branch (lines 1196-1199 vs 1200-1202). Only difference: no `blockedByAdmin` guard, passes `Nothing` instead of `Just sender`. + +**Solution:** Merge the two branches: +```haskell +(CChatItem SMDRcv ci@ChatItem {chatDir, content = CIRcvMsgContent mc, file}) + | maybe True (not . blockedByAdmin) sender_ -> do + fInvDescr_ <- join <$> forM file getRcvFileInvDescr + processContentItem sender_ ci mc fInvDescr_ + where sender_ = case chatDir of CIGroupRcv m -> Just m; CIChannelRcv -> Nothing; _ -> Nothing +``` + +**Additional Internal.hs duplication** (minor): +- `quoteData` (lines 228-229): `CIGroupRcv m` returns `(qmc, CIQGroupRcv $ Just m, False, Just m)`, `CIChannelRcv` returns `(qmc, CIQGroupRcv Nothing, False, Nothing)`. Two one-liners differing only in `Just m` vs `Nothing`. Trivial but noted. + +--- + +### View.hs + +**D9: View.hs pattern match duplication** + +The actual count of `CIChannelRcv` pattern match branches: +- **View.hs**: 6 branches (chatDirNtf, viewChatItem new, viewChatItem updated, reaction display, sentByMember', fileFrom) +- **Terminal/Output.hs**: 1 branch +- **Commands.hs**: 2 branches (itemDeletable, itemsMsgMemIds) +- **Internal.hs**: 2 branches (quoteData, processContentItem) +- **Subscriber.hs**: ~6 branches (scattered) +- **Store/Messages.hs**: ~4 branches (toGroupChatItem, createNewRcvChatItem, createNewChatItem_, getChatItemQuote_) + +Total: **~24 pattern match sites** across all files (~17 `CIChannelRcv` + ~7 `CDChannelRcv`). Each mirrors the corresponding `CIGroupRcv m` / `CDGroupRcv` branch passing `Nothing` instead of `Just m`. + +The `ttyFromGroup*` family of functions in View.hs was correctly generalized to take `Maybe GroupMember` — the duplication is at the call sites, not in the helper functions. + +**Solution:** This duplication is **inherent to the separate constructor choice** and can only be eliminated by the architectural change (merging `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)`). Without that change, the branches must remain. Extracting local helpers at each call site would add complexity without reducing total code. + +--- + +### Other Files (no significant deduplication needed) + +- **Commands.hs:** Parameter threading (`ShowGroupAsSender`, `SRGroup`). Clean, no duplication. +- **Protocol.hs:** Wire protocol changes (`ExtMsgContent.asGroup`, `XGrpMsgForward Maybe MemberId`). Necessary. +- **Delivery.hs:** `FwdSender` type replaces separate fields. Could be `Maybe (MemberId, ContactName)` but not a priority. +- **Store/Files.hs:** `createRcvGroupFileTransfer` takes `Maybe GroupMember`. Clean parameterization. +- **Store/Groups.hs:** `createPreparedGroup` returns `Maybe GroupMember`. Necessary for relay groups. +- **Types.hs:** `sendAsGroup'`, `groupId'` utilities. Minor. + +--- + +## Architectural Note: CIChannelRcv Constructor {#architectural-note} + +The deepest source of duplication is the choice to add `CIChannelRcv` / `CDChannelRcv` as separate constructors rather than parameterizing `CIGroupRcv :: Maybe GroupMember -> CIDirection 'CTGroup 'MDRcv` and `CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> ChatDirection 'CTGroup 'MDRcv`. + +This creates ~24 pattern match branches across the codebase, almost all passing `Nothing` where `CIGroupRcv` passes `Just m`. The `chatItemMember` function already returns `Maybe GroupMember`, confirming the abstraction is correct. + +**However**, changing these constructors is a large cross-cutting refactor affecting Messages.hs, View.hs, Commands.hs, Internal.hs, Subscriber.hs, Store/Messages.hs, and tests. It may be better suited as a follow-up PR. + +**Decision needed from user:** Merge `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)` in this PR, or defer? + +--- + +## Implementation Order + +### Phase 1: Store layer (D6) +1. Parameterize `getGroupChatItemBySharedMsgId` with `Maybe GroupMemberId` + `IS NOT DISTINCT FROM` +2. Delete `getGroupCIBySharedMsgId'` +3. Update all callers (pass `Just gmId` or `Nothing`) + +### Phase 2: Subscriber.hs function merges (D1, D2, D3) +4. Merge `channelMessageUpdate_` into `groupMessageUpdate` (takes `Maybe GroupMember`) +5. Extract shared `updateChatItemReaction` helper from `groupMsgReaction` and `fwdChannelReaction` +6. Merge `newChannelContentMessage_` into `newGroupContentMessage` (extract shared save-view helper or take `Maybe GroupMember`) + +### Phase 3: Dispatch unification (D4) +7. Merge `processForwardedChannelMsg` into `processForwardedMsg` (takes `Maybe GroupMember`; handle `XMsgReact` three-way split) + +### Phase 4: Internal cleanup (D7, D8) +8. Deduplicate `saveRcvChatItem'` CDChannelRcv branches (3 spots) +9. Merge `processContentItem` CIChannelRcv branch + +### Phase 5 (deferred unless approved): Constructor change (D9) +10. Merge `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)` — eliminates ~24 pattern match branches across all files + +### Phase 6 (optional): channelMessageDelete (D5) +11. Only if user wants it — extend `groupMessageDelete` with `Maybe GroupMember` diff --git a/plans/delivery-context-fix.md b/plans/delivery-context-fix.md new file mode 100644 index 0000000000..4b1b13c30a --- /dev/null +++ b/plans/delivery-context-fix.md @@ -0,0 +1,354 @@ +# Plan: Fix Channel Message Delivery Architecture + +## Table of Contents +1. [Context](#context) +2. [Executive Summary](#executive-summary) +3. [Issue 1: Eliminate memberForChannel/memberIdForChannel](#issue-1) +4. [Issue 2: groupMsgReaction required GroupMember](#issue-2) +5. [Issue 3: Fix groupMessageUpdate lookup](#issue-3) +6. [Issue 4: DeliveryTaskContext type](#issue-4) +7. [Issue 5: Fix testChannelReactionAttribution](#issue-5) +8. [Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment](#issue-6) +9. [Other: sendAsGroup parameter ordering](#other-issue) +10. [Verification](#verification) + +## Context + +The current implementation on `ep/channel-messages-2` determines delivery context (whether to forward messages as channel or as member) using `isChannelOwner` — inferring from the sender's role whether they're the channel owner. This is architecturally wrong: the delivery context should be determined **from the item's direction** (`CIChannelRcv` vs `CIGroupRcv`), not from who sent it. The `f/msg-from-channel` branch has the correct approach. + +## Executive Summary + +7 changes across 7 files: +1. **Delivery.hs** — Add `DeliveryTaskContext` type, update `NewMessageDeliveryTask` only (`MessageDeliveryTask` unchanged) +2. **Subscriber.hs** — Eliminate `isChannelOwner`/`memberForChannel`/`memberIdForChannel`; all processing functions return `Maybe DeliveryTaskContext`; determine `sentAsGroup` from item direction; `groupMsgReaction` takes required `GroupMember`; add `withAuthor` in forwarded handler +3. **Store/Delivery.hs** — Update SQL row mapping for `taskContext` +4. **Commands.hs** — Reorder `sendAsGroup` param in `APIForwardChatItems` +5. **Store/Messages.hs** — Reorder `showGroupAsSender` param in `createNewSndChatItem` +6. **Internal.hs** — Reorder `showGroupAsSender` param in `saveSndChatItems`, `prepareGroupMsg` +7. **Tests** — Fix reaction test comment/expectations, fix update fallback test comment + +--- + +## Issue 1: Eliminate memberForChannel/memberIdForChannel {#issue-1} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 935-937, 939-991 + +**Problem:** `isChannelOwner`, `memberForChannel`, `memberIdForChannel` computed at lines 935-937 and passed to processing functions. This pre-infers delivery context from member role. + +**Fix:** Remove these three bindings entirely. Always pass `(Just m'')` to functions that take `Maybe GroupMember`. Functions determine `sentAsGroup` from item direction internally. + +**Direct handler changes (lines 939-991):** +``` +-- BEFORE: +let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner + memberForChannel = if isChannelOwner then Nothing else Just m'' + memberIdForChannel = memberId' <$> memberForChannel +(deliveryJobScope_, showGroupAsSender') <- case event of + ... +forM deliveryJobScope_ $ \jobScope -> + pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, showGroupAsSender = showGroupAsSender'} + +-- AFTER: +deliveryTaskContext_ <- case event of + XMsgNew mc -> ... -- returns Maybe DeliveryTaskContext + XMsgFileDescr ... -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr + XMsgUpdate ... -> memberCanSend m'' msgScope Nothing $ groupMessageUpdate gInfo' (Just m'') sharedMsgId ... + XMsgDel ... -> groupMessageDelete gInfo' (Just m'') sharedMsgId ... + XMsgReact ... -> groupMsgReaction gInfo' m'' sharedMsgId ... -- required member + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId + ...other events -> Just <$> memberEventDeliveryContext m'' / Nothing +forM deliveryTaskContext_ $ \taskContext -> + pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} +``` + +**Processing function signature changes:** +- `groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryTaskContext)` — drop both `Maybe MemberId` params, pass `Maybe GroupMember`, determine `sentAsGroup` from `chatDir` of found item +- `groupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> Maybe Bool -> CM (Maybe DeliveryTaskContext)` — drop `senderGMId_` param +- `groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> CM (Maybe DeliveryTaskContext)` — drop `senderGMId_` param; fix `findOwnerCI` dual-lookup (lines 2028-2035) same as Issue 3: when `m_ = Nothing` search with `Nothing`, when `m_ = Just m` use member lookup directly +- `xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext)` — drop both `Maybe MemberId` params + +**`validSender` simplification:** Remove second `Maybe MemberId` parameter. With `(Just m'')` always passed, validation is just: +```haskell +validSender :: Maybe MemberId -> CIDirection 'CTGroup 'MDRcv -> Bool +validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m +validSender Nothing CIChannelRcv = True +validSender _ _ = False +``` + +**`isChannelDir` helper** remains as-is (line 1870-1872) — used to derive `sentAsGroup` from item's `chatDir`. + +**`memberCanSend`** (line 1436): Generic signature `a -> CM a -> CM a` — no change needed. Default values at call sites change from `(Nothing, False)` to `Nothing`. + +**`memberCanSend'`** (line 1448): Return type changes from `CM (Maybe DeliveryJobScope)` to `CM (Maybe DeliveryTaskContext)`. Used in forwarded handler (lines 3153, 3159). + +--- + +## Issue 2: groupMsgReaction required GroupMember {#issue-2} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` line 1814 + +**Problem:** `groupMsgReaction :: GroupInfo -> Maybe GroupMember -> ...` allows `Nothing`, uses `fromMaybe membership m_` fallback. + +**Fix:** Change to required `GroupMember`: +```haskell +groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) +``` + +- No `reactor` binding needed — use `m` directly (eliminates `fromMaybe membership m_` fallback) +- `ciDir = CIGroupRcv (Just m)` (reactions always attributed to member) +- Always return `sentAsGroup = False` — reactions are never from channel +- Return type: `Maybe DeliveryTaskContext` (not tuple) + +**Direct handler call site (line 958-960):** +```haskell +XMsgReact sharedMsgId memberId scope_ reaction add -> + groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs +``` + +**Forwarded handler call site (line 3162-3163):** +```haskell +XMsgReact sharedMsgId memId_ scope_ reaction add -> + withAuthor XMsgReact_ $ \author -> groupMsgReaction gInfo author sharedMsgId memId_ scope_ reaction add rcvMsg msgTs +``` + +--- + +## Issue 3: Fix groupMessageUpdate lookup {#issue-3} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 1973-1994 + +**Problem:** Dual-lookup with `catchError` tries `Nothing` first, then falls back to `senderGMId_`. This is wrong — the `asGroup_` flag from XMsgUpdate should drive the search. + +**Fix:** Use `asGroup_` (the wire flag) to determine search strategy. No `senderGMId_` parameter needed: +```haskell +updateRcvChatItem = do + (cci, scopeInfo) <- withStore $ \db -> do + cci <- case m_ of + Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId + (cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) +``` + +When `m_ = Nothing` (channel owner as channel), search with `Nothing` group_member_id → finds channel items. +When `m_ = Just m` (attributed member message), search with member's `memberId` → finds member items. + +The `isSender` check also simplifies — just check `m_` matches the found item's member. + +**Fallback path** (lines 1948-1968, `catchCINotFound`): When item not found, `showGroupAsSender` is derived from `asGroup_` flag (or defaults based on `m_`), which maps to `sentAsGroup` in the `DeliveryTaskContext`. + +--- + +## Issue 4: DeliveryTaskContext type {#issue-4} + +**File:** `src/Simplex/Chat/Delivery.hs` + +### 4a. Add DeliveryTaskContext type +```haskell +data DeliveryTaskContext = DeliveryTaskContext + { jobScope :: DeliveryJobScope, + sentAsGroup :: ShowGroupAsSender + } + deriving (Show) +``` + +Uses existing `type ShowGroupAsSender = Bool` from Messages.hs. + +### 4b. Modify existing helpers +Rename `infoToDeliveryScope` → `infoToDeliveryContext`, inline the scope logic, add `ShowGroupAsSender` parameter: +```haskell +infoToDeliveryContext :: GroupInfo -> Maybe GroupChatScopeInfo -> ShowGroupAsSender -> DeliveryTaskContext +infoToDeliveryContext GroupInfo {membership} scopeInfo sentAsGroup = DeliveryTaskContext {jobScope, sentAsGroup} + where + jobScope = case scopeInfo of + Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Just GCSIMemberSupport {groupMember_} -> + let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ + in DJSMemberSupport {supportGMId} +``` +Remove `infoToDeliveryScope` entirely. + +Rename `memberEventDeliveryScope` → `memberEventDeliveryContext`, change return type: +```haskell +memberEventDeliveryContext :: GroupMember -> Maybe DeliveryTaskContext +memberEventDeliveryContext m@GroupMember {memberRole, memberStatus} + | memberStatus == GSMemPendingApproval = Nothing + | memberStatus == GSMemPendingReview = Just $ DeliveryTaskContext {jobScope = DJSMemberSupport {supportGMId = groupMemberId' m}, sentAsGroup = False} + | memberRole >= GRModerator = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = True}}, sentAsGroup = False} + | otherwise = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, sentAsGroup = False} +``` + +### 4c. Update NewMessageDeliveryTask +```haskell +data NewMessageDeliveryTask = NewMessageDeliveryTask + { messageId :: MessageId, + taskContext :: DeliveryTaskContext + } + deriving (Show) +``` + +### 4d. MessageDeliveryTask — no change + +`MessageDeliveryTask` stays as-is. It's constructed from DB rows in `getMsgDeliveryTask_` and consumed by relay forwarding code — those consumers need `jobScope` and `fwdSender` directly, not `DeliveryTaskContext`. `DeliveryTaskContext` is only for the path from processing functions → `NewMessageDeliveryTask` creation. + +### 4e. Update Store/Delivery.hs + +**`createMsgDeliveryTask`** (line 71-87): Extract `jobScope` and `sentAsGroup` from `taskContext` instead of separate `jobScope`/`showGroupAsSender` fields. + +**`getMsgDeliveryTask_`** — no change needed (`MessageDeliveryTask` unchanged). + +### 4f. Consumers of MessageDeliveryTask — no change needed + +**Subscriber.hs** lines ~3325-3333 and **Messages/Batch.hs** lines ~77-80 already pattern match on `FwdSender` and use `jobScope` from `MessageDeliveryTask`. Since `MessageDeliveryTask` is unchanged, no updates needed. + +### 4g. Return type changes in processing functions + +All functions currently returning `(Maybe DeliveryJobScope, ShowGroupAsSender)` change to `Maybe DeliveryTaskContext`: +- `groupMessageFileDescription` → `CM (Maybe DeliveryTaskContext)` +- `groupMessageUpdate` → `CM (Maybe DeliveryTaskContext)` +- `groupMessageDelete` → `CM (Maybe DeliveryTaskContext)` +- `xFileCancelGroup` → `CM (Maybe DeliveryTaskContext)` +- `groupMsgReaction` → `CM (Maybe DeliveryTaskContext)` + +Events that return `(Nothing, False)` or `(Just scope, False)` are updated: +- `(Nothing, False)` → `Nothing` +- `(Just scope, False)` → `Just $ DeliveryTaskContext scope False` (or use `memberEventDeliveryContext`) +- `(Just scope, showGroupAsSender)` → `Just $ DeliveryTaskContext scope showGroupAsSender` (or use `infoToDeliveryContext`) + +--- + +## Issue 5: Fix testChannelReactionAttribution {#issue-5} + +**File:** `tests/ChatTests/Groups.hs` lines 9057-9084 + +**Problem:** Comment says "reaction is forwarded as channel (owner is anonymous)" and expects `#team>`. Owner should react **as member** — reactions are always `sentAsGroup = False`. + +**Fix:** Change comment and expectations: +```haskell +-- owner reacts to own member message - reaction is forwarded as member +alice ##> "+1 #team hello" +alice <## "added 👍" +bob <# "#team alice> > alice hello" +bob <## " + 👍" +concurrentlyN_ + [ do cath <# "#team alice> > alice hello" + cath <## " + 👍", + do dan <# "#team alice> > alice hello" + dan <## " + 👍", + do eve <# "#team alice> > alice hello" + eve <## " + 👍" + ] +``` + +--- + +## Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment {#issue-6} + +**File:** `tests/ChatTests/Groups.hs` line 9127 + +**Problem:** Comment says "bob's internally deleted item is still in DB, update finds it with correct member direction". This is wrong — the item was internally deleted, then XMsgUpdate re-creates it via the `catchCINotFound` fallback. + +**Fix:** Change comment to: +```haskell +-- bob's internally deleted item is re-created as from member (sendAsGroup=False) +``` + +--- + +## Other: sendAsGroup parameter ordering {#other-issue} + +**Problem:** `sendAsGroup`/`ShowGroupAsSender` should come right after direction/scope, not at the end. + +### 7a. `APIForwardChatItems` constructor + +**File:** `src/Simplex/Chat/Library/Commands.hs` (ChatCommand type definition + parser) + +Current: `APIForwardChatItems toChat fromChat itemIds itemTTL sendAsGroup` +New: `APIForwardChatItems toChat sendAsGroup fromChat itemIds itemTTL` + +Affects: +- Constructor definition in `src/Simplex/Chat/Controller.hs` line 341 +- Parser at line 4639 +- Call sites at lines 930, 2192, 2198, 2204 + +### 7b. `createNewSndChatItem` + +**File:** `src/Simplex/Chat/Store/Messages.hs` line 528 + +Current: `createNewSndChatItem db user chatDirection msg ciContent quotedItem itemForwarded timed live hasLink showGroupAsSender createdAt` +New: `createNewSndChatItem db user chatDirection showGroupAsSender msg ciContent quotedItem itemForwarded timed live hasLink createdAt` + +Move `showGroupAsSender` right after `chatDirection` (direction context). + +Affects call site in `Internal.hs` line 2276. + +### 7c. `saveSndChatItems` + +**File:** `src/Simplex/Chat/Library/Internal.hs` line 2256-2265 + +Current param order: `user -> cd -> itemsData -> itemTimed -> live -> showGroupAsSender` +New: `user -> cd -> showGroupAsSender -> itemsData -> itemTimed -> live` + +Move `showGroupAsSender` right after `cd` (direction context). + +Affects call sites: Internal.hs line 2242, Commands.hs lines 2561, 2608 (and the `saveSndChatItem'` wrapper at line 2240). + +### 7d. `prepareGroupMsg` + +**File:** `src/Simplex/Chat/Library/Internal.hs` line 203 + +Current: `prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live showGroupAsSender` +New: `prepareGroupMsg db user gInfo msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live` + +Move `showGroupAsSender` right after `msgScope` (scope context). + +Affects call sites: Internal.hs line 1249, Commands.hs line 4094. + +--- + +## Forwarded handler (xGrpMsgForward) changes + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 3136-3173 + +Add `withAuthor` helper to replace ad-hoc `| Just author <- author_` guards: +```haskell +where + withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM () + withAuthor tag action = case author_ of + Just author -> action author + Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" +``` + +Update forwarded event handling: +- `XMsgFileDescr` → pass `author_` (Maybe GroupMember) directly +- `XMsgUpdate` → pass `author_` directly, void result +- `XMsgDel` → pass `author_` directly, void result +- `XMsgReact` → use `withAuthor` (required member) +- `XFileCancel` → pass `author_` directly +- Other events with `| Just author <- author_` → use `withAuthor` + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Delivery.hs` | Add `DeliveryTaskContext`, update `NewMessageDeliveryTask` only | +| `src/Simplex/Chat/Store/Delivery.hs` | Update `createMsgDeliveryTask` to extract from `taskContext` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Eliminate `isChannelOwner`/`memberForChannel`/`memberIdForChannel`; change function signatures to return `Maybe DeliveryTaskContext`; add `withAuthor`; simplify `validSender`; `groupMsgReaction` required member; fix lookup | +| `src/Simplex/Chat/Controller.hs` | Reorder `sendAsGroup` in `APIForwardChatItems` constructor | +| `src/Simplex/Chat/Library/Commands.hs` | Reorder `sendAsGroup` in `APIForwardChatItems` parser + call sites | +| `src/Simplex/Chat/Store/Messages.hs` | Reorder `showGroupAsSender` in `createNewSndChatItem` | +| `src/Simplex/Chat/Library/Internal.hs` | Reorder `showGroupAsSender` in `saveSndChatItems`, `prepareGroupMsg` | +| `src/Simplex/Chat/Messages/Batch.hs` | No change needed (`MessageDeliveryTask` unchanged) | +| `tests/ChatTests/Groups.hs` | Fix reaction test expectations + update fallback comment | + +--- + +## Verification + +1. `cabal build --ghc-options=-O0` — must compile clean +2. Run channel test suite: `cabal test simplex-chat-test --test-option='-m "channels"' --ghc-options=-O0` +3. Adversarial self-review loop until 2 consecutive clean passes +4. Verify no `isChannelOwner` references remain in Subscriber.hs direct handler +5. Verify `groupMsgReaction` signature has required `GroupMember` (no Maybe) +6. Verify no dual-lookup with `catchError` in `groupMessageUpdate` diff --git a/plans/directory-tests-coverage.md b/plans/directory-tests-coverage.md new file mode 100644 index 0000000000..a17d8b379a --- /dev/null +++ b/plans/directory-tests-coverage.md @@ -0,0 +1,79 @@ +# Directory Modules: Test Coverage Report + +## Final Coverage + +| Module | Expressions | Coverage | Gap | +|---|---|---|---| +| **Captcha** | 84/84 | **100%** | -- | +| **Search** | 3/3 | **100%** | -- | +| **BlockedWords** | 158/158 | **100%** | -- | +| **Events** | 527/559 | **94%** | 32 expr | +| **Options** | 223/291 | **76%** | 68 expr | +| **Store** | 1137/1306 | **87%** | 169 expr | +| **Listing** | 379/650 | **58%** | 271 expr | + +84 tests, 0 failures. + +## What was covered + +Tests added to `tests/Bots/DirectoryTests.hs`: + +- **Search**: `SearchRequest` field selectors (`searchType`, `searchTime`, `lastGroup`) +- **BlockedWords**: `BlockedWordsConfig` field selectors, `removeTriples` with `'\0'` input to force initial `False` argument +- **Options**: `directoryOpts` parser via `execParserPure` (minimal args, non-default args, all `MigrateLog` variants), `mkChatOpts` remaining fields +- **Events**: command parser edge cases (`/`, `/filter 1 name=all`, `/submit`, moderate/strong presets), `Show` instances for `DirectoryCmdTag`, `DirectoryCmd`, `SDirectoryRole`, `DirectoryHelpSection`, `DirectoryEvent`, `ADirectoryCmd` (including `showList`), `DCApproveGroup` field selectors via `OverloadedRecordDot`, `CEvtChatErrors` path +- **Store**: `Show` instances for `GroupRegStatus` constructors, `ProfileCondition`, `noJoinFilter`, `GroupReg.createdAt` field +- **Listing**: `DirectoryEntryType` JSON round-trip with field selectors + +Source changes: + +- `Directory/Options.hs`: exported `directoryOpts` +- `Directory/Events.hs`: exported `DirectoryCmdTag (..)` + +## Why not 100% + +### Events (32 expr remaining) + +**Field selectors (9 expr)** on `DEGroupInvitation`, `DEServiceJoinedGroup`, `DEGroupUpdated` -- need `Contact`, `GroupInfo`, `GroupMember` types which have 20+ nested required fields each with no test constructors available. + +**`crDirectoryEvent_` branches (3 expr)**: `DEItemDeleteIgnored`, `DEUnsupportedMessage`, `CEvtMessageError` -- need `AChatItem` or `User`, both strict-data types with deep dependency chains impossible to construct in unit tests. + +**`DCSubmitGroup` paths (2 expr)**: constructor and `directoryCmdTag` case -- need a valid `ConnReqContact` (SMP queue URI with cryptographic keys). + +**Lazy `fail` strings (2 expr)**: `"bad command tag"` and `"bad help section"` -- Attoparsec discards the string argument to `fail` without evaluating it. Inherently uncoverable by HPC. + +### Options (68 expr remaining) + +**Parser metadata strings (~50 expr)**: `metavar` and `help` string literals in `optparse-applicative` option declarations are evaluated lazily by the library. `execParserPure` constructs the parser but doesn't force help strings unless `--help` is invoked. + +**`getDirectoryOpts` (~10 expr)**: wraps `execParser` which reads process `argv` -- can't unit-test without spawning a process. + +**`parseKnownGroup` internals (~8 expr)**: the `--owners-group` arg is parsed but the `KnownContacts` parser internals are instrumented separately. + +### Store (169 expr remaining) + +**DB operations (~150 expr)**: `withDB'` wrappers, SQL query strings, error message literals inside database functions (`setGroupStatusStore`, `setGroupRegOwnerStore`, `searchListedGroups`, `getAllGroupRegs_`, etc.) -- all require a running SQLite database with realistic data. + +**Pagination branches (~15 expr)**: `searchListedGroups` and `getAllGroupRegs_` cursor pagination -- need multi-page result sets. + +**Parser failure (~4 expr)**: `GroupRegStatus` `strDecode` failure path -- needs malformed stored data. + +### Listing (271 expr remaining) + +**Image processing (~80 expr)**: `imgFileData`, image file Base64 encoding paths -- require groups with profile images. + +**Listing generation (~120 expr)**: `generateListing`, `groupDirectoryEntry` -- require `GroupInfo` (21+ fields), `GroupLink`, `CreatedLinkContact` types with deep nesting into chat protocol internals. + +**Field selectors (~40 expr)**: `DirectoryEntry` fields (`displayName`, `fullName`, `image`, `memberCount`, etc.) -- need full `DirectoryEntry` construction which requires `CreatedLinkContact`. + +**TH-generated JSON (~30 expr)**: Template Haskell `deriveJSON` expressions are marked as runtime-uncovered by HPC despite executing at compile time. + +## Summary + +All remaining gaps fall into three categories: + +1. **DB integration paths** -- require a running database (Store) +2. **Complex chat protocol types** -- types with 20+ required nested fields (Events, Listing) +3. **Lazy evaluation artifacts** -- HPC can't observe values that are never forced at runtime (Options `help` strings, Attoparsec `fail` strings, TH-generated code) + +None are testable with pure unit tests without either standing up a database or constructing massive type hierarchies. diff --git a/plans/group_channel_feature_coverage.md b/plans/group_channel_feature_coverage.md new file mode 100644 index 0000000000..f4b6e49353 --- /dev/null +++ b/plans/group_channel_feature_coverage.md @@ -0,0 +1,377 @@ +# Group & Channel Feature Test Coverage Plan + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Feature Coverage Matrix](#feature-coverage-matrix) +3. [Gap Analysis by Category](#gap-analysis-by-category) +4. [Recommended New Tests](#recommended-new-tests) +5. [Implementation Roadmap](#implementation-roadmap) + +--- + +## Executive Summary + +**Current State:** The test suite in `Groups.hs` provides comprehensive coverage across 120+ scenarios in 14 categories. Core functionality (group CRUD, messaging, member management) is well-tested. + +**Key Gaps Identified:** +- Business/contact card group links (untested invitation flow) +- Legacy group link auto-accept path +- Permission enforcement for `SGFFullDelete` +- Error recovery paths (file transfers, database busy, duplicate forwarding) +- Moderator-only scoped message delivery (`DJSMemberSupport`) +- Edge cases in channel message deletion + +**Risk Assessment:** +| Priority | Gap Count | Impact | +|----------|-----------|--------| +| Critical | 3 | Production failures in business flows | +| High | 5 | Feature regressions possible | +| Medium | 4 | Edge case handling incomplete | + +**Recommendation:** Add 12 new test scenarios in 3 phases over 2 sprints. + +--- + +## Feature Coverage Matrix + +### Legend +- ✅ Tested (comprehensive) +- ⚠️ Partial (some paths covered) +- ❌ Untested + +### Core Group Operations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Group creation | ✅ | `testGroup` | Basic + edge cases | +| Group deletion | ✅ | `testGroupDelete*` | Multiple scenarios | +| Group naming/description | ✅ | `testUpdateGroupProfile` | | +| Group preferences | ✅ | `testGroupPreferences` | Voice, files, etc. | +| Group link creation | ✅ | `testGroupLink*` | | +| Group link via contact card | ❌ | - | Business links untested | +| Legacy auto-accept | ❌ | - | Deprecated path | + +### Message Operations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| XMsgNew (send) | ✅ | Multiple | Core flow | +| XMsgUpdate (edit) | ✅ | `testGroupMessageUpdate` | | +| XMsgDel (delete) | ✅ | `testGroupMessageDelete` | | +| XMsgReact | ✅ | `testGroupMsgReaction` | | +| XMsgFileDescr | ✅ | `testGroupFileTransfer` | | +| Batch messages | ✅ | `testBatch*` | | +| Live messages | ✅ | `testGroupLiveMessage` | | +| Quote messages | ✅ | `testGroup*Quote*` | | +| Duplicate forwarding | ❌ | - | De-dup logic untested | + +### Member Management + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Member add | ✅ | `testGroupAddMember*` | | +| Member remove | ✅ | `testGroupRemoveMember*` | | +| Member roles | ✅ | `testGroupMemberRole*` | | +| Member blocking | ✅ | `testGroupBlock*` | | +| Member merging | ✅ | `testMergeMemberContact*` | | +| Member deletion errors | ❌ | - | Error paths missing | +| Contact from member | ✅ | `testCreateMemberContact*` | | + +### Moderation & Full Delete + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Moderate message | ✅ | `testGroupModerate*` | | +| Block for all | ✅ | `testGroupBlockForAll*` | | +| SGFFullDelete enabled | ✅ | `testFullDeleteGroup*` | | +| SGFFullDelete restricted | ❌ | - | Permission checks | + +### Channels & Relays + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| 1-relay delivery | ✅ | `testChannel1Relay*` | | +| 2-relay delivery | ✅ | `testChannel2Relay*` | | +| Owner-only sending | ✅ | `testChannel*Message*` | | +| Identity protection | ✅ | `testChannel*Incognito*` | | +| Channel msg delete errors | ❌ | - | Invalid state handling | + +### Scoped Messages (Support Chats) + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Single moderator | ✅ | `testSupportChat*` | | +| Multi moderator | ✅ | `testSupportChat*Multi*` | | +| Member reports | ✅ | `testReportMessage*` | | +| Forwarding in scope | ✅ | `testSupportChatForward*` | | +| Stats | ✅ | `testSupportChatStats` | | +| DJSMemberSupport delivery | ❌ | - | Moderator-only path | + +### Group Links & Invitations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Create/delete link | ✅ | `testGroupLink*` | | +| Join via link | ✅ | `testGroupLink*` | | +| Link screening | ✅ | `testGroupLink*Screening*` | | +| Connection plans | ✅ | `testPlanGroupLink*` | | +| Short links | ✅ | `testGroupShortLink*` | | +| Business link invitation | ❌ | - | Contact card flow | + +### Error Handling + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| CEGroupNotJoined | ⚠️ | Implicit | Some coverage | +| CEGroupMemberNotFound | ⚠️ | Implicit | Some coverage | +| File transfer errors | ❌ | - | Recovery paths | +| Database busy | ❌ | - | Retry logic | +| Simplex link warnings | ❌ | - | Feature gate | + +### History & Disappearing + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| History on join | ✅ | `testGroupHistory*` | | +| File history | ✅ | `testGroupHistoryFiles` | | +| Disappearing messages | ✅ | `testGroupHistoryDisappear*` | | + +--- + +## Gap Analysis by Category + +### Critical Priority (Production Impact) + +#### 1. Business Group Link via Contact Card +**Location:** `APIAddMember` with `InvitationContact` path +**Risk:** Business users cannot invite via contact cards +**Current State:** Only `InvitationMember` path tested +**Missing Coverage:** +- `processGroupInvitation` with `CTContactRequest` +- Auto-accept flow for business links +- Profile merge on business join + +#### 2. SGFFullDelete Permission Enforcement +**Location:** `canFullDelete`, `checkFullDeleteAllowed` +**Risk:** Non-admins might delete others' messages +**Missing Coverage:** +- `SGFFullDelete` set to `FAAdmins` restriction +- Error `CECommandError` when non-admin attempts full delete +- Role-based permission matrix + +#### 3. DJSMemberSupport Delivery Path +**Location:** `deliverGroupMessages`, `groupMsgDeliveryJobs` +**Risk:** Support messages not reaching moderators correctly +**Missing Coverage:** +- `DJSMemberSupport` job creation +- Moderator-only broadcast logic +- Scope isolation verification + +### High Priority (Feature Regressions) + +#### 4. Channel Message Deletion Errors +**Location:** `apiDeleteMemberChatItem`, `deleteGroupChatItemInternal` +**Missing Coverage:** +- Delete non-existent channel message +- Delete by non-owner in channel +- `CEInvalidChatItemDelete` error path + +#### 5. Member Deletion Error Paths +**Location:** `removeMemberDeleteItem`, `deleteGroupChatItem` +**Missing Coverage:** +- Delete item for already-removed member +- Concurrent deletion race condition +- `CEGroupMemberNotFound` specific handling + +#### 6. File Transfer Error Recovery +**Location:** `rcvFileError`, `sndFileError` +**Missing Coverage:** +- Partial transfer resume +- `CEFileTransferError` handling +- Cleanup on failed transfers + +#### 7. Legacy Group Link Auto-Accept +**Location:** `processGroupInvitation`, `autoAcceptGroupLink` +**Risk:** Breaking change for older clients +**Missing Coverage:** +- V1 protocol compatibility +- Auto-accept timing + +#### 8. Duplicate Message Forwarding +**Location:** `forwardGroupMessage`, `checkDuplicateForward` +**Missing Coverage:** +- Same message forwarded twice +- De-duplication by `sharedMsgId` +- UI state consistency + +### Medium Priority (Edge Cases) + +#### 9. Simplex Links Feature Warnings +**Location:** `simplexLinkWarning`, `SGFSimplexLinks` +**Missing Coverage:** +- Warning when feature disabled +- Link detection in messages +- User preference override + +#### 10. Database Busy Error Handling +**Location:** `withTransaction`, `retryOnBusy` +**Missing Coverage:** +- Concurrent group operations +- Retry exhaustion +- State consistency after retry + +#### 11. Invalid Channel/Member Scope Errors +**Location:** `validateGroupChatScope`, `scopeNotAllowed` +**Missing Coverage:** +- Member sending to wrong scope +- Scope mismatch on receive +- `CECommandError "scope not allowed"` path + +#### 12. Contact Card Profile Merge +**Location:** `mergeMemberContactProfile`, `updateContactProfile` +**Missing Coverage:** +- Profile conflict resolution +- Image merge logic +- Display name precedence + +--- + +## Recommended New Tests + +### Phase 1: Critical (Sprint 1) + +```haskell +-- Test 1: Business Group Link Invitation +testBusinessGroupLinkInvitation :: HasCallStack => TestParams -> IO () +-- Covers: InvitationContact path, CTContactRequest, auto-accept + +-- Test 2: Full Delete Permission Restriction +testFullDeletePermissionRestricted :: HasCallStack => TestParams -> IO () +-- Covers: SGFFullDelete FAAdmins, non-admin rejection, CECommandError + +-- Test 3: Moderator-Only Support Delivery +testSupportChatModeratorOnlyDelivery :: HasCallStack => TestParams -> IO () +-- Covers: DJSMemberSupport, moderator broadcast, scope isolation +``` + +### Phase 2: High (Sprint 1-2) + +```haskell +-- Test 4: Channel Message Delete Errors +testChannelMessageDeleteErrors :: HasCallStack => TestParams -> IO () +-- Covers: non-existent delete, non-owner delete, CEInvalidChatItemDelete + +-- Test 5: Member Deletion Error Paths +testMemberDeletionErrorPaths :: HasCallStack => TestParams -> IO () +-- Covers: removed member delete, concurrent delete, CEGroupMemberNotFound + +-- Test 6: File Transfer Error Recovery +testGroupFileTransferErrorRecovery :: HasCallStack => TestParams -> IO () +-- Covers: partial resume, CEFileTransferError, cleanup + +-- Test 7: Legacy Group Link Compatibility +testLegacyGroupLinkAutoAccept :: HasCallStack => TestParams -> IO () +-- Covers: V1 protocol, auto-accept timing + +-- Test 8: Duplicate Forward Prevention +testDuplicateMessageForwardPrevention :: HasCallStack => TestParams -> IO () +-- Covers: duplicate detection, sharedMsgId, UI consistency +``` + +### Phase 3: Medium (Sprint 2) + +```haskell +-- Test 9: Simplex Links Feature Warning +testSimplexLinksFeatureWarning :: HasCallStack => TestParams -> IO () +-- Covers: disabled feature warning, link detection + +-- Test 10: Database Busy Retry +testGroupOperationsDatabaseBusy :: HasCallStack => TestParams -> IO () +-- Covers: concurrent ops, retry logic, state consistency + +-- Test 11: Scope Validation Errors +testGroupChatScopeValidationErrors :: HasCallStack => TestParams -> IO () +-- Covers: wrong scope send, scope mismatch, CECommandError + +-- Test 12: Contact Card Profile Merge +testMemberContactProfileMerge :: HasCallStack => TestParams -> IO () +-- Covers: conflict resolution, image merge, name precedence +``` + +--- + +## Implementation Roadmap + +### Sprint 1 (Week 1-2) + +| Day | Task | Owner | Deliverable | +|-----|------|-------|-------------| +| 1-2 | Test 1: Business link | - | PR ready | +| 3-4 | Test 2: Full delete perms | - | PR ready | +| 5 | Test 3: Moderator delivery | - | PR ready | +| 6-7 | Test 4: Channel delete errors | - | PR ready | +| 8-9 | Test 5: Member delete errors | - | PR ready | +| 10 | Integration + Review | - | Merged | + +### Sprint 2 (Week 3-4) + +| Day | Task | Owner | Deliverable | +|-----|------|-------|-------------| +| 1-2 | Test 6: File error recovery | - | PR ready | +| 3-4 | Test 7: Legacy link compat | - | PR ready | +| 5-6 | Test 8: Duplicate forward | - | PR ready | +| 7-8 | Tests 9-12: Medium priority | - | PR ready | +| 9-10 | Final integration + CI | - | Release | + +### Dependencies + +``` +Test 1 (Business Link) ─┬─> Test 12 (Profile Merge) + │ +Test 3 (Moderator) ─────┴─> Test 11 (Scope Validation) + +Test 4 (Channel Delete) ──> Test 5 (Member Delete) + +Test 6 (File Error) ──────> (standalone) + +Test 7 (Legacy Link) ─────> Test 1 (Business Link) + +Test 8 (Duplicate) ───────> (standalone) + +Tests 9, 10 ──────────────> (standalone) +``` + +### Success Criteria + +1. **Coverage Target:** 95%+ of identified gaps covered +2. **CI Integration:** All tests in nightly suite +3. **Documentation:** Test rationale in docstrings +4. **No Regressions:** Existing 120+ tests still pass + +### Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Test flakiness | Use explicit waits, avoid timing assumptions | +| Database state leaks | Ensure proper cleanup in each test | +| Protocol version issues | Test both V1 and V2 where applicable | +| CI timeout | Parallelize independent tests | + +--- + +## Appendix: Test File Locations + +| Test Category | Primary File | Secondary | +|---------------|--------------|-----------| +| Group Core | `tests/ChatTests/Groups.hs` | - | +| Channels | `tests/ChatTests/Groups.hs` | `Channels/` if split | +| Support Chats | `tests/ChatTests/Groups.hs` | `ScopedMessages/` if split | +| File Transfers | `tests/ChatTests/Files.hs` | `Groups.hs` | +| Error Handling | Inline with feature tests | - | + +--- + +*Generated: 2026-02-06* +*Branch: ep/channel-messages-2* +*Coverage baseline: 120+ scenarios, 14 categories* diff --git a/plans/groups_coverage_fill_plan.md b/plans/groups_coverage_fill_plan.md new file mode 100644 index 0000000000..ffe0b7a52c --- /dev/null +++ b/plans/groups_coverage_fill_plan.md @@ -0,0 +1,368 @@ +# Plan: Filling Group/Channel Test Coverage Gaps + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Test File Organization](#test-file-organization) +3. [Priority 0: Critical Channel Paths](#priority-0-critical-channel-paths) +4. [Priority 1: Error and Fallback Paths](#priority-1-error-and-fallback-paths) +5. [Priority 2: Scope-Related Features](#priority-2-scope-related-features) +6. [Priority 3: Feature Restrictions](#priority-3-feature-restrictions) + +--- + +## Executive Summary + +This plan addresses the coverage gaps identified in `groups_test_coverage.md`, focusing exclusively on DSL-based scenario tests using the existing test infrastructure. All tests follow patterns established in `tests/ChatTests/Groups.hs`. + +**Excluded from scope:** JSON serialization tests (per user request). + +**Key gap categories:** +- Non-channel-owner members sending in channel groups +- Moderation/delete paths in channels (`memberDelete`) +- Error fallback paths (`catchCINotFound`) +- Member support scope (`GCSIMemberSupport`) +- Full-delete feature, live updates, mentions + +--- + +## Test File Organization + +All new tests go in `tests/ChatTests/Groups.hs` under existing or new `describe` blocks. + +### New `describe` blocks to add: + +```haskell +describe "channel moderation" $ do + -- Tests for memberDelete path, channel moderation errors + +describe "channel error paths" $ do + -- Tests for catchCINotFound, invalid sender, etc. + +describe "channel mentions" $ do + -- Tests for mentions in channel messages + +describe "group full delete feature" $ do + -- Tests for SGFFullDelete enabled +``` + +--- + +## Priority 0: Critical Channel Paths + +### Test 1: `testChannelMemberModerate` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel moderation"` + +**Objective:** Cover `memberDelete` path in `groupMessageDelete` (lines 2016-2076) - moderation of channel messages by admin/owner. + +**Scenario:** +1. Create channel with owner (alice) + relay (bob) + members (cath, dan) +2. Owner sends channel message +3. Admin/owner moderates (deletes) the channel message +4. Verify message marked deleted for all members +5. Verify moderation event is forwarded + +**Coverage targets:** +- `memberDelete` function execution +- `moderate` helper with role checks +- `delete` with `delMember_` populated + +--- + +### Test 2: `testChannelMemberDeleteError` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover error path `CIChannelRcv -> messageError "x.msg.del: unexpected channel message in member delete"` (line 2036). + +**Scenario:** +1. Create channel with owner + relay + member +2. Attempt to trigger memberDelete on CIChannelRcv item (malformed delete request) +3. Verify error is logged/handled correctly + +**Coverage targets:** +- Line 2036: `CIChannelRcv` error case in `memberDelete` + +--- + +### Test 3: `testChannelUpdateNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `catchCINotFound` fallback in `groupMessageUpdate` (lines 1950-1969) - update arrives for locally deleted item. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message, member receives +3. Member locally deletes the message +4. Owner updates the message +5. Verify member creates new item from update (fallback path) + +**Coverage targets:** +- Line 1960: `Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing)` +- Lines 1951-1969: create-from-update fallback + +--- + +### Test 4: `testChannelReactionNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `catchCINotFound` fallback in `groupMsgReaction` (lines 1823-1837) - reaction on locally deleted item. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message, member receives +3. Member locally deletes the message +4. Owner adds reaction +5. Verify reaction is handled without crash + +**Coverage targets:** +- Lines 1835-1837: channel reaction fallback + +--- + +### Test 5: `testChannelForwardedMessages` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "relay delivery"` (existing) + +**Objective:** Cover `FwdChannel` branch in delivery task (line 3311) and forwarded message parameters. + +**Scenario:** +1. Create channel with owner + 2 relays + members +2. Send various message types (new, update, delete, reaction) +3. Verify all are forwarded through relay chain +4. Check forwarded parameters are correctly passed + +**Coverage targets:** +- Line 3311: `FwdChannel -> (Nothing, Nothing)` +- Lines 3139-3145: forwarded message handlers + +--- + +## Priority 1: Error and Fallback Paths + +### Test 6: `testGroupDeleteNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` or existing moderation tests + +**Objective:** Cover delete error when message not found (line 2039). + +**Scenario:** +1. Create group with alice, bob +2. Bob sends message +3. Alice locally deletes it +4. Bob broadcasts delete for the same message +5. Verify error path is handled + +**Coverage targets:** +- Line 2039: `messageError ("x.msg.del: message not found, " <> tshow e)` + +--- + +### Test 7: `testGroupInvalidSenderUpdate` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `validSender _ _ = False` (line 1874) and update from wrong member error (line 1980). + +**Scenario:** +1. Create group with alice, bob, cath +2. Bob sends message +3. Cath (with spoofed member ID) attempts to update bob's message +4. Verify error is thrown + +**Coverage targets:** +- Line 1874: `validSender _ _ = False` +- Line 1980: `messageError "x.msg.update: group member attempted to update..."` + +--- + +### Test 8: `testGroupReactionDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** existing `describe "group message reactions"` + +**Objective:** Cover reaction disabled path (line 1839). + +**Scenario:** +1. Create group with reactions feature disabled +2. Member attempts to add reaction +3. Verify reaction is rejected + +**Coverage targets:** +- Line 1839: `otherwise = pure Nothing` when reactions not allowed + +--- + +### Test 9: `testChannelItemNotChanged` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel message operations"` (existing) + +**Objective:** Cover `CEvtChatItemNotChanged` path (lines 2001-2002) - update with same content. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message +3. Owner "updates" message with identical content +4. Verify no change event is emitted + +**Coverage targets:** +- Lines 2001-2002: `CEvtChatItemNotChanged` path + +--- + +## Priority 2: Scope-Related Features + +### Test 10: `testScopedSupportMentions` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover mentions in scoped support messages (`getRcvCIMentions` with non-empty mentions). + +**Scenario:** +1. Create group with alice (owner), bob (member), dan (moderator) +2. Bob sends support message mentioning @alice +3. Alice receives with mention highlighted +4. Verify `userMention` flag is set correctly + +**Coverage targets:** +- Line 2316: `getRcvCIMentions` with actual mentions +- Line 2319: `sameMemberId mId membership` in userReply check +- Lines 279-281: `uniqueMsgMentions` path + +--- + +### Test 11: `testMemberChatStats` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover `memberChatStats` function (lines 2323-2330) for both `CDGroupRcv` and `CDChannelRcv` with scope. + +**Scenario:** +1. Create group with support enabled +2. Member sends support message +3. Verify unread stats are updated +4. Verify `memberAttentionChange` is computed + +**Coverage targets:** +- Lines 2325-2329: `memberChatStats` branches +- Line 2621: `memberAttentionChange` + +**Note:** Tests `testScopedSupportUnreadStatsOnRead` and `testScopedSupportUnreadStatsOnDelete` exist but may not cover all branches. + +--- + +### Test 12: `testMkGetMessageChatScope` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover `mkGetMessageChatScope` branches (lines 1599-1617). + +**Scenario:** +1. Create group with pending member (knocking) +2. Pending member sends message with scope +3. Verify correct scope resolution +4. Test with `isReport mc` content type + +**Coverage targets:** +- Line 1601: `Just _scopeInfo` return +- Line 1604: `isReport mc` branch +- Lines 1610-1617: `sameMemberId` and `otherwise` branches + +--- + +## Priority 3: Feature Restrictions + +### Test 13: `testGroupFullDelete` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** new `describe "group full delete feature"` + +**Objective:** Cover `groupFeatureAllowed SGFFullDelete` = True path (line 2067) - `deleteGroupCIs` instead of `markGroupCIsDeleted`. + +**Scenario:** +1. Create group with full delete enabled: `/set delete #team on` +2. Bob sends message +3. Alice (or bob) deletes message +4. Verify message is fully deleted (not just marked) + +**Coverage targets:** +- Line 2067: `deleteGroupCIs` path +- `groupFeatureAllowed SGFFullDelete` returns True + +--- + +### Test 14: `testGroupLiveMessage` +**File:** `tests/ChatTests/Groups.hs` +**Note:** `testGroupLiveMessage` exists but may not cover update path. + +**Objective:** Cover live message update path (line 830 in View.hs, `itemLive == Just True`). + +**Scenario:** +1. Create group +2. Send live message +3. Update live message content +4. Verify live update is processed + +**Coverage targets:** +- Line 830: `itemLive == Just True && not liveItems -> []` +- Live update in `groupMessageUpdate` + +--- + +### Test 15: `testGroupVoiceDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** existing tests or new `describe "group feature restrictions"` + +**Objective:** Cover voice message rejection (line 342 in Internal.hs). + +**Scenario:** +1. Create group with voice disabled: `/set voice #team off` +2. Member attempts to send voice message +3. Verify rejection + +**Coverage targets:** +- Line 342: `isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo)` + +--- + +### Test 16: `testGroupReportsDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group member reports"` (existing) + +**Objective:** Cover reports disabled path (line 344 in Internal.hs). + +**Scenario:** +1. Create group with reports disabled +2. Member attempts to send report +3. Verify rejection + +**Coverage targets:** +- Line 344: `isReport mc && ... not (groupFeatureAllowed SGFReports gInfo)` + +--- + +## Implementation Order + +1. **Phase 1 (P0):** Tests 1-5 - Critical channel paths +2. **Phase 2 (P1):** Tests 6-9 - Error and fallback paths +3. **Phase 3 (P2):** Tests 10-12 - Scope-related features +4. **Phase 4 (P3):** Tests 13-16 - Feature restrictions + +Each test should: +- Use existing DSL operators (`##>`, `<#`, `#$>`, etc.) +- Follow naming convention `test` +- Include `HasCallStack` constraint +- Use appropriate test helpers (`createGroup2`, `createChannel1Relay`, etc.) + +--- + +## Dependencies + +- Existing test infrastructure in `ChatTests.Utils` +- Helper functions: `createChannel1Relay`, `createGroup2`, `createGroup3`, etc. +- DSL operators for assertions + +## Estimated New Tests: 16 + +## Files Modified: 1 +- `tests/ChatTests/Groups.hs` diff --git a/plans/groups_test_coverage.md b/plans/groups_test_coverage.md new file mode 100644 index 0000000000..7ee01f1d6f --- /dev/null +++ b/plans/groups_test_coverage.md @@ -0,0 +1,441 @@ +# Group/Channel Test Coverage Analysis + +Coverage run: `cabal test simplex-chat-test --enable-coverage --ghc-options=-O0 --test-options="-m group"` + +Full 164 group tests executed (151 passed, 13 failed due to unrelated issues). + +## Coverage Summary + +After running all group tests: +- Expressions: 48% +- Alternatives: 33% +- Local declarations: 64% +- Top-level: 34% + +--- + +## What IS Covered (Channel-Specific Paths) + +- `createNewRcvChatItem` with `CDChannelRcv` - channel message creation +- `toGroupChatItem` with `showGroupAsSender = True` - channel message reading +- `validSender Nothing CIChannelRcv = True` - channel sender validation +- `getGroupChatItemBySharedMsgId` with `Nothing` memberId (`IS NOT DISTINCT FROM`) +- `toCIDirection CDChannelRcv -> CIChannelRcv` +- `toChatInfo CDChannelRcv g s -> GroupChat g s` +- `chatItemMember CIChannelRcv -> Nothing` +- `viewChatItem` for both `CIGroupRcv` and `CIChannelRcv` +- `viewItemReaction` dispatch to `groupReaction` for both constructors +- Channel delete happy path (`channelDelete` -> `delete Nothing`) + +--- + +## Uncovered Code Paths + +### 1. Subscriber.hs + +#### `processGroupMessage` dispatch (lines 935-972) + +| Line | Code | Status | +|------|------|--------| +| 956 | `asGroup == Just True && memberRole' m'' < GROwner` | tickonlyfalse - rejecting non-owner sending as group never tested | +| 963 | `ttl` parameter in `groupMessageUpdate` | nottickedoff | +| 965 | `scope_` parameter in `groupMsgReaction` | nottickedoff | +| 967 | `XFile` handler | nottickedoff | +| 970 | `XFileAcptInv` handler | nottickedoff | +| 987 | `XGrpPrefs` handler | nottickedoff | +| 993 | `BFileChunk` handler | nottickedoff | +| 994 | Catch-all `_` for unsupported messages | nottickedoff | + +#### `memberCanSend` / `memberCanSend'` (lines 1446-1454) + +| Line | Code | Status | +|------|------|--------| +| 1449 | `memberPending m` part of condition | tickonlytrue - never false | +| 1450 | `otherwise` branch (error "member is not allowed to send messages") | nottickedoff | + +#### `newGroupContentMessage` (lines 1876-1940) + +| Line | Code | Status | +|------|------|--------| +| 1879 | `vr` parameter in `mkGetMessageChatScope` | nottickedoff | +| 1882 | `ft_` and `False` parameters to `prohibitedGroupContent` | nottickedoff | +| 1883 | `rejected` helper invocation | nottickedoff | +| 1895 | `mentions` parameter for channel messages | nottickedoff | +| 1896 | `pure []` for reactions when `sharedMsgId_` is Nothing | nottickedoff | +| 1901 | `rejected` function body | nottickedoff | +| 1902 | `Just Nothing` for timed_ when forwarded | nottickedoff | +| 1910 | `M.empty` for mentions when blocked | tickonlyfalse | +| 1914 | `gInfo'` and `m'` params to `processFileInv` | nottickedoff | + +#### `groupMessageUpdate` (lines 1943-2002) + +| Line | Code | Status | +|------|------|--------| +| 1960 | `Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing)` | nottickedoff - channel catchCINotFound | +| 1967 | `CDChannelRcv {} -> pure ci'` | nottickedoff | +| 1977 | `mentions' = if memberBlocked m then []` | tickonlyfalse | +| 1980 | `otherwise -> messageError "x.msg.update: group member attempted to update..."` | nottickedoff | +| 1984 | `messageError "x.msg.update: invalid message update"` | nottickedoff | +| 2001-2002 | `CEvtChatItemNotChanged` path | nottickedoff | + +#### `groupMessageDelete` (lines 2004-2076) + +**channelDelete path:** +| Line | Code | Status | +|------|------|--------| +| 2013 | `messageError "x.msg.del: invalid channel message delete"` | nottickedoff | +| 2015 | `messageError ("x.msg.del: channel message not found, " <> tshow e)` | nottickedoff | + +**memberDelete path:** +| Line | Code | Status | +|------|------|--------| +| 2028 | `messageError "x.msg.del: member attempted invalid message delete"` | tickonlyfalse | +| 2036 | `CIChannelRcv -> messageError "x.msg.del: unexpected channel message..."` | nottickedoff | +| 2039 | `messageError ("x.msg.del: message not found, " <> tshow e)` | tickonlyfalse | +| 2041-2042 | `messageError "...message of another member with insufficient..."` | tickonlyfalse | +| 2044-2047 | `createCIModeration` scoped moderation path | nottickedoff | + +**moderate helper:** +| Line | Code | Status | +|------|------|--------| +| 2058 | `messageError "x.msg.del: message of another member with incorrect memberId"` | nottickedoff | +| 2059 | `messageError "x.msg.del: message of another member without memberId"` | nottickedoff | +| 2062 | `messageError "...insufficient member permissions"` | tickonlyfalse | + +#### `groupMsgReaction` (lines 1818-1860) + +| Line | Code | Status | +|------|------|--------| +| 1823-1837 | Entire `catchCINotFound` fallback | nottickedoff | +| 1825-1831 | Scoped reaction path for member with scope | nottickedoff | +| 1832-1834 | Regular group reaction when item not found | nottickedoff | +| 1835-1837 | Channel reaction when item not found | nottickedoff | +| 1839 | `otherwise = pure Nothing` when reactions not allowed | tickonlyfalse | +| 1859 | `Nothing` return for channel (`isJust m_` is False) | nottickedoff | +| 1860 | `pure Nothing` when `ciReactionAllowed` is False | nottickedoff | + +#### `validSender` (lines 1871-1874) + +| Line | Code | Status | +|------|------|--------| +| 1872 | `validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m` | nottickedoff | +| 1873 | `validSender Nothing CIChannelRcv = True` | **covered** | +| 1874 | `validSender _ _ = False` | nottickedoff | + +#### `processForwardedMsg` / `xGrpMsgForward` (lines 3127-3153) + +| Line | Code | Status | +|------|------|--------| +| 3133 | `(const Nothing)` wrapper | nottickedoff | +| 3139 | `mentions`, `msgScope`, `ttl`, `live`, `True` to `groupMessageUpdate` | nottickedoff | +| 3141 | `scope_` and `rcvMsg` to `groupMessageDelete` | nottickedoff | +| 3143 | `scope_` to `groupMsgReaction` | nottickedoff | +| 3145 | `XInfo` handler when `author_` is Just | nottickedoff | +| 3152 | `XGrpPrefs` forwarding | nottickedoff | +| 3153 | Catch-all error for unsupported forwarded event | nottickedoff | +| 3311 | `FwdChannel -> (Nothing, Nothing)` | nottickedoff | + +--- + +### 2. View.hs + +#### `viewChatItem` (line 646) + +| Line | Code | Status | +|------|------|--------| +| 555 | `groupNtf user g mention` - `mention` parameter for channel | nottickedoff | +| 673 | `showSndItemProhibited to` for `CISndGroupInvitation` | nottickedoff | +| 674 | `showSndItem to` fallback for GroupChat | nottickedoff | +| 682 | `CIRcvIntegrityError` in group context | nottickedoff | +| 683 | `CIRcvGroupInvitation` with `isJust m_` guard | nottickedoff | +| 684 | `CIRcvModerated` in group context | nottickedoff | +| 685 | `CIRcvBlocked` in group context | nottickedoff | +| 686 | `showRcvItem from` fallback | nottickedoff | +| 691 | `forwardedFrom` in context computation | nottickedoff | + +#### `viewItemUpdate` (line 798) + +| Line | Code | Status | +|------|------|--------| +| 819 | `CIGroupRcv m -> updGroupItem (Just m)` | nottickedoff | +| 822 | `CIGroupSnd _ -> []` fallback | nottickedoff | +| 825 | `ttyToGroup g scopeInfo` (non-edited send path) | nottickedoff | +| 830 | `itemLive == Just True && not liveItems -> []` | tickonlyfalse | +| 832 | `_ -> []` fallback for non-message content | nottickedoff | +| 834 | `ttyFromGroup g scopeInfo m_` (non-edited receive path) | nottickedoff | +| 837 | `forwardedFrom` in context | nottickedoff | +| 838 | `groupQuote g` in context | nottickedoff | + +#### `viewItemReaction` (line 890) + +| Line | Code | Status | +|------|------|--------| +| 898-899 | `sentByMember' g itemDir` in both CIGroupRcv and CIChannelRcv | nottickedoff | +| 913 | `groupReaction _ -> []` (non-message-content fallback) | nottickedoff | +| 917 | `else sentBy` branch when `showGroupAsSender` is False | nottickedoff | +| 958 | `sentByMember'` function | **entirely nottickedoff** | +| 962 | `CIChannelRcv -> Nothing` in sentByMember' | nottickedoff | + +#### `viewItemDelete` (line 869) + +| Line | Code | Status | +|------|------|--------| +| 880 | `_ -> prohibited` in GroupChat branch | nottickedoff | + +#### `viewGroupChatItemsDeleted` (line 866) + +| Line | Code | Status | +|------|------|--------| +| 158 | `member_` parameter | nottickedoff | +| 866 | `maybe "" (\m -> " " <> ttyMember m) member_` - empty string fallback | nottickedoff | +| - | Entire function | **entirely nottickedoff** | + +#### `groupScopeInfoStr` (line 2785) + +| Line | Code | Status | +|------|------|--------| +| - | `Just (GCSIMemberSupport {groupMember_}) -> ...` | nottickedoff | +| - | `Nothing -> "(support)"` sub-branch | nottickedoff | +| - | `Just m -> "(support: " <> viewMemberName m <> ")"` sub-branch | nottickedoff | + +#### Scope info display + +| Line | Code | Status | +|------|------|--------| +| 2768 | `groupScopeInfoStr scopeInfo` in `ttyToGroup` | nottickedoff | +| 2779 | `groupScopeInfoStr scopeInfo` in `ttyToGroupEdited` | nottickedoff | +| 2782 | `groupScopeInfoStr scopeInfo` in `fromGroupAttention_` | nottickedoff | + +#### Other display functions + +| Line | Code | Status | +|------|------|--------| +| 625 | `GroupChat g scopeInfo -> [" " <> ttyToGroup g scopeInfo]` | nottickedoff | +| 766 | `(SMDSnd, GroupChat gInfo _scopeInfo) -> Just $ "you #" <> ...` | nottickedoff | +| 767 | `(SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> ...` | nottickedoff | +| 936 | `viewReactionMembers` | **entirely nottickedoff** | +| 1020 | `viewChatCleared` GroupChat branch | nottickedoff | + +--- + +### 3. Internal.hs + +#### `saveRcvChatItem'` (lines 2294-2340) + +| Line | Code | Status | +|------|------|--------| +| 2288 | `M.empty` for non-group mentions | nottickedoff | +| 2299 | `groupMentions` parameters `db` and `membership` | nottickedoff | +| 2300 | `_ -> pure (M.empty, False)` for non-group | nottickedoff | +| 2303 | `contactChatDeleted cd` | tickonlyfalse | +| 2303 | `vr` parameter in `updateChatTsStats` | nottickedoff | +| 2304 | `else pure $ toChatInfo cd` | nottickedoff | +| 2316 | `getRcvCIMentions` - `db`, `user`, `mentions` parameters | nottickedoff | +| 2319 | `sameMemberId mId membership` in userReply check | nottickedoff | +| 2320 | `\CIMention {memberId} -> sameMemberId memberId membership` | nottickedoff | +| 2311 | `createGroupCIMentions db g ci mentions'` | nottickedoff (mentions always empty) | + +#### `memberChatStats` (line 2323) + +| Line | Code | Status | +|------|------|--------| +| 2325-2327 | `CDGroupRcv _g (Just scope) m -> ...` | nottickedoff | +| 2328-2329 | `CDChannelRcv _g (Just scope) -> ...` | nottickedoff | +| 2330 | `_ -> Nothing` | nottickedoff | +| - | Entire function | **entirely nottickedoff** | + +#### `memberAttentionChange` (line 2621) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### `getRcvCIMentions` (line 277) + +| Line | Code | Status | +|------|------|--------| +| 279 | `not (null ft) && not (null mentions) -> ...` | nottickedoff | +| 280 | `uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft` | nottickedoff | +| 281 | `mapM (getMentionedMemberByMemberId db user groupId) mentions'` | nottickedoff | + +#### `uniqueMsgMentions` (line 286) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### `prepareGroupMsg` / `quoteData` (line 204) + +| Line | Code | Status | +|------|------|--------| +| 209 | `MCForward $ ExtMsgContent ...` forward branch | nottickedoff | +| 227 | `CIGroupSnd` with `showGroupAsSender` False | nottickedoff | +| 228 | `CIGroupRcv m -> pure (qmc, CIQGroupRcv $ Just m, False, Just m)` | nottickedoff | + +#### `mkGetMessageChatScope` (lines 1599-1617) + +| Line | Code | Status | +|------|------|--------| +| 1601 | `groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope` | nottickedoff | +| 1604 | `isReport mc -> ...` | tickonlyfalse | +| 1610 | `sameMemberId mId membership -> ...` | nottickedoff | +| 1614 | `otherwise -> do referredMember <- ...` | nottickedoff | +| 1614 | `vr` parameter in `getGroupMemberByMemberId` | nottickedoff | + +#### `mkGroupSupportChatInfo` (line 1620) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### Feature checks (tickonlyfalse - never true) + +| Line | Code | Status | +|------|------|--------| +| 342 | `isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo)` | tickonlyfalse | +| 344 | `isReport mc && ... not (groupFeatureAllowed SGFReports gInfo)` | tickonlyfalse | +| 485 | `isACIUserMention deletedChatItem` | tickonlyfalse | +| 1593 | `memberPending m` | tickonlyfalse | + +#### `sendGroupMessages` (line 1986) + +| Line | Code | Status | +|------|------|--------| +| 1989 | `sendProfileUpdate catchAllErrors eToView` | nottickedoff | +| 1995 | `isJust scope = False` branch | nottickedoff | + +--- + +### 4. Messages.hs + +#### JSON direction functions - ALL ENTIRELY UNTESTED + +**`jsonCIDirection` (lines 314-321):** +| Line | Code | Status | +|------|------|--------| +| 315 | `CIDirectSnd -> JCIDirectSnd` | nottickedoff | +| 316 | `CIDirectRcv -> JCIDirectRcv` | nottickedoff | +| 317 | `CIGroupSnd -> JCIGroupSnd` | nottickedoff | +| 318 | `CIGroupRcv m -> JCIGroupRcv m` | nottickedoff | +| 319 | `CIChannelRcv -> JCIChannelRcv` | nottickedoff | +| 320 | `CILocalSnd -> JCILocalSnd` | nottickedoff | +| 321 | `CILocalRcv -> JCILocalRcv` | nottickedoff | + +**`jsonACIDirection` (lines 324-331):** +| Line | Code | Status | +|------|------|--------| +| 325-331 | All branches including `JCIChannelRcv -> ACID SCTGroup SMDRcv CIChannelRcv` | nottickedoff | + +**`jsonCIQDirection` (lines 646-651):** +| Line | Code | Status | +|------|------|--------| +| 647 | `CIQDirectSnd -> JCIDirectSnd` | nottickedoff | +| 648 | `CIQDirectRcv -> JCIDirectRcv` | nottickedoff | +| 649 | `CIQGroupSnd -> JCIGroupSnd` | nottickedoff | +| 650 | `CIQGroupRcv (Just m) -> JCIGroupRcv m` | nottickedoff | +| 651 | `CIQGroupRcv Nothing -> JCIChannelRcv` | nottickedoff | + +**`jsonACIQDirection` (lines 654-661):** +| Line | Code | Status | +|------|------|--------| +| 655-659 | All branches including `JCIChannelRcv -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv Nothing` | nottickedoff | +| 660 | `JCILocalSnd -> Left "unquotable"` | nottickedoff | +| 661 | `JCILocalRcv -> Left "unquotable"` | nottickedoff | + +**ToJSON/FromJSON instances:** +| Line | Code | Status | +|------|------|--------| +| 1469-1470 | `CIDirection` ToJSON | nottickedoff | +| 1473 | `CCIDirection` FromJSON | nottickedoff | +| 1476 | `ACIDirection` FromJSON | nottickedoff | +| 1479 | `CIQDirection` FromJSON | nottickedoff | +| 1482-1483 | `CIQDirection` ToJSON | nottickedoff | + +#### Other Messages.hs functions + +| Line | Code | Status | +|------|------|--------| +| 372-375 | `chatItemRcvFromMember` | partially covered - `_ -> Nothing` nottickedoff | +| 403 | `toCIDirection CDLocalRcv _ -> CILocalRcv` | nottickedoff | +| 413 | `toChatInfo CDLocalRcv l -> LocalChat l` | nottickedoff | +| 486 | `aChatItemRcvFromMember` | nottickedoff | +| 665 | `quoteMsgDirection CIQDirectSnd -> MDSnd` | nottickedoff | +| 666 | `quoteMsgDirection CIQDirectRcv -> MDRcv` | nottickedoff | + +--- + +### 5. Store/Messages.hs + +#### Scope-filtered query functions - ALL ENTIRELY UNTESTED + +| Function | Lines | Status | +|----------|-------|--------| +| `findGroupChatPreviews_` | 862-900 | nottickedoff | +| `getChatContentTypes` | 1183-1197 | nottickedoff | +| `getChatItemIDs` | 1476-1505 | nottickedoff | +| `queryUnreadGroupItems` | 1686-1707 | nottickedoff | +| `updateSupportChatItemsRead` | 2038-2077 | nottickedoff | +| `getGroupUnreadTimedItems` | 2080-2102 | nottickedoff | +| `getGroupMemberCIBySharedMsgId` | 2950-2960 | nottickedoff | + +#### `toGroupChatItem` (lines 2327-2337) + +| Line | Code | Status | +|------|------|--------| +| 2329 | `CIChannelRcv` with file | **covered** | +| 2332 | `CIChannelRcv` without file | **covered** | +| 2334 | `CIGroupRcv member` with file | nottickedoff | +| 2336 | `CIGroupRcv member` without file | nottickedoff | +| 2337 | `badItem` fallback | nottickedoff | +| 2321 | `deletedByGroupMember_` parsing | nottickedoff | + +#### `getChatItemQuote_` CDChannelRcv (lines 648-653) + +| Line | Code | Status | +|------|------|--------| +| 651 | `mId == userMemberId` check | nottickedoff | +| 651 | `getUserGroupChatItemId_` call | nottickedoff | +| 652 | `otherwise` fallback | nottickedoff | +| 653 | `_ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing` | **covered** | + +#### Reaction functions + +| Line | Code | Status | +|------|------|--------| +| 3275 | `getGroupCIReactions` | **covered** | +| 3328 | `deleteGroupCIReactions_` | nottickedoff | + +--- + +## Summary + +### Well-tested channel paths: +- Channel message create/read/delete happy paths +- Basic channel reactions +- Channel quote creation (quoting nothing) +- `validSender Nothing CIChannelRcv` +- `getGroupChatItemBySharedMsgId` with `Nothing` memberId + +### Major gaps: + +1. **Non-channel-owner member in channel groups** - `isChannelOwner` always True, `memberForChannel = Just m''` never executed + +2. **All JSON serialization for CI directions** - `jsonCIDirection`, `jsonACIDirection`, `jsonCIQDirection`, `jsonACIQDirection` and all `ToJSON`/`FromJSON` instances entirely untested + +3. **Member support scope (`GCSIMemberSupport`)** - `mkGroupSupportChatInfo`, `groupScopeInfoStr`, `memberChatStats` entirely untested + +4. **Mentions in channel/group messages** - `getRcvCIMentions` with non-empty mentions, `uniqueMsgMentions`, `createGroupCIMentions` never called + +5. **Error/fallback paths** - `catchCINotFound` in update/delete/reaction, invalid sender validation, permission errors + +6. **Full-delete feature** - `groupFeatureAllowed SGFFullDelete` always false, `deleteGroupCIs` never called + +7. **Live message updates** - `itemLive == Just True` always false + +8. **Forwarded message handling** - Most parameters to forwarded handlers untested, `FwdChannel` branch untested + +9. **View functions** - `sentByMember'`, `viewGroupChatItemsDeleted`, `viewReactionMembers` entirely untested + +10. **Scope-filtered store queries** - 7 functions entirely untested + +11. **Feature restriction checks** - Voice messages (`SGFVoice`), reports (`SGFReports`) feature checks never triggered diff --git a/plans/website-file-page-implementation.md b/plans/website-file-page-implementation.md new file mode 100644 index 0000000000..bca77e77a9 --- /dev/null +++ b/plans/website-file-page-implementation.md @@ -0,0 +1,472 @@ +# File Transfer Page — Implementation Plan + +## Table of Contents +1. [Context](#1-context) +2. [Executive Summary](#2-executive-summary) +3. [High-Level Design](#3-high-level-design) +4. [Detailed Implementation Plan](#4-detailed-implementation-plan) +5. [Known Divergences from Product Plan](#5-known-divergences-from-product-plan) +6. [Verification](#6-verification) + +--- + +## 1. Context + +**Problem**: The website needs a `/file` page that lets users upload/download files via XFTP servers directly in the browser — a live demo that funnels users toward downloading the SimpleX app. + +**Product plan**: `plans/website-file-page-product.md` + +**Approach**: Use the pre-built `dist-web/` bundle from `@shhhum/xftp-web@0.8.0`. Copy three files (`index.js` + `index.css` + `crypto.worker.js`) to website static assets. Wrap with an 11ty page providing the protocol overlay, app download CTA, and i18n bridge. **No Vite/TS build step.** The bundle handles all XFTP protocol, crypto, Web Worker, upload/download UI. + +**Library features used** (v0.8.0): +- `data-xftp-app` — configurable target element +- `data-no-hashchange` — prevents conflict with overlay system +- `window.__XFTP_I18N__` — string externalization for i18n +- `xftp:upload-complete` / `xftp:download-complete` — CustomEvents for CTA injection +- Scoped CSS (`#app` / `.dark #app`) — no global resets +- Relative worker URL — both files co-located in same directory + +**Routing**: `/file/` (no hash) = upload mode; `/file/#` = download mode. + +--- + +## 2. Executive Summary + +| Action | Files | +|--------|-------| +| **Create** | `website/src/file.html`, `website/src/_data/file_overlays.json`, `website/src/_includes/overlay_content/file/protocol.html` | +| **Copy from npm** | `dist-web/assets/index.js` + `dist-web/assets/index.css` + `dist-web/assets/crypto.worker.js` → `src/file-assets/` | +| **Modify** | `website/package.json`, `website/.eleventy.js`, `website/src/_includes/navbar.html`, `website/langs/en.json` (~30 keys), `website/web.sh`, `website/src/js/script.js`, `.gitignore` | + +--- + +## 3. High-Level Design + +### Architecture + +``` +website/src/ +├── file.html # 11ty page +├── _data/file_overlays.json # overlay config (showImage: false for v1) +├── _includes/overlay_content/file/ +│ └── protocol.html # protocol popup content +└── file-assets/ # COPIED from npm dist-web/assets/ (gitignored) + ├── index.js # main bundle (~1.1 MB) + ├── index.css # scoped CSS (~2.3 KB) + └── crypto.worker.js # worker (~1.0 MB) +``` + +### Data flow + +**Upload**: `#app` div → bundle renders drop zone → file input → Worker encrypts (OPFS) → `uploadFile()` → share link → `xftp:upload-complete` event → website shows inline CTA + +**Download**: hash parsed by bundle on init → `decodeDescriptionURI()` → download button → Worker decrypts → browser save → `xftp:download-complete` event → website shows inline CTA + +### Overlay conflict resolution + +Bundle's `hashchange` listener is disabled via `data-no-hashchange` attribute. Protocol overlay opens via **direct DOM manipulation** (inline JS `classList.remove('hidden')`) — not hash-based. script.js's global `.close-overlay-btn` handler still closes it. No hash events fired when opening. + +Note: `closeOverlay()` in script.js calls `history.replaceState(null, null, ' ')` which clears the URL hash. In download mode (`/file/#simplex:...`), this means the hash disappears from the URL bar after closing the overlay. This is cosmetic only — the bundle parses the hash once on init and doesn't re-read it. Download continues unaffected. + +A null guard is added to `openOverlay()` in script.js (Step 9) to prevent crashes when the hash is an XFTP URI fragment rather than a DOM element ID. + +### i18n bridge + +The 11ty template renders `window.__XFTP_I18N__` from en.json keys. The bundle reads via `t(key, fallback)`. All JS-rendered strings are overridable. The bundle renders strings via template literals into innerHTML, so HTML in i18n values (e.g. links in `maxSizeHint`) is rendered correctly. + +--- + +## 4. Detailed Implementation Plan + +### Step 1: Add npm dependency + +**Modify**: `website/package.json` + +```diff + "dependencies": { ++ "@shhhum/xftp-web": "^0.8.0", + } +``` + +### Step 2: Copy dist-web files in web.sh + +**Modify**: `website/web.sh` + +After the existing `cp node_modules/...` lines (after line 30): + +```bash +mkdir -p src/file-assets +cp node_modules/@shhhum/xftp-web/dist-web/assets/index.js src/file-assets/ +cp node_modules/@shhhum/xftp-web/dist-web/assets/index.css src/file-assets/ +cp node_modules/@shhhum/xftp-web/dist-web/assets/crypto.worker.js src/file-assets/ +``` + +Add `file.html` to language copy loop (after line 42, `cp src/fdroid.html src/$lang`): +```bash + cp src/file.html src/$lang +``` + +### Step 3: Create 11ty page — `website/src/file.html` + +``` +--- +layout: layouts/main.html +title: "SimpleX File Transfer" +description: "Send files securely with end-to-end encryption" +templateEngineOverride: njk +active_file: true +--- +{% set lang = page.url | getlang %} +{% from "components/macro.njk" import overlay %} +``` + +**Structure** (top to bottom): + +1. **Noscript fallback**: + ```html + + ``` + +2. **Page section** with centered container: + - `

` with i18n title + - `
` — bundle renders here, hashchange disabled + - Static "E2E encrypted" note below `#app`: + ```html +

+ {{ "file-e2e-note" | i18n({}, lang) | safe }} +

+ ``` + - "Learn more" link (opens overlay via inline JS, not hash): + ```html +

+ + {{ "file-learn-more" | i18n({}, lang) | safe }} + +

+ ``` + +3. **Inline CTA container** (hidden, shown by JS after upload/download): + ```html + + ``` + +4. **Protocol overlay** via existing macro: + ```html + {% for section in file_overlays.sections %} + {{ overlay(section, lang) }} + {% endfor %} + ``` + +5. **Bottom CTA section** (same pattern as `join_simplex.html`): + - Heading: "Get SimpleX — the most private messenger" + - Subheading about the app using the same protocol + - 5 buttons: Apple Store, Google Play, F-Droid, TestFlight, APK (same markup as inline CTA) + +6. **i18n bridge script** (BEFORE bundle load, so `window.__XFTP_I18N__` is set when bundle initializes): + ```html + + ``` + +7. **Overlay open + CTA injection script**: + ```html + + ``` + +8. **Bundle + CSS** (bundle AFTER i18n bridge): + ```html + + + ``` + +### Step 4: Create protocol overlay data + content + +**New file**: `website/src/_data/file_overlays.json` +```json +{ + "sections": [{ + "id": 1, + "imgLight": "", + "imgDark": "", + "overlayContent": { + "overlayId": "xftp-protocol", + "overlayScrollTo": "", + "title": "file-protocol-title", + "showImage": false, + "contentBody": "overlay_content/file/protocol.html" + } + }] +} +``` + +Note: `showImage: false` — protocol diagram SVGs are deferred to a future iteration. The overlay works without images (same as existing overlays when `showImage` is false — the content section spans full width). + +**New file**: `website/src/_includes/overlay_content/file/protocol.html` + +5 blocks with heading + paragraph structure (existing hero overlay cards use plain `

` tags; this overlay uses `

` + `

` inside `

` wrappers since it has titled sections): + +```html +
+

{{ "file-proto-h-1" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-1" | i18n({}, lang) | safe }}

+
+
+

{{ "file-proto-h-2" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-2" | i18n({}, lang) | safe }}

+
+
+

{{ "file-proto-h-3" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-3" | i18n({}, lang) | safe }}

+
+
+

{{ "file-proto-h-4" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-4" | i18n({}, lang) | safe }}

+
+
+

{{ "file-proto-h-5" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-5" | i18n({}, lang) | safe }}

+
+

+ + {{ "file-proto-spec" | i18n({}, lang) | safe }} + +

+``` + +### Step 5: Add navbar link + +**Modify**: `website/src/_includes/navbar.html` + +After Directory `
  • ` block (after line 27, before the `
    ` at line 29): +```html +
    +
  • +``` + +Add `and ('file' not in page.url)` to language-selector exclusion condition (line 137): +``` +{% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('vouchers' not in page.url) and ('file' not in page.url) %} +``` + +### Step 6: Add translation keys + +**Modify**: `website/langs/en.json` — add these keys: + +``` +Navbar: + "file": "File" + +Noscript + static page content: + "file-noscript": "JavaScript is required for file transfer." + "file-e2e-note": "End-to-end encrypted — the server never sees your file." + "file-learn-more": "Learn more about XFTP protocol" + "file-cta-heading": "Get SimpleX — the most private messenger" + "file-cta-subheading": "The file transfer you just used is built on the same protocol as SimpleX Chat — end-to-end encrypted messaging, voice and video calls, groups, and file sharing. No user IDs. No phone numbers." + +i18n bridge (fed to bundle via window.__XFTP_I18N__): + "file-title": "SimpleX File Transfer" + "file-drop-text": "Drag & drop a file here" + "file-drop-hint": "or" + "file-choose": "Choose file" + "file-max-size": "Max 100 MB — the SimpleX app supports up to 1 GB" + "file-encrypting": "Encrypting\u2026" + "file-uploading": "Uploading\u2026" + "file-cancel": "Cancel" + "file-uploaded": "File uploaded" + "file-copy": "Copy" + "file-copied": "Copied!" + "file-share": "Share" + "file-expiry": "Files are typically available for 48 hours." + "file-sec-1": "Your file was encrypted in the browser before upload — the server never sees file contents." + "file-sec-2": "The link contains the decryption key in the hash fragment, which the browser never sends to any server." + "file-sec-3": "For maximum security, use the SimpleX app." + "file-retry": "Retry" + "file-downloading": "Downloading\u2026" + "file-decrypting": "Decrypting\u2026" + "file-download-complete": "Download complete" + "file-download-btn": "Download" + "file-too-large": "File too large (%size%). Maximum is 100 MB. The SimpleX app supports files up to 1 GB." + "file-empty": "File is empty." + "file-invalid-link": "Invalid or corrupted link." + "file-init-error": "Failed to initialize: %error%" + "file-available": "File available (~%size%)" + "file-dl-sec-1": "This file is encrypted \u2014 the server never sees file contents." + "file-dl-sec-2": "The decryption key is in the link\u2019s hash fragment, which your browser never sends to any server." + "file-dl-sec-3": "For maximum security, use the SimpleX app." + "file-workers-required": "Web Workers required \u2014 update your browser" + +Protocol overlay content: + "file-protocol-title": "Why XFTP is the most private file transfer" + "file-proto-h-1": "No accounts, no identifiers" + "file-proto-p-1": "Each file chunk uses a fresh, random credential that is used once and discarded. The server has no concept of \"users\" — it only sees isolated, anonymous chunk operations." + "file-proto-h-2": "Encrypted in your browser" + "file-proto-p-2": "The entire file is encrypted with a random key before upload. The server stores ciphertext it cannot decrypt. The key travels only in the URL fragment, which browsers never send to any server." + "file-proto-h-3": "Triple encryption" + "file-proto-p-3": "Every transfer has three layers: TLS transport encryption, per-recipient transit encryption (unique ephemeral key exchange per download), and file-level end-to-end encryption." + "file-proto-h-4": "Distributed across independent servers" + "file-proto-p-4": "File chunks are split across servers operated by independent parties. No single operator sees all chunks. Even if one operator is compromised, they only see encrypted fragments." + "file-proto-h-5": "Files expire automatically" + "file-proto-p-5": "Files are deleted after approximately 48 hours. There is no persistent storage, no file management, no way to extend expiration. Ephemeral by design." + "file-proto-spec": "Read the XFTP protocol specification →" +``` + +### Step 7: Update .eleventy.js + +**Modify**: `website/.eleventy.js` + +1. Add `"file"` to `supportedRoutes` array (line 56): +```js +const supportedRoutes = ["blog", "contact", "invitation", "messaging", "docs", "fdroid", "file", ""] +``` + +2. Add passthrough copy (after line 306, with the other `addPassthroughCopy` calls): +```js +ty.addPassthroughCopy("src/file-assets") +``` + +### Step 8: Gitignore + +**Modify**: `.gitignore` (project root) — add: +``` +website/src/file-assets/ +``` + +### Step 9: Fix script.js null guard + +**Modify**: `website/src/js/script.js` + +The `openOverlay()` function (line 180) crashes when the URL hash is an XFTP URI fragment (e.g. `#simplex:...`) because `document.getElementById('simplex:...')` returns null, and `el.classList.contains('overlay')` throws a TypeError on null. + +**Change** (line 184-185): +```js +// Before: +const el = document.getElementById(id) +if (el.classList.contains('overlay')) { + +// After: +const el = document.getElementById(id) +if (el && el.classList.contains('overlay')) { +``` + +This is a one-character change (`if (el.classList` → `if (el && el.classList`). It makes `openOverlay()` safely ignore hash fragments that don't correspond to overlay elements — which is correct behavior regardless of the file page (any non-overlay hash should be silently ignored). + +--- + +## 5. Known Divergences from Product Plan + +These are intentional deviations from the product plan, caused by browser constraints or library limitations: + +1. **Download requires a click**: Product plan says "No intermediate 'click to download' step." The bundle shows a "Download" button instead of auto-starting. This is a browser security constraint — triggering a file download requires a user gesture. The button also lets the user see file metadata before downloading. + +2. **No cancel during download**: Product plan specifies a Cancel button during download. The bundle does not implement this. The download is relatively fast (direct HTTPS) and cancellation can be done by closing the tab. + +3. **Protocol diagram deferred**: Product plan describes a protocol flow diagram in the overlay. SVG diagrams are deferred to a future iteration. The overlay ships with text-only content (`showImage: false`). + +4. **Overlay close clears download hash**: When the protocol overlay is opened and closed during download mode, `closeOverlay()` clears the URL hash. This is cosmetic — the bundle already parsed the hash on init and the download is unaffected. The URL bar loses the fragment, but the user received the link from elsewhere and doesn't need to re-copy it. + +--- + +## 6. Verification + +### Build +```bash +cd website +npm install --ignore-scripts +mkdir -p src/file-assets +cp node_modules/@shhhum/xftp-web/dist-web/assets/{index.js,index.css,crypto.worker.js} src/file-assets/ +npm run build +ls _site/file/index.html _site/file-assets/index.js _site/file-assets/index.css _site/file-assets/crypto.worker.js +``` + +### Manual test checklist +``` +Visit /file/ + 1. Navbar "File" link is active + 2.
    @@ -30,7 +30,7 @@
    - + @@ -92,7 +92,7 @@ + + + + + + + diff --git a/website/src/js/design3.js b/website/src/js/design3.js new file mode 100644 index 0000000000..bc725ac7ce --- /dev/null +++ b/website/src/js/design3.js @@ -0,0 +1,307 @@ +const isMobile = { + Android: () => navigator.userAgent.match(/Android/i), + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i), + any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i) +}; + +(function() { +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializePage); +} else { + initializePage(); +} + +function initializePage() { + const googlePlayBtn = document.querySelector('.google-play-btn'); + const appleStoreBtn = document.querySelector('.apple-store-btn'); + const fDroidBtn = document.querySelector('.f-droid-btn'); + const testflightBtn = document.querySelector('.testflight-btn'); + const androidBtn = document.querySelector('.android-btn'); + const desktopAppBtn = document.querySelector('.desktop-app-btn'); + + if (!googlePlayBtn || !appleStoreBtn || !fDroidBtn || !testflightBtn || !androidBtn || !desktopAppBtn) return; + + + if (isMobile.Android()) { + googlePlayBtn.classList.remove('hidden'); + fDroidBtn.classList.remove('hidden'); + androidBtn.classList.remove('hidden'); + } + else if (isMobile.iOS()) { + appleStoreBtn.classList.remove('hidden'); + testflightBtn.classList.remove('hidden'); + } + else { + appleStoreBtn.classList.remove('hidden'); + googlePlayBtn.classList.remove('hidden'); + desktopAppBtn.classList.remove('hidden'); + // fDroidBtn.classList.remove('hidden'); + // testflightBtn.classList.remove('hidden'); + // androidBtn.classList.remove('hidden'); + } + + showPromotedGroups(); +} + +async function showPromotedGroups() { + welcome(); + const listing = await fetchJSON(simplexDirectoryDataURL + 'promoted.json'); + let [entries, imgPath] = + Array.isArray(listing?.entries) && listing.entries.length > 0 + ? [listing.entries, simplexDirectoryDataURL] + : [fallbackEntries(), '/img/groups/']; + // Uncomment to log fallback entries + // entries.forEach(e => { + // delete e.activeAt; + // delete e.createdAt; + // delete e.entryType; + // delete e.groupLink.connFullLink; + // delete e.shortDescr; + // delete e.welcomeMessage; + // }); + // console.log(entries); + const links = document.querySelectorAll('.group-images a.group-image'); + entries = shuffleEntries(entries, links.length); + + for (let i = 0; i < links.length; i++) { + const link = links[i] + const img = link.querySelector('img'); + const {displayName, imageFile, groupLink} = entries[i % entries.length]; + img.src = imageFile ? imgPath + imageFile : '/img/group.svg'; + img.addEventListener('error', () => img.src = '/img/group.svg'); + link.title = displayName; + const groupLinkUri = groupLink.connShortLink ?? groupLink.connFullLink + try { + link.href = platformSimplexUri(groupLinkUri); + } catch(e) { + console.log(e); + link.href = groupLinkUri; + } + } + + function shuffleEntries(entries, count) { + let a = entries.filter(e => e.displayName != simplexUsersGroup) + shuffle(); + let result = a; + while (result.length < count) { + shuffle(); + result = result.concat(a); + } + return result; + + function shuffle() { + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + } + } + + async function fetchJSON(url) { + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP status: ${response.status}`) + return await response.json() + } catch (e) { + console.error(e) + } + } + + function welcome() { + console.log('%c%s', 'font-family: monospace; white-space: pre;', +`Welcome to __ __ + ___ ___ __ __ ___ _ ___\\ \\ / / ___ _ _ _ _____ +/ __|_ _| \\/ | _ \\ | | __ \\ V / / __| || | /_\\_ _| +\\__ \\| || |\\/| | _/ |__| _| / . \\| (__| __ |/ _ \\| | +|___/___|_| |_|_| |____|___/_/ \\_\\\\___|_||_/_/ \\_\\_| + +SimpleX directory: https://simplex.chat/directory +Ask SimpleX team: https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw +GitHub: https://github.com/simplex-chat/simplex-chat +Reddit: https://www.reddit.com/r/SimpleXChat +X/Twitter: https://x.com/SimpleXChat + +Docs +---- +Whitepaper: https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md +Bots API: https://github.com/simplex-chat/simplex-chat/tree/stable/bots +TypeScript library: https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/typescript +Terminal CLI: https://github.com/simplex-chat/simplex-chat/blob/stable/docs/CLI.md +Hosting SMP servers: https://simplex.chat/docs/server.html + +Downloads +--------- +Apps: https://simplex.chat/downloads +Servers: https://github.com/simplex-chat/simplexmq/releases + +Project +------- +About & Contact us: https://simplex.chat/about +Privacy policy: https://simplex.chat/privacy +Join team: https://simplex.chat/jobs +Donations: https://github.com/simplex-chat/simplex-chat#please-support-us-with-your-donations +` + ); + } + + function fallbackEntries() { + console.log('Error: using hardcoded listing as fallback'); + return [ + { + displayName: "Bitcoin&LightningNetwork", + groupLink: { + connShortLink: "https://smp4.simplex.im/g#-xXBhQRrvRB1ffhxcPpB44Im1_ci4BMIdCHwj8m8IHo" + }, + imageFile: "images/F4hPy5IGO6G9QUH6-nM_-A.jpg" + }, + { + displayName: "Freedom.Tech", + groupLink: { + connShortLink: "https://smp4.simplex.im/g#r5z3uzHp8_pL3ZPyuBCJWmvzQxMnc0Tj3QMLTEnyw6c" + }, + imageFile: "images/HylzOLARvIhUR1wiq0fnJA.png" + }, + { + displayName: "Spirituality", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#OZ8ml_2dj5AutxnNIrHy0CPn1QdnSkQ0oh_84nAv5io" + }, + imageFile: "images/eb2RINRsdEBI0a06ghzqVg.jpg" + }, + { + displayName: "SovereignStack", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#u2D3BdOb3nt9wMR_qoweAINLcEZaU60Xjwfpf74Dq2I" + }, + imageFile: "images/YLLz-47eBnAoIH1PKYJ75g.jpg" + }, + { + displayName: "Monero", + groupLink: { + connShortLink: "https://smp6.simplex.im/g#FlIy4-q4TzZDI9fK2aw3lQTUvnmaoiLCyeKCoP27kGU" + }, + imageFile: "images/TwaN96DcV2OCMfUo6oJ4LQ.png" + }, + { + displayName: "Start9 - Sovereign Computing", + groupLink: { + connShortLink: "https://smp4.simplex.im/g#JArWigpS6OB0gYE2U94pDSzPQyejOdmqe98ohBNoW2Q" + }, + imageFile: "images/478ec86_izoJb95VXKWEhg.jpg" + }, + { + displayName: "Meshtastic", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#Ub1c3ByH5vkhXMMsRdG0fBhSik_qPuEZHcx8AQ2f2Tw" + }, + imageFile: "images/PPTLdveOyb9Wsg3bm6Y_IQ.png" + }, + { + displayName: "GrapheneOS (unofficial)", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#6OTo6kP4ccV4lPOOHekZfOajdxGkxC1_DkAR39_cU4U" + }, + imageFile: "images/zIotMF8Zoe85k956B48N9g.jpg" + }, + { + displayName: "BasicSwap", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#yXMQy5Si6sD5YAdCB4DlUM0kvlKYcUfSiIvOA-RXI_U" + }, + imageFile: "images/AXalaTZ4HsgGtEkaGFbclg.png" + }, + { + displayName: "Linux", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#gqXY-Fxwral34c4bsHlYKZ5QuB6ptVSRMwhWgLKHz54" + }, + imageFile: "images/gqyRO21CwqK3huZ2zbkOqQ.jpg" + }, + { + displayName: "ModernSurvival", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#e4_r2E20FSWl97XGsHyXoPOkW5B6SmjlWQ1ngVW6Umc" + }, + imageFile: "images/5QzoN8PNFD2dkButszPu5g.png" + }, + { + displayName: "Qubes OS", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#COegA1s1ppZG4hRbcpuWwx_QWB4ScouQcIDWwXx64SY" + }, + imageFile: "images/Wsdcf731ufbEOYpHc2rqrg.jpg" + }, + { + displayName: "RoboSats", + groupLink: { + connShortLink: "https://smp4.simplex.im/g#PNRhbupXbsSr5SpkjqP8IjkI6ACPCr2WOxAqSAW4jr0" + }, + imageFile: "images/uZU6Cn1przsjVJ-DBm1-eA.jpg" + }, + { + displayName: "Guardian Project (Unofficial)", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#7zpeTUzhVIwEbZpvo9SUGz1LE35jKfmF_AHYx5YLsxQ" + }, + imageFile: "images/NvmOInofh4RSB2fHdN0zQA.jpg" + }, + { + displayName: "Private Messaging Apps 2", + groupLink: { + connShortLink: "https://smp6.simplex.im/g#aPhAePNB7Nn-W4kUBNBpZELXttysG-yAM8ZiU2XoB10" + }, + imageFile: "images/9aT7qRY_JbSJsW4qDaIHEg.jpg" + }, + { + displayName: "Cake Wallet - Official", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#D46rZgLF1eLDLkOyqYnaifRuZcYjlEMgkujKV2buH6k" + }, + imageFile: "images/YhyznG68PNewCNjoD_ceOg.png" + }, + { + displayName: "CoMaps (EN)", + groupLink: { + connShortLink: "https://smp4.simplex.im/g#mBdIDCJotrN7pimTmXoaAPybC7CmCaaAFxQcwultCvo" + }, + imageFile: "images/BD6FXuHO-eKOnYCAzkRfmA.jpg" + }, + { + displayName: "SimpleX users group", + groupLink: { + connShortLink: "https://smp4.simplex.im/g#hr4lvFeBmndWMKTwqiodPz3VBo_6UmdGWocXd1SupsM" + }, + imageFile: "images/CX-1MPD3r3a7NYBvW7de6g.jpg" + }, + { + displayName: "NBTV Community", + groupLink: { + connShortLink: "https://smp6.simplex.im/g#RX598AUwyQBG6bqa4TOnEnUg7xONdrA-_e0CmNGxEBI" + }, + imageFile: "images/n3VLCEBWhqU9rVtlEAKUhQ.jpg" + }, + { + displayName: "Lossless Audio Community", + groupLink: { + connShortLink: "https://smp6.simplex.im/g#D4P5ENAzT-JoVlFNvRC7geHkkMUuQ9IuhQQC15OFE-o" + }, + imageFile: "images/B3xza7zUzYi3dHiRsg6Afg.jpg" + }, + { + displayName: "RetoSwap – Official SimpleXChat Group", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#-_h6fBWisca6RKhteZtVuXol1a49vFH1Jo-n74fnRK0" + }, + imageFile: "images/ESomaJp7MlFlThqcoj3Ycg.png" + }, + { + displayName: "UW Support 💬", + groupLink: { + connShortLink: "https://smp5.simplex.im/g#6KPZcRjE6KDNQ1VcS1a2wd9LRuJy1zgdvldaE5bhg5c" + }, + imageFile: "images/t1RmI4AhKgelVoWeSBTqUA.jpg" + } + ] + } +} +})(); diff --git a/website/src/js/directory.js b/website/src/js/directory.js new file mode 100644 index 0000000000..afaac1053f --- /dev/null +++ b/website/src/js/directory.js @@ -0,0 +1,571 @@ +(function() { +if (!document.location.pathname.startsWith('/directory')) return; + +let allEntries = []; + +let filteredEntries = []; + +let currentSortMode = ''; + +let currentSearch = ''; + +let currentPage = 1; + +async function initDirectory() { + const listing = await fetchJSON(simplexDirectoryDataURL + 'listing.json') + const liveBtn = document.querySelector('#top-pagination .live'); + const newBtn = document.querySelector('#top-pagination .new'); + const topBtn = document.querySelector('#top-pagination .top'); + const searchInput = document.getElementById('search'); + allEntries = listing.entries + + applyHash(); + + searchInput.addEventListener('input', (e) => renderEntries('top', bySortPriority, topBtn, e.target.value.trim(), true)); + liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn)); + newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn)); + topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn)); + window.addEventListener('popstate', applyHash); + + function applyHash() { + const hash = location.hash; + let mode, comparator, btn, search = ''; + switch (hash) { + case '#active': + mode = 'live'; + comparator = byActiveAtDesc; + btn = liveBtn; + break; + case '#new': + mode = 'new'; + comparator = byCreatedAtDesc; + btn = newBtn; + break; + default: + mode = 'top'; + comparator = bySortPriority; + btn = topBtn; + try { + if (hash.startsWith('#q=')) { + search = decodeURIComponent(hash.slice(3)); + if (search) searchInput.value = search; + } + } catch(e) {} + } + currentSortMode = ''; + currentSearch = ''; + currentPage = 1; + renderEntries(mode, comparator, btn, search); + } + + function renderEntries(mode, comparator, btn, search = '') { + if (currentSortMode === mode && search == currentSearch) return; + currentSortMode = mode; + const hash = search ? '#q=' + encodeURIComponent(search) + : mode === 'live' ? '#active' + : mode === 'new' ? '#new' + : ''; + const url = hash || (location.pathname + location.search); + history.replaceState(null, '', url); + liveBtn.classList.remove('active'); + newBtn.classList.remove('active'); + topBtn.classList.remove('active'); + if (search == '') { + currentSearch = ''; + currentPage = 1; + searchInput.value = ''; + btn.classList.add('active'); + } else { + currentSearch = search; + } + filteredEntries = filterEntries(mode, search ?? '').sort(comparator); + renderDirectoryPage(); + } +} + +function renderDirectoryPage() { + const currentEntries = addPagination(filteredEntries); + displayEntries(currentEntries); +} + +function filterEntries(mode, s) { + if (s === '' && mode == 'top') return allEntries.slice(); + const query = s.toLowerCase(); + return allEntries.filter(entry => + ( mode === 'top' + || (mode === 'new' && entry.createdAt) + || (mode === 'live' && entry.activeAt) + ) && + ( query === '' + || (entry.displayName || '').toLowerCase().includes(query) + || includesQuery(entry.shortDescr, query) + || includesQuery(entry.welcomeMessage, query) + ) + ); +} + +function includesQuery(field, query) { + return field + && Array.isArray(field) + && field.some(ft => { + switch (ft.format?.type) { + case 'uri': return uriIncludesQuery(ft.text, query); + case 'hyperLink': return textIncludesQuery(ft.format.showText, query) || uriIncludesQuery(ft.format.linkUri, query); + case 'simplexLink': return textIncludesQuery(ft.format.showText, query); + default: return textIncludesQuery(ft.text, query); + } + }); +} + +function textIncludesQuery(text, query) { + return text ? text.toLowerCase().includes(query) : false +} + +function uriIncludesQuery(uri, query) { + if (!uri) return false; + uri = uri.toLowerCase(); + return !uri.includes('simplex') && uri.includes(query); +} + +async function fetchJSON(url) { + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP status: ${response.status}`) + return await response.json() + } catch (e) { + console.error(e) + } +} + +function bySortPriority(entry1, entry2) { + return entrySortPriority(entry2) - entrySortPriority(entry1); +} + +function byActiveAtDesc(entry1, entry2) { + return (roundedTs(entry2.activeAt) - roundedTs(entry1.activeAt)) * 10 + + Math.sign(bySortPriority(entry1, entry2)); +} + +function byCreatedAtDesc(entry1, entry2) { + return (roundedTs(entry2.createdAt) - roundedTs(entry1.createdAt)) * 10 + + Math.sign(bySortPriority(entry1, entry2)); +} + +function roundedTs(s) { + try { + return new Date(s).valueOf(); + } catch { + return 0; + } +} + +function entrySortPriority(entry) { + return entry.displayName === simplexUsersGroup + ? Number.MAX_VALUE + : entryMemberCount(entry) +} + +function entryMemberCount(entry) { + return entry.entryType.type == 'group' + ? (entry.entryType.summary?.publicMemberCount ?? entry.entryType.summary?.currentMembers ?? 0) + : 0 +} + +const now = new Date(); +const nowVal = now.valueOf(); +const today = new Date(now); +today.setHours(0, 0, 0, 0); +const todayVal = today.valueOf(); +const todayYear = today.getFullYear(); + +const dateFormatter = Intl?.DateTimeFormat?.(undefined, {month: '2-digit', day: '2-digit'}); +const dateYearFormatter = Intl?.DateTimeFormat?.(undefined, {year: 'numeric', month: '2-digit', day: '2-digit'}); + +function showDate(d) { + return dateFormatter && d.getFullYear() == todayYear + ? dateFormatter.format(d) + : dateYearFormatter?.format(d) ?? d.toLocaleDateString(); +} + +function showCreatedOn(s) { + const d = new Date(s) + d.setHours(0, 0, 0, 0); + return 'Created' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); +} + +function showActiveOn(s) { + const d = new Date(s) + const ago = nowVal - d.valueOf(); + if (ago <= 1200000) return 'Active now'; // 20 minutes + if (ago <= 10800000) return 'Active recently'; // 3 hours + d.setHours(0, 0, 0, 0); + return 'Active' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); +} + +function displayEntries(entries) { + const directory = document.getElementById('directory'); + directory.innerHTML = ''; + + for (let entry of entries) { + try { + const { entryType, displayName, groupLink, shortDescr, welcomeMessage, imageFile } = entry; + const entryDiv = document.createElement('div'); + entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8'; + + const textContainer = document.createElement('div'); + textContainer.className = 'text-container'; + + const nameElement = document.createElement('h2'); + nameElement.textContent = displayName; + nameElement.className = 'text-grey-black dark:text-white !text-lg md:!text-xl font-bold'; + textContainer.appendChild(nameElement); + + const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; + const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; + if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { + const descrElement = document.createElement('p'); + descrElement.innerHTML = renderMarkdown(shortDescr); + textContainer.appendChild(descrElement); + } + + if (welcomeMessageHTML) { + const messageElement = document.createElement('p'); + messageElement.innerHTML = welcomeMessageHTML; + textContainer.appendChild(messageElement); + + const readMore = document.createElement('p'); + readMore.textContent = 'Read more'; + readMore.className = 'read-more'; + readMore.style.display = 'none'; + textContainer.appendChild(readMore); + + setTimeout(() => { + const computedStyle = window.getComputedStyle(messageElement); + const lineHeight = parseFloat(computedStyle.lineHeight); + const maxLines = 5; + const maxHeight = maxLines * lineHeight + const maxHeightPx = `${maxHeight}px`; + messageElement.style.maxHeight = maxHeightPx; + messageElement.style.overflow = 'hidden'; + + if (messageElement.scrollHeight > maxHeight + 4) { + readMore.style.display = 'block'; + readMore.addEventListener('click', () => { + if (messageElement.style.maxHeight === maxHeightPx) { + messageElement.style.maxHeight = 'none'; + readMore.className = 'read-less'; + readMore.innerHTML = '▲'; + } else { + messageElement.style.maxHeight = maxHeightPx; + readMore.className = 'read-more'; + readMore.textContent = 'Read more'; + } + }); + } + }, 0); + } + + if (entryType?.groupType) { + const noteElement = document.createElement('p'); + noteElement.innerHTML = 'You need SimpleX Chat app v6.5 to join.'; + noteElement.className = 'text-sm'; + textContainer.appendChild(noteElement); + } + + const entryTimestamp = currentSortMode === 'new' && entry.createdAt + ? showCreatedOn(entry.createdAt) + : entry.activeAt + ? showActiveOn(entry.activeAt) + : ''; + if (entryTimestamp) { + timestampElement = document.createElement('p'); + timestampElement.textContent = entryTimestamp; + timestampElement.className = 'text-sm'; + textContainer.appendChild(timestampElement); + } + + const memberCount = entryMemberCount(entry); + if (typeof memberCount == 'number' && memberCount > 0) { + const memberCountElement = document.createElement('p'); + const isChannel = entryType?.groupType === 'channel'; + memberCountElement.textContent = `${memberCount} ${isChannel ? 'subscribers' : 'members'}`; + memberCountElement.className = 'text-sm'; + textContainer.appendChild(memberCountElement); + } + + if (entryType?.admission?.review === "all") { + const knockingElement = document.createElement('p'); + knockingElement.textContent = 'New members are reviewed by admins'; + knockingElement.className = 'text-sm'; + textContainer.appendChild(knockingElement); + } + + const imgLinkElement = document.createElement('a'); + imgLinkElement.className = 'img-link'; + const groupLinkUri = groupLink.connShortLink ?? groupLink.connFullLink + try { + imgLinkElement.href = platformSimplexUri(groupLinkUri); + } catch(e) { + console.log(e); + imgLinkElement.href = groupLinkUri; + } + imgLinkElement.target = "_blank"; + imgLinkElement.title = `Join ${displayName}`; + + const imgElement = document.createElement('img'); + imgElement.src = imageFile ? simplexDirectoryDataURL + imageFile : '/img/group.svg'; + imgElement.alt = displayName; + imgElement.addEventListener('error', () => imgElement.src = '/img/group.svg'); + imgLinkElement.appendChild(imgElement); + entryDiv.appendChild(imgLinkElement); + + entryDiv.appendChild(textContainer); + directory.appendChild(entryDiv); + } catch (e) { + console.log(e); + } + } + + for (let el of document.querySelectorAll('.secret')) { + el.addEventListener('click', () => el.classList.toggle('visible')); + } + + directory.style.height = ''; +} + +function goToPage(p) { + currentPage = p; + renderDirectoryPage(); +} + +function addPagination(entries) { + const entriesPerPage = 10; + const totalPages = Math.ceil(entries.length / entriesPerPage); + if (currentPage < 1) currentPage = 1; + if (currentPage > totalPages) currentPage = totalPages; + + const startIndex = (currentPage - 1) * entriesPerPage; + const endIndex = Math.min(startIndex + entriesPerPage, entries.length); + const currentEntries = entries.slice(startIndex, endIndex); + + // addPaginationElements('top-pagination') + addPaginationElements('bottom-pagination') + return currentEntries; + + function addPaginationElements(paginationId) { + const pagination = document.getElementById(paginationId); + if (!pagination) { + return currentEntries; + } + pagination.innerHTML = ''; + + try { + let startPage, endPage; + const pageButtonCount = 8 + if (totalPages <= pageButtonCount) { + startPage = 1; + endPage = totalPages; + } else { + startPage = Math.max(1, currentPage - 4); + endPage = Math.min(totalPages, startPage + pageButtonCount - 1); + if (endPage - startPage + 1 < pageButtonCount) { + startPage = Math.max(1, endPage - pageButtonCount + 1); + } + } + + // if (currentPage > 1 && startPage > 1) { + // const firstBtn = document.createElement('button'); + // firstBtn.textContent = 'First'; + // firstBtn.classList.add('text-btn'); + // firstBtn.addEventListener('click', () => goToPage(1)); + // pagination.appendChild(firstBtn); + // } + + if (currentPage > 1) { + const prevBtn = document.createElement('button'); + prevBtn.textContent = 'Prev'; + prevBtn.classList.add('text-btn'); + prevBtn.addEventListener('click', () => goToPage(currentPage - 1)); + pagination.appendChild(prevBtn); + } + + for (let p = startPage; p <= endPage; p++) { + const pageBtn = document.createElement('button'); + pageBtn.textContent = p.toString(); + if (p === currentPage) { + pageBtn.classList.add('active'); + } else if (p === currentPage - 1 || p === currentPage + 1 || (currentPage === 1 && p === 3) || (currentPage === totalPages && p === totalPages - 2)) { + pageBtn.classList.add('neighbor'); + } + pageBtn.addEventListener('click', () => goToPage(p)); + pagination.appendChild(pageBtn); + } + + if (currentPage < totalPages) { + const nextBtn = document.createElement('button'); + nextBtn.textContent = 'Next'; + nextBtn.classList.add('text-btn'); + nextBtn.addEventListener('click', () => goToPage(currentPage + 1)); + pagination.appendChild(nextBtn); + } + + // if (endPage < totalPages) { + // const lastBtn = document.createElement('button'); + // lastBtn.textContent = 'Last'; + // lastBtn.classList.add('text-btn'); + // lastBtn.addEventListener('click', () => goToPage(totalPages)); + // pagination.appendChild(lastBtn); + // } + + } catch (e) { + console.log(e); + } + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDirectory); +} else { + initDirectory(); +} + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
    "); +} + +function getSimplexLinkDescr(linkType) { + switch (linkType) { + case 'contact': return 'SimpleX contact address'; + case 'invitation': return 'SimpleX one-time invitation'; + case 'group': return 'SimpleX group link'; + case 'channel': return 'SimpleX channel link'; + case 'relay': return 'SimpleX relay link'; + default: return 'SimpleX link'; + } +} + +function viaHost(smpHosts) { + const first = smpHosts[0] ?? '?'; + return `via ${first}`; +} + +function isCurrentSite(uri) { + return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") +} + +function targetBlank(uri) { + return isCurrentSite(uri) ? '' : ' target="_blank"' +} + +function renderMarkdown(fts) { + let html = ''; + for (const ft of fts) { + const { format, text } = ft; + if (!format) { + html += escapeHtml(text); + continue; + } + try { + switch (format.type) { + case 'bold': + html += `${escapeHtml(text)}`; + break; + case 'italic': + html += `${escapeHtml(text)}`; + break; + case 'strikeThrough': + html += `${escapeHtml(text)}`; + break; + case 'snippet': + html += `${escapeHtml(text)}`; + break; + case 'secret': + html += `${escapeHtml(text)}`; + break; + case 'small': + html += `${escapeHtml(text)}`; + break; + case 'colored': + html += `${escapeHtml(text)}`; + break; + case 'uri': + let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; + html += `${escapeHtml(text)}`; + break; + case 'hyperLink': { + const { showText, linkUri } = format; + html += `${escapeHtml(showText ?? linkUri)}`; + break; + } + case 'simplexLink': { + const { showText, linkType, simplexUri, smpHosts } = format; + const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); + html += `${linkText} (${viaHost(smpHosts)})`; + break; + } + case 'command': + html += `${escapeHtml(text)}`; + break; + case 'mention': + html += `${escapeHtml(text)}`; + break; + case 'email': + html += `${escapeHtml(text)}`; + break; + case 'phone': + html += `${escapeHtml(text)}`; + break; + case 'unknown': + html += escapeHtml(text); + break; + default: + html += escapeHtml(text); + } + } catch(e) { + console.log(e); + html += escapeHtml(text); + } + } + return html; +} +})(); + +const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/'; + +// const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/'; + +const simplexUsersGroup = 'SimpleX users group'; + +const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; + +const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; + +function platformSimplexUri(uri) { + if (isMobile.any()) return uri; + const res = uri.match(simplexAddressRegexp); + if (!res || !Array.isArray(res) || res.length < 3) return uri; + const linkType = res[1]; + const fragment = res[2]; + if (simplexShortLinkTypes.includes(linkType)) { + const queryIndex = fragment.indexOf('?'); + if (queryIndex === -1) return uri; + const hashPart = fragment.substring(0, queryIndex); + const queryStr = fragment.substring(queryIndex + 1); + const params = new URLSearchParams(queryStr); + const host = params.get('h'); + if (!host) return uri; + params.delete('h'); + let newFragment = hashPart; + const remainingParams = params.toString(); + if (remainingParams) newFragment += '?' + remainingParams; + return `https://${host}:/${linkType}#${newFragment}`; + } else { + return `https://simplex.chat/${linkType}#${fragment}`; + } +} diff --git a/website/src/js/links.js b/website/src/js/links.js new file mode 100644 index 0000000000..0200bf8a51 --- /dev/null +++ b/website/src/js/links.js @@ -0,0 +1,259 @@ +document.addEventListener("DOMContentLoaded", function () { + var ITEMS_PER_PAGE = 10 + var allItems = Array.from(document.querySelectorAll("#links-list .link-item")) + var filteredItems = allItems.slice() + var currentPage = 1 + + var langSelect = document.getElementById("filter-language") + var pills = Array.from(document.querySelectorAll("#links-filters .filter-chip")) + var pagination = document.getElementById("links-pagination") + + var activeFilter = "" // "" | "media" | "cat" + var activeValue = "" // "video" | "audio" | "review" | etc. + + function matchesActivePill(el) { + if (!activeFilter) return true + if (activeFilter === "media") return el.getAttribute("data-media") === activeValue + if (activeFilter === "cat") return el.getAttribute("data-category") === activeValue + return true + } + + function matchesLang(el) { + var lang = langSelect.value + return !lang || el.getAttribute("data-lang") === lang + } + + function getFiltered() { + return allItems.filter(function (el) { + return matchesActivePill(el) && matchesLang(el) + }) + } + + function updateLanguageOptions() { + var available = {} + allItems.forEach(function (el) { + if (matchesActivePill(el)) { + var lang = el.getAttribute("data-lang") + if (lang) available[lang] = true + } + }) + var options = langSelect.options + for (var i = 1; i < options.length; i++) { + var has = !!available[options[i].value] + options[i].disabled = !has + options[i].style.display = has ? "" : "none" + } + if (langSelect.value && !available[langSelect.value]) langSelect.value = "" + } + + function updatePillAvailability() { + pills.forEach(function (pill) { + var filter = pill.getAttribute("data-filter") + if (filter === null || filter === "") return + var value = pill.getAttribute("data-value") + var has = allItems.some(function (el) { + if (!matchesLang(el)) return false + if (filter === "media") return el.getAttribute("data-media") === value + if (filter === "cat") return el.getAttribute("data-category") === value + return false + }) + pill.style.opacity = has ? "" : "0.3" + }) + } + + function applyFilters() { + filteredItems = getFiltered() + currentPage = 1 + updateLanguageOptions() + updatePillAvailability() + render() + updateHash() + } + + function render() { + var totalPages = Math.max(1, Math.ceil(filteredItems.length / ITEMS_PER_PAGE)) + if (currentPage > totalPages) currentPage = totalPages + var start = (currentPage - 1) * ITEMS_PER_PAGE + var end = start + ITEMS_PER_PAGE + + for (var i = 0; i < allItems.length; i++) allItems[i].style.display = "none" + for (var i = 0; i < filteredItems.length; i++) { + filteredItems[i].style.display = (i >= start && i < end) ? "" : "none" + } + renderPagination(totalPages) + } + + function renderPagination(totalPages) { + pagination.innerHTML = "" + if (totalPages <= 1) return + + var prevBtn = document.createElement("button") + prevBtn.textContent = "Prev" + prevBtn.className = "text-btn" + prevBtn.disabled = currentPage <= 1 + prevBtn.addEventListener("click", function () { goToPage(currentPage - 1) }) + pagination.appendChild(prevBtn) + + var pages = getPaginationRange(currentPage, totalPages) + for (var i = 0; i < pages.length; i++) { + if (pages[i] === "...") { + var span = document.createElement("span") + span.textContent = "…" + span.style.padding = "8px 4px" + pagination.appendChild(span) + } else { + var btn = document.createElement("button") + btn.textContent = String(pages[i]) + if (pages[i] === currentPage) btn.className = "active" + if (Math.abs(pages[i] - currentPage) === 1) btn.classList.add("neighbor") + ;(function (p) { btn.addEventListener("click", function () { goToPage(p) }) })(pages[i]) + pagination.appendChild(btn) + } + } + + var nextBtn = document.createElement("button") + nextBtn.textContent = "Next" + nextBtn.className = "text-btn" + nextBtn.disabled = currentPage >= totalPages + nextBtn.addEventListener("click", function () { goToPage(currentPage + 1) }) + pagination.appendChild(nextBtn) + } + + function goToPage(page) { + currentPage = page + render() + updateHash() + window.scrollTo({ top: document.getElementById("links-page").offsetTop - 80, behavior: "smooth" }) + } + + function getPaginationRange(current, total) { + if (total <= 7) { + var r = [] + for (var i = 1; i <= total; i++) r.push(i) + return r + } + var pages = [1] + if (current > 3) pages.push("...") + var s = Math.max(2, current - 1), e = Math.min(total - 1, current + 1) + for (var i = s; i <= e; i++) pages.push(i) + if (current < total - 2) pages.push("...") + pages.push(total) + return pages + } + + // Hash + function getHashParams() { + var hash = window.location.hash.slice(1) + if (!hash) return {} + var params = {} + hash.split("&").forEach(function (part) { + var kv = part.split("=") + if (kv.length === 2) params[kv[0]] = decodeURIComponent(kv[1]) + }) + return params + } + + function setHash(params) { + var parts = [] + if (params.link) parts.push("link=" + encodeURIComponent(params.link)) + else if (params.page && params.page > 1) parts.push("page=" + params.page) + if (params.lang) parts.push("lang=" + encodeURIComponent(params.lang)) + if (params.media) parts.push("media=" + encodeURIComponent(params.media)) + if (params.cat) parts.push("cat=" + encodeURIComponent(params.cat)) + var hash = parts.join("&") + history.replaceState(null, "", hash ? "#" + hash : window.location.pathname) + } + + function updateHash() { + var params = { page: currentPage, lang: langSelect.value } + if (activeFilter === "media") params.media = activeValue + if (activeFilter === "cat") params.cat = activeValue + setHash(params) + } + + function setActivePill(filter, value) { + activeFilter = filter + activeValue = value + pills.forEach(function (p) { + var pf = p.getAttribute("data-filter") + var pv = p.getAttribute("data-value") + p.classList.toggle("active", pf === filter && (pv || "") === (value || "")) + }) + } + + function readHashAndApply() { + var params = getHashParams() + + if (params.lang) langSelect.value = params.lang + if (params.media) setActivePill("media", params.media) + else if (params.cat) setActivePill("cat", params.cat) + else setActivePill("", "") + + filteredItems = getFiltered() + updateLanguageOptions() + updatePillAvailability() + + if (params.link) { + var target = document.getElementById(params.link) + if (target) { + var idx = filteredItems.indexOf(target) + if (idx === -1) { + setActivePill("", "") + langSelect.value = "" + filteredItems = getFiltered() + updateLanguageOptions() + updatePillAvailability() + idx = filteredItems.indexOf(target) + } + if (idx >= 0) { + currentPage = Math.floor(idx / ITEMS_PER_PAGE) + 1 + render() + setTimeout(function () { + target.scrollIntoView({ behavior: "smooth", block: "start" }) + target.classList.add("highlighted") + setTimeout(function () { target.classList.remove("highlighted") }, 3000) + }, 100) + return + } + } + } + + if (params.page) currentPage = parseInt(params.page) || 1 + render() + } + + // Share anchor + document.getElementById("links-list").addEventListener("click", function (e) { + var anchor = e.target.closest(".share-anchor") + if (!anchor) return + e.preventDefault() + var id = anchor.getAttribute("href").split("=")[1] + var params = { link: id, lang: langSelect.value } + if (activeFilter === "media") params.media = activeValue + if (activeFilter === "cat") params.cat = activeValue + setHash(params) + if (navigator.clipboard) navigator.clipboard.writeText(window.location.href) + }) + + langSelect.addEventListener("change", applyFilters) + + pills.forEach(function (pill) { + pill.addEventListener("click", function () { + var filter = pill.getAttribute("data-filter") + var value = pill.getAttribute("data-value") || "" + if (activeFilter === filter && activeValue === value) { + setActivePill("", "") + } else { + // If pill is greyed out (no items with current language), reset language first + if (pill.style.opacity === "0.3") { + langSelect.value = "" + } + setActivePill(filter, value) + } + applyFilters() + }) + }) + + window.addEventListener("hashchange", readHashAndApply) + readHashAndApply() +}) diff --git a/website/src/js/script.js b/website/src/js/script.js index 5f863f48ee..b26d8b0f7c 100644 --- a/website/src/js/script.js +++ b/website/src/js/script.js @@ -26,7 +26,8 @@ const uniqueSwiper = new Swiper('.unique-swiper', { const isMobile = { Android: () => navigator.userAgent.match(/Android/i), - iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i) + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i), + any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i) }; const privateSwiper = new Swiper('.private-swiper', { @@ -129,7 +130,7 @@ if (isMobile.iOS) { } function clickHandler(e) { - if (e.target.closest('.card')) { + if (e.target.closest('.card') && !e.target.closest('[data-xftp-app]')) { e.target.closest('.card').classList.toggle('card-active'); e.target.closest('.card').classList.toggle('no-hover'); } @@ -181,7 +182,7 @@ function openOverlay() { if (hash) { const id = hash.split('#')[1]; const el = document.getElementById(id) - if (el.classList.contains('overlay')) { + if (el && el.classList.contains('overlay')) { const scrollTo = el.getAttribute('data-scroll-to') if (scrollTo) { const scrollToEl = document.getElementById(scrollTo) diff --git a/website/src/links.html b/website/src/links.html new file mode 100644 index 0000000000..c28ee07568 --- /dev/null +++ b/website/src/links.html @@ -0,0 +1,303 @@ +--- +layout: layouts/main.html +title: "SimpleX Chat Links" +description: "Reviews, articles, videos, podcasts, and community content about SimpleX Chat" +permalink: /links/ +templateEngineOverride: njk +active_links: true +--- +{% set lang = page.url | getlang %} +{% block css_links %} + +{% endblock %} + +{% block js_scripts %} + +{% endblock %} + + diff --git a/website/src/livestream.html b/website/src/livestream.html deleted file mode 100644 index ee7a96ab30..0000000000 --- a/website/src/livestream.html +++ /dev/null @@ -1,8 +0,0 @@ ---- -layout: layouts/group_link.html -title: "SimpleX Chat: Power to the People" -description: "Join the group for livestream Q&A" -groupLink: "https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FSkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w%3D%40smp9.simplex.im%2FoVQ-kg2rjMRituleO6t26DhQDPW6OjLL%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEATIRrsU4GwjpF6SeMWa6Li20Rkibgu4ozZMADZfdAZzE%253D%26srv%3Djssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion" -groupLinkText: Open Livestream Q&A group link -templateEngineOverride: njk ---- \ No newline at end of file diff --git a/website/src/messaging.html b/website/src/messaging.html new file mode 100644 index 0000000000..4ab6c1c2cd --- /dev/null +++ b/website/src/messaging.html @@ -0,0 +1,331 @@ +--- +layout: layouts/main.html +title: "SimpleX Chat: The World's Most Secure Messaging" +description: "SimpleX Chat - a private and encrypted messenger without any user IDs (not even random ones)! Make a private connection via link / QR code to send messages and make calls." +templateEngineOverride: njk +--- +{%- from "components/macro.njk" import overlay -%} + +
    +
    + {% mdInclude "sections/messaging.md" %} +
    +
    + +{# Why SimpleX is unique #} +{% include "sections/simplex_unique.html" %} + +{# Features #} +
    +
    +

    {{ "features" | i18n({}, lang ) | safe }}

    + +
    + {% for feature in features.sections %} +
    +
    + + +
    +

    {{ feature.title | i18n({}, lang ) | safe }}

    +
    + {% endfor %} +
    +
    +
    + +{# what makes simplex private #} +
    +
    +

    {{ "simplex-private-section-header" | i18n({}, lang ) | safe }}

    + +
    +
    + + {% for section in what_makes_simplex_private.sections %} +
    +
    + + +
    +
    +

    {{ section.title | i18n({}, lang ) | safe }}

    +
    + {% for point in section.points %} +

    {{ point | i18n({}, lang ) | safe }}

    + {% endfor %} +
    +

    {{ "tap-to-close" | i18n({}, lang ) | safe }}

    +
    +
    + {% endfor %} +
    + + + + + + + + + + + +
    +
    +
    +
    + +{# Network #} +
    +
    +

    {{ "simplex-network-section-header" | i18n({}, lang ) | safe }}

    +

    {{ "simplex-network-section-desc" | i18n({}, lang ) | safe }}

    + +
    +
    +
    + + +
    +
    +

    {{ "simplex-network-1-header" | i18n({}, lang ) | safe }}

    +

    + {{ "simplex-network-1-desc" | i18n({}, lang ) | safe }} {{ "simplex-network-1-overlay-linktext" | i18n({}, lang ) | safe }}. + {{ overlay(simplex_network_overlay.sections[0],lang) }} +

    +
    +
    + + + +
    +
    + + +
    +
    +

    {{ "simplex-network-2-header" | i18n({}, lang ) | safe }}

    +

    + {{ "simplex-network-2-desc" | i18n({}, lang ) | safe }} +

    +
    +
    + + + +
    +
    + + +
    +
    +

    {{ "simplex-network-3-header" | i18n({}, lang ) | safe }}

    +

    + {{ "simplex-network-3-desc" | i18n({}, lang ) | safe }} +

    +
    +
    + +
    +
    + +
    + + +{# simplex explained #} +{% include "simplex_explained.html" %} + + +{# Comparison #} +
    +
    +

    {{ "comparison-section-header" | i18n({}, lang ) | safe }}

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + simplex logo + + {{ "protocol-1-text" | i18n({}, lang ) | safe }}{{ "protocol-2-text" | i18n({}, lang ) | safe }}{{ "protocol-3-text" | i18n({}, lang ) | safe }}
    {{ "comparison-point-1-text" | i18n({}, lang ) | safe }}{{ "no-private" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }} 1{{ "yes" | i18n({}, lang ) | safe }} 2{{ "yes" | i18n({}, lang ) | safe }} 3
    {{ "comparison-point-2-text" | i18n({}, lang ) | safe }}{{ "no-secure" | i18n({}, lang ) | safe }} 4{{ "yes" | i18n({}, lang ) | safe }} 5{{ "yes" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}
    {{ "comparison-point-3-text" | i18n({}, lang ) | safe }}{{ "no-resilient" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}{{ "no" | i18n({}, lang ) | safe }}
    {{ "comparison-point-4-text" | i18n({}, lang ) | safe }}{{ "no-decentralized" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}{{ "no-federated" | i18n({}, lang ) | safe }} 6{{ "yes" | i18n({}, lang ) | safe }} 7
    {{ "comparison-point-5-text" | i18n({}, lang ) | safe }}{{ "no-resilient" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }} 2{{ "yes" | i18n({}, lang ) | safe }} 8
    +
    + +
    + +
    +
    +
      +
    1. {{ "comparison-section-list-point-1" | i18n({}, lang ) | safe }}
    2. +
    3. {{ "comparison-section-list-point-2" | i18n({}, lang ) | safe }}
    4. +
    5. {{ "comparison-section-list-point-3" | i18n({}, lang ) | safe }}
    6. +
    7. {{ "comparison-section-list-point-4a" | i18n({}, lang ) | safe }}
    8. +
    9. {{ "comparison-section-list-point-4" | i18n({}, lang ) | safe }}
    10. +
    11. {{ "comparison-section-list-point-5" | i18n({}, lang ) | safe }}
    12. +
    13. {{ "comparison-section-list-point-6" | i18n({}, lang ) | safe }}
    14. +
    15. {{ "comparison-section-list-point-7" | i18n({}, lang ) | safe }} — {{ "see-here" | i18n({}, lang ) | safe }}
    16. +
    +
    +
    +
    +
    + +
    +
    +

    {{ "how-secure-comparison-title" | i18n({}, lang ) | safe }}

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + simplex logo +

    Session

    +
    + simplex logo +

    Briar

    +
    + simplex logo +

    Element

    +
    + simplex logo +

    Cwtch

    +
    + simplex logo +

    Signal

    +
    + simplex logo + +

    SimpleX

    +
    {{ "how-secure-message-padding" | i18n({}, lang ) | safe }}✔︎1✔︎✔︎1✔︎
    {{ "how-secure-repudiation-deniability" | i18n({}, lang ) | safe }}✔︎2✔︎3✔︎
    {{ "how-secure-forward-secrecy" | i18n({}, lang ) | safe }}✔︎✔︎✔︎✔︎✔︎
    {{ "how-secure-break-in-recovery" | i18n({}, lang ) | safe }}✔︎4✔︎
    {{ "how-secure-two-factor-key-exchange" | i18n({}, lang ) | safe }}✔︎✔︎5✔︎5✔︎✔︎5✔︎
    {{ "how-secure-post-quantum-hybrid-crypto" | i18n({}, lang ) | safe }}✔︎6✔︎
    +
    + +
    + +
    +
    +
      +
    1. {{ "messengers-comparison-section-list-point-1" | i18n({}, lang ) | safe }}
    2. +
    3. {{ "messengers-comparison-section-list-point-2" | i18n({}, lang ) | safe }}
    4. +
    5. {{ "messengers-comparison-section-list-point-3" | i18n({}, lang ) | safe }}
    6. +
    7. {{ "messengers-comparison-section-list-point-4" | i18n({}, lang ) | safe }} — {{ "see-here" | i18n({}, lang ) | safe }}.
    8. +
    9. {{ "messengers-comparison-section-list-point-5" | i18n({}, lang ) | safe }}
    10. +
    11. {{ "messengers-comparison-section-list-point-6" | i18n({}, lang ) | safe }}
    12. +
    +
    +
    +
    +
    + +{# join simplex #} +{# {% include "sections/join_simplex.html" %} #} \ No newline at end of file diff --git a/website/src/old.html b/website/src/old.html new file mode 100644 index 0000000000..ff86020d4a --- /dev/null +++ b/website/src/old.html @@ -0,0 +1,244 @@ +--- +layout: layouts/main.html +title: "SimpleX Chat: private and secure messenger without any user IDs (not even random)" +description: "SimpleX Chat - a private and encrypted messenger without any user IDs (not even random ones)! Make a private connection via link / QR code to send messages and make calls." +templateEngineOverride: njk +active_home: true +--- +{%- from "components/macro.njk" import overlay -%} + +{% include "hero.html" %} + +
    +
    +

    {{ "privacy-matters-section-header" | i18n({}, lang ) | safe }}

    +

    {{ "privacy-matters-section-subheader" | i18n({}, lang ) | safe }}

    +
    + + {% for section in why_privacy_matters.sections %} +
    +
    + +
    +
    +

    {{ section.title | i18n({}, lang ) | safe }}

    + {% if section.overlayContent %} + {{ section.overlayContent.linkText | i18n({}, lang ) | safe }} + {{ overlay(section,lang) }} + {% endif %} +
    +
    + {% endfor %} + +
    +

    {{ "privacy-matters-section-label" | i18n({}, lang ) | safe }}

    +
    +
    + +{# Why SimpleX is unique #} +{% include "sections/simplex_unique.html" %} + +{# Features #} +
    +
    +

    {{ "features" | i18n({}, lang ) | safe }}

    + +
    + {% for feature in features.sections %} +
    +
    + + +
    +

    {{ feature.title | i18n({}, lang ) | safe }}

    +
    + {% endfor %} +
    +
    +
    + +{# what makes simplex private #} +
    +
    +

    {{ "simplex-private-section-header" | i18n({}, lang ) | safe }}

    + +
    +
    + + {% for section in what_makes_simplex_private.sections %} +
    +
    + + +
    +
    +

    {{ section.title | i18n({}, lang ) | safe }}

    +
    + {% for point in section.points %} +

    {{ point | i18n({}, lang ) | safe }}

    + {% endfor %} +
    +

    {{ "tap-to-close" | i18n({}, lang ) | safe }}

    +
    +
    + {% endfor %} +
    + + + + + + + + + + + +
    +
    +
    +
    + +{# Network #} +
    +
    +

    {{ "simplex-network-section-header" | i18n({}, lang ) | safe }}

    +

    {{ "simplex-network-section-desc" | i18n({}, lang ) | safe }}

    + +
    +
    +
    + + +
    +
    +

    {{ "simplex-network-1-header" | i18n({}, lang ) | safe }}

    +

    + {{ "simplex-network-1-desc" | i18n({}, lang ) | safe }} {{ "simplex-network-1-overlay-linktext" | i18n({}, lang ) | safe }}. + {{ overlay(simplex_network_overlay.sections[0],lang) }} +

    +
    +
    + + + +
    +
    + + +
    +
    +

    {{ "simplex-network-2-header" | i18n({}, lang ) | safe }}

    +

    + {{ "simplex-network-2-desc" | i18n({}, lang ) | safe }} +

    +
    +
    + + + +
    +
    + + +
    +
    +

    {{ "simplex-network-3-header" | i18n({}, lang ) | safe }}

    +

    + {{ "simplex-network-3-desc" | i18n({}, lang ) | safe }} +

    +
    +
    + +
    +
    + +
    + + +{# simplex explained #} +{% include "simplex_explained.html" %} + + +{# Comparison #} +
    +
    +

    {{ "comparison-section-header" | i18n({}, lang ) | safe }}

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + simplex logo + + {{ "protocol-1-text" | i18n({}, lang ) | safe }}{{ "protocol-2-text" | i18n({}, lang ) | safe }}{{ "protocol-3-text" | i18n({}, lang ) | safe }}
    {{ "comparison-point-1-text" | i18n({}, lang ) | safe }}{{ "no-private" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }} 1{{ "yes" | i18n({}, lang ) | safe }} 2{{ "yes" | i18n({}, lang ) | safe }} 3
    {{ "comparison-point-2-text" | i18n({}, lang ) | safe }}{{ "no-secure" | i18n({}, lang ) | safe }} 4{{ "yes" | i18n({}, lang ) | safe }} 5{{ "yes" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}
    {{ "comparison-point-3-text" | i18n({}, lang ) | safe }}{{ "no-resilient" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}{{ "no" | i18n({}, lang ) | safe }}
    {{ "comparison-point-4-text" | i18n({}, lang ) | safe }}{{ "no-decentralized" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}{{ "no-federated" | i18n({}, lang ) | safe }} 6{{ "yes" | i18n({}, lang ) | safe }} 7
    {{ "comparison-point-5-text" | i18n({}, lang ) | safe }}{{ "no-resilient" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }}{{ "yes" | i18n({}, lang ) | safe }} 2{{ "yes" | i18n({}, lang ) | safe }} 8
    +
    + +
    + +
    +
    +
      +
    1. {{ "comparison-section-list-point-1" | i18n({}, lang ) | safe }}
    2. +
    3. {{ "comparison-section-list-point-2" | i18n({}, lang ) | safe }}
    4. +
    5. {{ "comparison-section-list-point-3" | i18n({}, lang ) | safe }}
    6. +
    7. {{ "comparison-section-list-point-4a" | i18n({}, lang ) | safe }}
    8. +
    9. {{ "comparison-section-list-point-4" | i18n({}, lang ) | safe }}
    10. +
    11. {{ "comparison-section-list-point-5" | i18n({}, lang ) | safe }}
    12. +
    13. {{ "comparison-section-list-point-6" | i18n({}, lang ) | safe }}
    14. +
    15. {{ "comparison-section-list-point-7" | i18n({}, lang ) | safe }} - {{ "see-here" | i18n({}, lang ) | safe }}
    16. +
    +
    +
    +
    +
    + +{# join simplex #} +{% include "sections/join_simplex.html" %} + + diff --git a/website/src/token.html b/website/src/token.html new file mode 100644 index 0000000000..0949e2f2ce --- /dev/null +++ b/website/src/token.html @@ -0,0 +1,8 @@ +--- +layout: layouts/redirect.html +title: "SimpleX Community Credits" +description: "" +destinationURI: "/credits/" +destinationText: SimpleX Community Credits +templateEngineOverride: njk +--- diff --git a/website/src/token.md b/website/src/token.md new file mode 100644 index 0000000000..eab17edee6 --- /dev/null +++ b/website/src/token.md @@ -0,0 +1,131 @@ +--- +layout: layouts/token.html +title: "SimpleX Community Credits" +permalink: "/credits/index.html" +--- + +# SimpleX Crowdfunding to Build Community Credits: Strategy & Vision + + + +SimpleX is a private and secure messaging network without user IDs where you own your identity, contacts, groups, and content — there are no ads, no tracking, and no central authority. It relies on open protocols and open-source code, enabling anyone to audit the code and to create alternative apps and servers. No single entity can control it. + +To scale for large groups and channels, without relying on any single entity, network needs a sustainable way to fund servers. + +Take money from advertisers and you become a surveillance company. Take money from a single large investor and you hand them the kill switch. Community Credits offer the solution — they are prepaid infrastructure credits for servers used by groups and channels. + +These credits are not tradable tokens or speculative assets — there will be no pre-sale or emission. It's a method to pay directly for the network infrastructure while maintaining privacy. + +## Crowdfunding to Build Community Credits + +Building Community Credits requires more capital than we have. So we will raise capital in the same way we designed the network: decentralized, community-funded, with no single point of capture. If you use SimpleX, you can own part of what you already help build. Register your interest via [this form](https://simplexchat.typeform.com/crowdfunding) and join [SimpleX Crowdfunding News channel](https://smp10.simplex.im/c#q09nMBmWFGz1m2TvgfZFaEOG5D2a7Ma9mSkl6pHXEsg) for updates. + +_Disclaimer: SimpleX Chat is testing the waters for a possible Reg CF offering. We’re not asking for or accepting any money right now, and we won’t accept any if sent. We can’t accept any offers to buy securities or take any payments until the official filing is done and it’s live through a regulated platform. Our testing the waters and your possible indications of interest doesn’t create any obligation or commitment of any kind._ + +## Why Community Credits? + +To pay for network infrastructure securely and privately. + +With "free" centralized platforms: +- you lose security and privacy, because your data is used for advertising and sold. +- they de-platform inconvenient users, often based on frivolous complaints. +- you don't own all rights to your content. + +Paying for server capacity may be cheaper than "free" platforms. + +## How Will It Work? + +In short: + + + + + +- Buy Community Credits. Initially you would pay with a stablecoin (USDT/USDC). The goal is to allow using other popular cryptocurrencies (BTC/ETH/XMR) and also in-app payments - to make direct usage of blockchain optional for the end users. + +- Funds are locked in an autonomous smart contract not controlled by SimpleX Chat company or by anybody else. + +- Assign Community Credits to a group or channel you want, using its public address. This assignment is private, and group owners or server operators won't be able to link it to the purchase, thanks to zero-knowledge proofs. + +- Group or channel owners redeem the Credits to the server operators they use. The redemption is also private, and not linkable to the assignment or purchase. + +- Server operators receive up to 70% of the unlocked funds, with the rest being allocated to network development and governance. + +## Why Blockchain? + +It's the only way to make SimpleX network truly decentralized and secure: + +- Group and user names resistant to man-in-the-middle attacks. + +- Public registry of network operators, with their trust scores. + +- Private and secure payments based on zero-knowledge proofs in smart-contracts. + +We are currently evaluating several popular blockchains that have strong support for zero-knowledge proofs - technology that supports private operations on public blockchains. + +## Timeline & How to Get Involved + +**2025-26**: +- evaluating blockchains, +- drafting Community Credits whitepaper about system and cryptography design for Community Credits. +- development of large groups and communities. + +We welcome your feedback on this proposal and any in-progress design documents. + +**2026-27**: +- launch support for large groups and channels. +- test version of Community Credits. +- SimpleX network namespace v1. + +**Join in**: +- Create a small group or channel using today's tech, and get it added to our experimental directory of groups. +- Talk to us if you want to be a server operator to earn revenue and about any partnerships. + +## Community Credits FAQ + +**Will self-hosted servers still work?** + +Yes! Support for self-hosted servers will be improved, and they can be used together with paid servers, for better reliability and censorship-resistance. + +**Why not just use existing cryptocurrency?** + +Cryptocurrencies are: +- Speculative and volatile. +- Regulated as financial transactions. +- BTC and XMR blockchains do not support smart contract logic, e.g. for locked funds. + +**How can it be private on a public blockchain?** + +High level of privacy is achieved by new address per purchase, proxied access to blockchain, and zero-knowledge proofs that make payment and usage unlinkable. + +**Can I sell or transfer Credits?** + +No, Community Credits cannot be sold or transferred. Once purchased and assigned to a group or channel, they can only be redeemed to server operators. They will expire in 12 months if not redeemed, with the funds released to network development and governance. + +**Free messaging limits?** + +Private chats and small groups remain free within fair use (up to 128 undelivered messages per contact, with up to 21 days storage, up to 1GB files stored for 2 days). Community Credits will be used to pay for large groups infrastructure and for memorable public names. + +**Who controls the smart contracts?** + +We will control them during testing. Once released for general access, the contracts that accept and hold funds will be autonomous and immutable. + +**How is revenue share of server operators determined?** + +Server operators will receive up to 70% of the infrastructure payments. A higher share will be allocated for: +- identified operators, because knowing who runs the servers achieves better user privacy and security, +- more reliable servers, +- servers that operated for a longer time. + +**What is the technology design?** + +[The conceptual design](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2025-12-10-vouchers-2.md) for Community Credits uses zero-knowledge proofs, making the purchase, assigning credits to groups and their redemptions unlinkable. + +A whitepaper will be published in June 2026. + + +## Disclaimer + +This design is evolving — please share your feedback. + +This is not an investment offer. All details are subject to legal review. diff --git a/website/src/vouchers.html b/website/src/vouchers.html new file mode 100644 index 0000000000..0949e2f2ce --- /dev/null +++ b/website/src/vouchers.html @@ -0,0 +1,8 @@ +--- +layout: layouts/redirect.html +title: "SimpleX Community Credits" +description: "" +destinationURI: "/credits/" +destinationText: SimpleX Community Credits +templateEngineOverride: njk +--- diff --git a/website/src/why.html b/website/src/why.html new file mode 100644 index 0000000000..e16034106a --- /dev/null +++ b/website/src/why.html @@ -0,0 +1,51 @@ +--- +layout: layouts/main.html +title: "Why we are building SimpleX Network" +description: "A network with no accounts, no identities, and no way to know who you are." +templateEngineOverride: njk +noGlossary: true +--- + +{% set lang = page.url | getlang %} + +
    +
    + +

    + {{ "why-p1" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p2" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p3" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p4" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p5" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p6" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p7" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p8" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-tagline" | i18n({}, lang) | safe }} +

    + +
    +
    diff --git a/website/tailwind.config.js b/website/tailwind.config.js index 515e701796..2e645a439a 100644 --- a/website/tailwind.config.js +++ b/website/tailwind.config.js @@ -1,11 +1,11 @@ module.exports = { darkMode : 'class', - content: ["./src/**/*.{html,js,njk}"], + content: ["./src/**/*.{html,js,njk}", "!./src/file-assets/**"], theme: { extend: { backgroundImage: { - 'gradient-radial': 'radial-gradient(88.77% 102.03% at 92.64% -13.22%, #17203D 0%, #0C0B13 100%)', - 'gradient-radial-mobile': 'radial-gradient(77.4% 73.09% at -3.68% 100%, #17203D 0%, #0C0B13 100%)', + 'gradient-radial': 'radial-gradient(88.77% 102.03% at 92.64% -13.22%, #071C46 0%, #000832 100%)', + 'gradient-radial-mobile': 'radial-gradient(77.4% 73.09% at -3.68% 100%, #071C46 0%, #000832 100%)', }, colors: { 'primary-light': '#0053D0', @@ -18,16 +18,16 @@ module.exports = { 'active-blue': '#0197FF', 'black': '#0D0E12', 'grey-black': '#3F484B', - 'secondary-bg-light': '#F3F6F7', + 'secondary-bg-light': '#F3FAFF', 'primary-bg-light': '#FFFFFF', - 'secondary-bg-dark': '#11182F', - 'primary-bg-dark': '#0C0B13', + 'secondary-bg-dark': '#0B2A59', + 'primary-bg-dark': '#000832', // What makes SimpleX private 'card-bg-light': '#ffffff', - 'card-desc-bg-light': '#D9E7ED', - 'card-bg-dark': '#17203D', + 'card-desc-bg-light': '#DBEEFF', + 'card-bg-dark': '#071C46', 'card-desc-bg-dark': '#1B325C', } }, diff --git a/website/web.sh b/website/web.sh index 897c87743e..9464982a45 100755 --- a/website/web.sh +++ b/website/web.sh @@ -1,14 +1,20 @@ #!/bin/bash set -e +# Eleventy OOMs with default 2GB V8 heap when building 280+ pages across 23 languages +export NODE_OPTIONS=--max-old-space-size=4096 cp -R docs website/src +rm -rf website/src/docs/contributing rm -rf website/src/docs/rfcs rm website/src/docs/lang/*/README.md rm -rf website/src/docs/dependencies +rm -f website/src/docs/LINKS.md +cp -R docs/links/images website/src/link-images 2>/dev/null || true cp -R blog website/src cp -R images website/src rm website/src/blog/README.md +rm -rf website/src/blog/new cp PRIVACY.md website/src/privacy.md cd website @@ -25,6 +31,12 @@ done npm install cp node_modules/lottie-web/build/player/lottie.min.js src/js +cp node_modules/ethers/dist/ethers.umd.min.js src/js +cp node_modules/ethers/dist/ethers.umd.js.map src/js +mkdir -p src/file-assets +cp node_modules/@simplex-chat/xftp-web/dist-web/assets/index.js src/file-assets/ +cp node_modules/@simplex-chat/xftp-web/dist-web/assets/index.css src/file-assets/ +cp node_modules/@simplex-chat/xftp-web/dist-web/assets/crypto.worker.js src/file-assets/ node merge_translations.js node customize_docs_frontmatter.js @@ -32,9 +44,13 @@ node customize_docs_frontmatter.js for lang in "${langs[@]}"; do mkdir -p src/$lang cp src/index.html src/$lang + cp src/old.html src/$lang + cp src/messaging.html src/$lang cp src/contact.html src/$lang cp src/invitation.html src/$lang cp src/fdroid.html src/$lang + cp src/why.html src/$lang + cp src/file.html src/$lang echo "{\"lang\":\"$lang\"}" > src/$lang/$lang.json echo "done $lang copying" done @@ -64,6 +80,6 @@ done # val_json_obj=$(echo "$val_json_obj" | jq ". + {$lang: $val}") # fi # done -# main_json_obj=$(echo "$main_json_obj" | jq ". + {\"$key\": $val_json_obj}") +# main_json_obj=$(echo "$main_json_obj" | jq ". + {\"$key\": $val_json_obj}") # done -# echo "$main_json_obj" > translations.json \ No newline at end of file +# echo "$main_json_obj" > translations.json